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/bubbles.py ADDED
@@ -0,0 +1,133 @@
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
+
16
+ def _wrap(text: str, max_chars: int) -> list[str]:
17
+ lines, cur = [], ""
18
+ for word in text.split():
19
+ if cur and len(cur) + 1 + len(word) > max_chars:
20
+ lines.append(cur)
21
+ cur = word
22
+ else:
23
+ cur = f"{cur} {word}".strip()
24
+ if cur:
25
+ lines.append(cur)
26
+ return lines or [""]
27
+
28
+
29
+ def _text_block(lines, cx, top, fs, lh):
30
+ spans = []
31
+ for i, ln in enumerate(lines):
32
+ spans.append(
33
+ f'<tspan x="{cx:.1f}" y="{top + fs + i * lh:.1f}">{escape(ln)}</tspan>'
34
+ )
35
+ weight = ""
36
+ return (
37
+ f'<text text-anchor="middle" font-family="{FONT}" '
38
+ f'font-size="{fs}" fill="{INK}" {weight}>{"".join(spans)}</text>'
39
+ )
40
+
41
+
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
49
+ x, y = bx - w / 2, by - h / 2
50
+ txt = _text_block(lines, bx, y + pad, fs, lh)
51
+
52
+ if kind == "shout":
53
+ body = _burst(x, y, w, h)
54
+ elif kind == "thought":
55
+ 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"/>')
58
+ 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"/>')
62
+
63
+ tail_svg = ""
64
+ if tail is not None:
65
+ tail_svg = _tail(bx, by, w, h, tail, kind)
66
+
67
+ return f'<g>{body}{tail_svg}{txt}</g>'
68
+
69
+
70
+ def _tail(bx, by, w, h, tail, kind):
71
+ """A slim tail that drops from the bubble underside and points toward the
72
+ target — but stops well short of it, so the tip never reaches the figure."""
73
+ tx, ty = tail
74
+ # exit from the bottom edge, nudged horizontally toward the target but kept
75
+ # under the bubble body
76
+ ex = min(max(tx, bx - w * 0.3), bx + w * 0.3)
77
+ ey = by + h / 2
78
+ dx, dy = tx - ex, ty - ey
79
+ dist = math.hypot(dx, dy) or 1.0
80
+ reach = min(dist * 0.45, 46) # capped length keeps the tip off the figure
81
+ ux, uy = dx / dist, dy / dist
82
+ tipx, tipy = ex + ux * reach, ey + uy * reach
83
+
84
+ if kind == "thought":
85
+ dots = ""
86
+ for f in (0.45, 0.74, 1.0):
87
+ 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"/>')
91
+ return dots
92
+ # narrow tapered tail for speech/shout
93
+ perp = math.atan2(uy, ux) + math.pi / 2
94
+ base = 6
95
+ ax = ex + math.cos(perp) * base
96
+ ay = ey + math.sin(perp) * base
97
+ bx2 = ex - math.cos(perp) * base
98
+ by2 = ey - math.sin(perp) * base
99
+ return (
100
+ f'<path d="M{ax:.1f} {ay:.1f} L{tipx:.1f} {tipy:.1f} '
101
+ f'L{bx2:.1f} {by2:.1f} Z" fill="#ffffff" stroke="{INK}" '
102
+ f'stroke-width="2.5" stroke-linejoin="round"/>'
103
+ )
104
+
105
+
106
+ def _cloud(x, y, w, h):
107
+ # rounded body + scalloped top edge via overlapping circles
108
+ 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"/>')
111
+ bumps = ""
112
+ n = max(3, int(w // 34))
113
+ for i in range(n):
114
+ 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"/>')
117
+ # 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"/>')
120
+ return body + bumps + cover + body.replace('fill="#ffffff"', 'fill="none"')
121
+
122
+
123
+ def _burst(x, y, w, h):
124
+ cx, cy = x + w / 2, y + h / 2
125
+ rx, ry = w / 2 + 8, h / 2 + 8
126
+ n = 18
127
+ pts = []
128
+ for i in range(n * 2):
129
+ a = math.pi * i / n
130
+ rr = 1.0 if i % 2 == 0 else 0.78
131
+ 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"/>')
comicforge/cli.py ADDED
@@ -0,0 +1,396 @@
1
+ """Command line interface.
2
+
3
+ python -m comicforge render examples/pes/pages/slepice.yaml -o out.pdf
4
+ python -m comicforge scene examples/pes/pages/dvur-scene.yaml -o out.png
5
+ python -m comicforge characters --library examples/pes/characters
6
+ python -m comicforge scenes --scenes examples/pes/scenes
7
+ python -m comicforge inspire examples/pes/references.yaml --review
8
+
9
+ When `-o` is omitted, render/scene/panel/character write a timestamped file into
10
+ the gitignored ``output/`` dir (so successive renders accumulate for comparison).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import json
17
+ import sys
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+
21
+ from rich import print as rich_print
22
+
23
+ from .library import Library
24
+ from .pixelart import PixelLibrary
25
+ from .render import (
26
+ render_all_panels,
27
+ render_character,
28
+ render_panel,
29
+ render_scene,
30
+ render_spec,
31
+ )
32
+ from .scene import SceneLibrary
33
+ from .validate import validate_spec
34
+
35
+ # Where renders land when -o is omitted. Gitignored; filenames carry a timestamp
36
+ # so successive renders accumulate and you can watch a page/character evolve.
37
+ OUTPUT_DIR = Path("output")
38
+
39
+
40
+ def _stamp() -> str:
41
+ return datetime.now(UTC).astimezone().strftime("%Y%m%d-%H%M%S")
42
+
43
+
44
+ def _default_out(stem: str, ext: str = ".png") -> Path:
45
+ """A timestamped path inside the gitignored output dir."""
46
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
47
+ return OUTPUT_DIR / f"{stem}-{_stamp()}{ext}"
48
+
49
+
50
+ def _parse_selection(char, tokens, pose):
51
+ """Turn CLI tokens into a (selection, pose) pair for one character.
52
+
53
+ A token is either ``key=value`` (``pose=walk``, ``face=happy``) or a bare
54
+ name that is matched against the character's poses, then its slot variants.
55
+ """
56
+ # pass 1: settle the pose first, so variant lookup uses the right slots
57
+ for tok in tokens:
58
+ if "=" in tok:
59
+ k, v = tok.split("=", 1)
60
+ if k == "pose":
61
+ pose = v
62
+ elif tok in char.poses:
63
+ pose = tok
64
+
65
+ slots = char.slots_for(pose)
66
+ selection: dict[str, str] = {}
67
+ for tok in tokens:
68
+ if "=" in tok:
69
+ k, v = tok.split("=", 1)
70
+ if k != "pose":
71
+ selection[k] = v
72
+ elif tok in char.poses:
73
+ continue
74
+ else:
75
+ slot = next((s for s, variants in slots.items() if tok in variants), None)
76
+ if slot is None:
77
+ raise SystemExit(
78
+ f"don't know '{tok}' for {char.name}: "
79
+ f"not a pose {list(char.poses)} nor a variant in {slots}"
80
+ )
81
+ selection[slot] = tok
82
+ return selection, pose
83
+
84
+
85
+ def main(argv=None): # noqa: PLR0912, PLR0915 — flat CLI dispatcher; clearer linear
86
+ p = argparse.ArgumentParser(prog="comicforge")
87
+ sub = p.add_subparsers(dest="cmd", required=True)
88
+
89
+ init = sub.add_parser(
90
+ "init", help="scaffold a new data-only project (no Python needed)"
91
+ )
92
+ init.add_argument(
93
+ "dest", type=Path, help="directory to create the project in (e.g. ./my-comic)"
94
+ )
95
+ init.add_argument(
96
+ "--force",
97
+ action="store_true",
98
+ help="overwrite existing files (default: skip files that already exist)",
99
+ )
100
+
101
+ r = sub.add_parser("render", help="render a comic page spec")
102
+ r.add_argument("spec", type=Path)
103
+ r.add_argument(
104
+ "-o",
105
+ "--out",
106
+ type=Path,
107
+ default=None,
108
+ help="output .svg / .png / .pdf (default: output/<spec>-<timestamp>.png)",
109
+ )
110
+ r.add_argument("--library", type=Path, default=None)
111
+ r.add_argument("--scenes", type=Path, default=None)
112
+ r.add_argument(
113
+ "--pixel-dir",
114
+ type=Path,
115
+ default=None,
116
+ dest="pixel_dir",
117
+ help="pixel-art sprite library directory",
118
+ )
119
+
120
+ v = sub.add_parser(
121
+ "validate", help="check a page/scene spec against its libraries (no render)"
122
+ )
123
+ v.add_argument("spec", type=Path)
124
+ v.add_argument("--library", type=Path, default=None)
125
+ v.add_argument("--scenes", type=Path, default=None)
126
+ v.add_argument(
127
+ "--pixel-dir",
128
+ type=Path,
129
+ default=None,
130
+ dest="pixel_dir",
131
+ help="pixel-art sprite library directory",
132
+ )
133
+
134
+ s = sub.add_parser("scene", help="render a standalone scene illustration")
135
+ s.add_argument("spec", type=Path)
136
+ s.add_argument(
137
+ "-o",
138
+ "--out",
139
+ type=Path,
140
+ default=None,
141
+ help="output .svg / .png / .pdf (default: output/<spec>-<timestamp>.png)",
142
+ )
143
+ s.add_argument("--library", type=Path, default=None)
144
+ s.add_argument("--scenes", type=Path, default=None)
145
+ s.add_argument(
146
+ "--pixel-dir",
147
+ type=Path,
148
+ default=None,
149
+ dest="pixel_dir",
150
+ help="pixel-art sprite library directory",
151
+ )
152
+
153
+ pn = sub.add_parser(
154
+ "panel", help="render individual panel(s) at low res for review"
155
+ )
156
+ pn.add_argument("spec", type=Path)
157
+ pn.add_argument(
158
+ "-o",
159
+ "--out",
160
+ type=Path,
161
+ default=None,
162
+ help="output file, or a directory when --all "
163
+ "(default: timestamped path under output/)",
164
+ )
165
+ pn.add_argument("--row", type=int, default=0)
166
+ pn.add_argument("--col", type=int, default=0)
167
+ pn.add_argument(
168
+ "--all", action="store_true", help="render every panel into the -o directory"
169
+ )
170
+ pn.add_argument(
171
+ "--scale",
172
+ type=float,
173
+ default=0.5,
174
+ help="size vs full-page panel (default 0.5 = low res)",
175
+ )
176
+ pn.add_argument("--library", type=Path, default=None)
177
+ pn.add_argument("--scenes", type=Path, default=None)
178
+ pn.add_argument(
179
+ "--pixel-dir",
180
+ type=Path,
181
+ default=None,
182
+ dest="pixel_dir",
183
+ help="pixel-art sprite library directory",
184
+ )
185
+
186
+ ch = sub.add_parser(
187
+ "character", help="render a single character on its own, for review"
188
+ )
189
+ ch.add_argument("name", help="character to render")
190
+ ch.add_argument(
191
+ "selections",
192
+ nargs="*",
193
+ help="pose and slot variants as bare names (sit, happy) or key=value "
194
+ "(face=happy, pose=walk)",
195
+ )
196
+ ch.add_argument(
197
+ "-o",
198
+ "--out",
199
+ type=Path,
200
+ default=None,
201
+ help="output .svg / .png / .pdf (default: output/<name>-<timestamp>.png)",
202
+ )
203
+ ch.add_argument(
204
+ "--library", type=Path, required=True, help="character library directory"
205
+ )
206
+ ch.add_argument(
207
+ "--pose", default=None, help="pose to render (default: character's)"
208
+ )
209
+ ch.add_argument(
210
+ "--scale", type=float, default=2.0, help="px per viewBox unit (default 2)"
211
+ )
212
+ ch.add_argument("--bg", default="#ffffff", help="canvas background colour")
213
+ ch.add_argument("--flip", action="store_true", help="mirror horizontally")
214
+ ch.add_argument(
215
+ "--thumb-px",
216
+ type=int,
217
+ default=320,
218
+ dest="thumb_px",
219
+ help="body width of the small companion PNG (0 to skip it)",
220
+ )
221
+
222
+ c = sub.add_parser("characters", help="print the character library contract")
223
+ c.add_argument(
224
+ "--library",
225
+ type=Path,
226
+ required=True,
227
+ help="character library directory (required)",
228
+ )
229
+
230
+ sc = sub.add_parser("scenes", help="print the scene library contract")
231
+ sc.add_argument(
232
+ "--scenes", type=Path, required=True, help="scenes directory (required)"
233
+ )
234
+
235
+ ins = sub.add_parser(
236
+ "inspire", help="generate reference images from a theme + descriptions"
237
+ )
238
+ ins.add_argument("references", type=Path, help="references spec (list of items)")
239
+ ins.add_argument(
240
+ "-o",
241
+ "--out",
242
+ type=Path,
243
+ default=None,
244
+ help="output dir (default: references/ next to the spec)",
245
+ )
246
+ ins.add_argument(
247
+ "--theme",
248
+ type=Path,
249
+ default=None,
250
+ help="theme.yaml (default: sibling of the spec)",
251
+ )
252
+ ins.add_argument(
253
+ "--only",
254
+ default=None,
255
+ help="generate only these ids (comma-separated)",
256
+ )
257
+ ins.add_argument(
258
+ "--force", action="store_true", help="regenerate even if a .png exists"
259
+ )
260
+ ins.add_argument(
261
+ "--dry-run",
262
+ action="store_true",
263
+ help="write composed prompts only; no API call, no token needed",
264
+ )
265
+ ins.add_argument(
266
+ "--review", action="store_true", help="also write a review.html grid"
267
+ )
268
+
269
+ args = p.parse_args(argv)
270
+
271
+ if args.cmd == "init":
272
+ from .scaffold import init_project # noqa: PLC0415 — keep import local
273
+
274
+ created = init_project(args.dest, force=args.force)
275
+ for f in created:
276
+ rich_print(f"created {f}")
277
+ if not created:
278
+ rich_print(f"{args.dest}: nothing to do (all files exist; use --force)")
279
+ else:
280
+ rich_print(
281
+ f"\nProject ready in {args.dest}. Next:\n"
282
+ f" cmf render {args.dest / 'pages' / 'hello.yaml'}"
283
+ )
284
+ elif args.cmd in ("render", "scene"):
285
+ lib = Library(args.library) if args.library else None
286
+ scenes = SceneLibrary(args.scenes) if args.scenes else None
287
+ px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
288
+ fn = render_spec if args.cmd == "render" else render_scene
289
+ out = args.out or _default_out(args.spec.stem)
290
+ try:
291
+ fn(args.spec, out, library=lib, scenes=scenes, pixel_library=px)
292
+ except ValueError as e:
293
+ rich_print(f"{args.spec}: {e}")
294
+ return 1
295
+ rich_print(f"wrote {out}")
296
+ elif args.cmd == "validate":
297
+ lib = Library(args.library) if args.library else None
298
+ scenes = SceneLibrary(args.scenes) if args.scenes else None
299
+ px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
300
+ problems = validate_spec(
301
+ args.spec, library=lib, scenes=scenes, pixel_library=px
302
+ )
303
+ if problems:
304
+ rich_print(f"{args.spec}: {len(problems)} problem(s)")
305
+ for msg in problems:
306
+ rich_print(f" - {msg}")
307
+ return 1
308
+ rich_print(f"{args.spec}: ok")
309
+ return 0
310
+ elif args.cmd == "panel":
311
+ lib = Library(args.library) if args.library else None
312
+ scenes = SceneLibrary(args.scenes) if args.scenes else None
313
+ px = PixelLibrary(args.pixel_dir) if args.pixel_dir else None
314
+ if args.all:
315
+ out = args.out or (OUTPUT_DIR / f"{args.spec.stem}-panels-{_stamp()}")
316
+ for o in render_all_panels(
317
+ args.spec,
318
+ out,
319
+ library=lib,
320
+ scenes=scenes,
321
+ pixel_library=px,
322
+ scale=args.scale,
323
+ ):
324
+ rich_print(f"wrote {o}")
325
+ else:
326
+ out = args.out or _default_out(
327
+ f"{args.spec.stem}-r{args.row}c{args.col}"
328
+ )
329
+ render_panel(
330
+ args.spec,
331
+ out,
332
+ args.row,
333
+ args.col,
334
+ library=lib,
335
+ scenes=scenes,
336
+ scale=args.scale,
337
+ pixel_library=px,
338
+ )
339
+ rich_print(f"wrote {out}")
340
+ elif args.cmd == "character":
341
+ lib = Library(args.library)
342
+ char = lib.get(args.name)
343
+ selection, pose = _parse_selection(char, args.selections, args.pose)
344
+ out = args.out or _default_out(args.name)
345
+ render_character(
346
+ args.name,
347
+ out,
348
+ selection,
349
+ pose=pose,
350
+ library=lib,
351
+ scale=args.scale,
352
+ bg=args.bg,
353
+ flip=args.flip,
354
+ )
355
+ rich_print(f"wrote {out}")
356
+ # a smaller PNG alongside it, sized for Claude to read with fewer tokens
357
+ if args.thumb_px > 0:
358
+ thumb = out.with_name(f"{out.stem}.small.png")
359
+ thumb_scale = min(args.scale, args.thumb_px / char.resolve_pose(pose).w)
360
+ render_character(
361
+ args.name,
362
+ thumb,
363
+ selection,
364
+ pose=pose,
365
+ library=lib,
366
+ scale=thumb_scale,
367
+ bg=args.bg,
368
+ flip=args.flip,
369
+ )
370
+ rich_print(f"wrote {thumb}")
371
+ elif args.cmd == "characters":
372
+ lib = Library(args.library)
373
+ json.dump(lib.manifest(), sys.stdout, indent=2, ensure_ascii=False)
374
+ rich_print()
375
+ elif args.cmd == "scenes":
376
+ scenes = SceneLibrary(args.scenes)
377
+ json.dump(scenes.manifest(), sys.stdout, indent=2, ensure_ascii=False)
378
+ rich_print()
379
+ elif args.cmd == "inspire":
380
+ from .inspire import generate # noqa: PLC0415 — lazy: keeps Replicate optional
381
+
382
+ only = {s.strip() for s in args.only.split(",")} if args.only else None
383
+ out = generate(
384
+ args.references,
385
+ args.out,
386
+ theme_path=args.theme,
387
+ only=only,
388
+ force=args.force,
389
+ review=args.review,
390
+ dry_run=args.dry_run,
391
+ )
392
+ rich_print(f"wrote {len(out)} file(s)")
393
+
394
+
395
+ if __name__ == "__main__":
396
+ main()