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/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