comicforge 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {comicforge-0.1.0 → comicforge-0.2.0}/.claude/skills/comicforge/SKILL.md +29 -1
  2. {comicforge-0.1.0 → comicforge-0.2.0}/.claude/skills/comicforge/reference.md +24 -10
  3. {comicforge-0.1.0 → comicforge-0.2.0}/PKG-INFO +1 -1
  4. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/__init__.py +1 -1
  5. comicforge-0.2.0/comicforge/bubbles.py +232 -0
  6. comicforge-0.2.0/comicforge/caption.py +91 -0
  7. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/render.py +169 -45
  8. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/validate.py +23 -3
  9. {comicforge-0.1.0 → comicforge-0.2.0}/pyproject.toml +1 -1
  10. {comicforge-0.1.0 → comicforge-0.2.0}/skills/comicforge/SKILL.md +29 -1
  11. {comicforge-0.1.0 → comicforge-0.2.0}/skills/comicforge/reference.md +24 -10
  12. comicforge-0.2.0/tests/test_bubbles.py +62 -0
  13. comicforge-0.2.0/tests/test_caption.py +68 -0
  14. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_render.py +121 -0
  15. {comicforge-0.1.0 → comicforge-0.2.0}/uv.lock +59 -55
  16. comicforge-0.1.0/comicforge/bubbles.py +0 -167
  17. comicforge-0.1.0/tests/test_bubbles.py +0 -32
  18. {comicforge-0.1.0 → comicforge-0.2.0}/.github/dependabot.yml +0 -0
  19. {comicforge-0.1.0 → comicforge-0.2.0}/.github/workflows/ci.yml +0 -0
  20. {comicforge-0.1.0 → comicforge-0.2.0}/.github/workflows/release.yml +0 -0
  21. {comicforge-0.1.0 → comicforge-0.2.0}/.gitignore +0 -0
  22. {comicforge-0.1.0 → comicforge-0.2.0}/.python-version +0 -0
  23. {comicforge-0.1.0 → comicforge-0.2.0}/CLAUDE.md +0 -0
  24. {comicforge-0.1.0 → comicforge-0.2.0}/README.md +0 -0
  25. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/__main__.py +0 -0
  26. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/cli.py +0 -0
  27. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/inspire.py +0 -0
  28. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/library.py +0 -0
  29. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/pixelart.py +0 -0
  30. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/raster.py +0 -0
  31. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/scaffold.py +0 -0
  32. {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/scene.py +0 -0
  33. {comicforge-0.1.0 → comicforge-0.2.0}/docs/starting-a-project.md +0 -0
  34. {comicforge-0.1.0 → comicforge-0.2.0}/examples/README.md +0 -0
  35. {comicforge-0.1.0 → comicforge-0.2.0}/examples/README.md.old +0 -0
  36. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/character.yaml +0 -0
  37. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/face-happy.svg +0 -0
  38. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
  39. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
  40. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
  41. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
  42. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
  43. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
  44. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-down.svg +0 -0
  45. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
  46. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-point.svg +0 -0
  47. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
  48. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
  49. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/base.svg +0 -0
  50. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/character.yaml +0 -0
  51. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-angry.svg +0 -0
  52. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-happy.svg +0 -0
  53. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
  54. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
  55. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-sad.svg +0 -0
  56. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
  57. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-wink.svg +0 -0
  58. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/dvur-scene.yaml +0 -0
  59. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/kosticka.yaml +0 -0
  60. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/slepice.yaml +0 -0
  61. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/bone.yaml +0 -0
  62. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/heart.yaml +0 -0
  63. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/star.yaml +0 -0
  64. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/sun.yaml +0 -0
  65. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/references.yaml +0 -0
  66. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/base.svg +0 -0
  67. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
  68. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
  69. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
  70. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/pokoj/base.svg +0 -0
  71. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
  72. {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/theme.yaml +0 -0
  73. {comicforge-0.1.0 → comicforge-0.2.0}/poe_tasks.toml +0 -0
  74. {comicforge-0.1.0 → comicforge-0.2.0}/tests/__init__.py +0 -0
  75. {comicforge-0.1.0 → comicforge-0.2.0}/tests/conftest.py +0 -0
  76. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_cli.py +0 -0
  77. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_inspire.py +0 -0
  78. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_library.py +0 -0
  79. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_pixelart.py +0 -0
  80. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_raster.py +0 -0
  81. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_scaffold.py +0 -0
  82. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_scene.py +0 -0
  83. {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_validate.py +0 -0
@@ -75,20 +75,31 @@ Every position inside a panel is a **fraction 0..1 of that panel**:
75
75
 
76
76
  ```yaml
77
77
  title: "Optional page title" # bold caption strip at the top
78
+ title_style: {font_size: 26, color: "#21304a"} # optional
78
79
  type: page # page (default) | scene — see below
79
80
  page: A4 # A4 | A5 | letter | [w_mm, h_mm]
81
+ bg: "#ffffff" # paper colour
80
82
  px_per_mm: 4 # raster scale (vector PDF ignores it)
81
83
  margin_mm: 14
82
84
  gutter_mm: 6
85
+ frame: # panel outline, page-wide; a panel's own
86
+ width: 3.5 # `frame:` overrides. width 0 = no line
87
+ color: "#21304a"
88
+ radius: 10 # corner radius (also clips the art)
83
89
  library: "../characters" # path to character dir
84
90
  scenes_dir: "../scenes" # path to scenes dir (omit if no scenes used)
85
91
  pixel_dir: "../pixel" # path to pixel-art dir (omit if inline only)
86
92
 
87
93
  rows: # page is a stack of rows…
88
- - height: 1.0 # relative row height (default 1)
94
+ - height: 1.0 # relative row height (default 1), or
95
+ # height_mm: 60 for a fixed height —
96
+ # weighted rows share what is left
89
97
  panels: # …each row is a left→right list of panels
90
98
  - width: 1.0 # relative panel width (default 1)
91
99
  bg: "#fbfaf6" # optional flat panel background
100
+ frame: {width: 0} # optional per-panel outline override
101
+ caption: "Rain came." # narration band under the art, inside the
102
+ # frame; or {text:, max_chars:}
92
103
  scene: dvur # optional scene background (see below)
93
104
  image: "art/01.png" # optional raster background (see below)
94
105
  actors: [ ... ] # characters (drawn back→front in list order)
@@ -200,6 +211,9 @@ bubbles: [ ... ]
200
211
  kind: speech # speech | thought | shout
201
212
  speaker: tom # OPTIONAL: auto-place above this actor + aim the tail at
202
213
  # their head. Prefer this over manual x/y/to.
214
+ at: tr # OPTIONAL corner/edge to hug: tl t tr l c r bl b br.
215
+ # Each column (l/c/r) stacks its own top and
216
+ # bottom, so tl + tr sit side by side.
203
217
  x: 0.5 # OPTIONAL bubble centre (panel fraction); else from speaker
204
218
  y: 0.18 # OPTIONAL; omit and bubbles stack downward by measured
205
219
  # height, so they never overlap. All bubbles are
@@ -218,9 +232,23 @@ page or scene spec to style every bubble at once; per-bubble keys override:
218
232
  bubble_style:
219
233
  uppercase: true # render all bubble text in CAPS (classic comic lettering)
220
234
  font_size: 16 # default font size px for all bubbles
235
+ pad: 14 # text inset from the outline
236
+ radius: 18 # speech-bubble corner radius
237
+ stroke: "#21304a" # outline colour; stroke_width: 3; fill: "#ffffff"
238
+ ink: "#21304a" # text colour; font: "DejaVu Sans, sans-serif"
239
+ em: 1.0 # width scale of the text measure — 0.8 for a narrow
240
+ # handwriting font, so bubbles hug the words
221
241
  rows: [ ... ]
222
242
  ```
223
243
 
244
+ **Captions** — narration, as opposed to a character's bubble — go in a band
245
+ along the bottom of the panel, inside the frame, separated from the art by a
246
+ hairline in the frame colour. The art box shrinks to make room, so bubble and
247
+ actor coordinates stay relative to the picture. Page-wide look via
248
+ `caption_style` (`font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`,
249
+ `align: left|center`, `rule`, `uppercase`); `comicforge.caption.height(text, style)` tells
250
+ you how tall a band will be, for sizing rows.
251
+
224
252
  `thought` draws an ellipse with a trail of dots; `shout` draws a spiky burst.
225
253
  The tail is a slim line dropping from the bubble underside; its tip is capped
226
254
  short so it never overlaps the figure.
@@ -592,12 +592,22 @@ Do **not** auto-vectorize the result — hand/LLM-author the SVG using it as a g
592
592
 
593
593
  For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
594
594
 
595
- - **Page-level keys**: `title`, `page` (A4/A5/letter/[w,h]), `px_per_mm`,
596
- `margin_mm`, `gutter_mm`, `library`, `scenes_dir`, `pixel_dir`,
597
- `bubble_style` (page-wide bubble defaults: `uppercase`, `font_size`)
598
- - **Rows and panels**: `rows[].height` (relative weight), `rows[].panels[].width`
599
- (relative weight), panel keys: `bg`, `scene`, `image`, `actors`, `pixel`,
600
- `bubbles` — `validate` flags any other panel key as a typo
595
+ - **Page-level keys**: `title`, `title_style` (`font_size`, `color`, `font`),
596
+ `page` (A4/A5/letter/[w,h]), `bg` (paper colour), `px_per_mm`, `margin_mm`,
597
+ `gutter_mm`, `library`, `scenes_dir`, `pixel_dir`,
598
+ `frame` (panel outline: `width` in px, 0 for none; `color`; `radius` — the
599
+ corner radius also clips the art),
600
+ `bubble_style` (page-wide bubble look: `font`, `font_size`, `pad`, `radius`,
601
+ `stroke`, `stroke_width`, `fill`, `ink`, `uppercase`, `em` — width scale of
602
+ the text measure for narrower fonts)
603
+ - **Rows and panels**: `rows[].height` (relative weight) or `rows[].height_mm`
604
+ (fixed; weighted rows share the rest, all-fixed leaves the bottom blank),
605
+ `rows[].panels[].width` (relative weight), panel keys: `bg`, `frame`
606
+ (per-panel override), `caption` (narration band under the art, inside the
607
+ frame: a string or `{text, max_chars}`; page-wide `caption_style` with
608
+ `font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`, `align`, `rule`, `uppercase`),
609
+ `scene`, `image`, `actors`, `pixel`, `bubbles` — `validate` flags any other
610
+ panel key as a typo
601
611
  - **Image keys**: `image: path.png` or `image: {src:, fit:}` with
602
612
  `fit: cover` (default; scale-to-fill + centre-crop) or `contain`
603
613
  (fit inside + letterbox). The path resolves against the spec file's dir and
@@ -607,12 +617,16 @@ For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
607
617
  - **Actor keys**: `char`, `pose` (optional; defaults to the character's default
608
618
  pose), per-slot variant keys (`face`, `arms`, etc.), `x`, `y`, `scale`, `flip`
609
619
  - **Bubble keys**: `text`, `kind` (speech/thought/shout), `speaker`, optional
610
- `x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
620
+ `at`/`x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
611
621
  the page-level `bubble_style`). Omit `y` and bubbles stack down by their
612
622
  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
623
+ their `speaker`). `at` hugs a corner or edge instead — `tl`, `t`, `tr`, `l`,
624
+ `c`, `r`, `bl`, `b`, `br` — and each column keeps its own top and bottom
625
+ stack, so `tl` + `tr` sit side by side and `bl` climbs up from the bottom.
626
+ `to: [x, y]` (panel fractions) aims a tail without a speaker. Every bubble is
627
+ kept inside the panel — so a generated spec over raster panels, which has no
628
+ `speaker` to anchor to, can omit coordinates entirely, or place each with `at`
629
+ to keep it off the faces
616
630
  - **Pixel keys**: `art` (library name) or `grid`+`palette`, plus `x`/`y`/`scale`
617
631
 
618
632
  ---
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comicforge
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A tiny, scriptable comic-page engine — author comics as YAML, render to SVG / PNG / PDF
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: cairosvg>=2.7
@@ -7,4 +7,4 @@ Designed so an LLM (or you) can author pages as plain declarative text.
7
7
  from .pixelart import PixelLibrary # noqa: F401
8
8
  from .render import load_spec, render_scene, render_spec # noqa: F401
9
9
 
10
- __version__ = "0.1.0"
10
+ __version__ = "0.2.0"
@@ -0,0 +1,232 @@
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
+ # Every knob a bubble's look has. A page's `bubble_style:` overrides any of
16
+ # these for the whole page; a bubble's own keys override again.
17
+ DEFAULT_STYLE = {
18
+ "font": FONT,
19
+ "font_size": 16,
20
+ "pad": 14, # text inset from the outline
21
+ "radius": 18, # corner radius of a speech bubble (capped at half height)
22
+ "stroke": INK, # outline colour
23
+ "stroke_width": 3,
24
+ "fill": "#ffffff",
25
+ "ink": INK, # text colour
26
+ "uppercase": False,
27
+ "em": 1.0, # width scale for the text measure: <1 for a narrower font
28
+ }
29
+
30
+
31
+ def resolve_style(*layers) -> dict:
32
+ """Merge style dicts over ``DEFAULT_STYLE``; later layers win, ``None`` skipped."""
33
+ out = dict(DEFAULT_STYLE)
34
+ for layer in layers:
35
+ if layer:
36
+ out.update({k: v for k, v in layer.items() if v is not None})
37
+ return out
38
+
39
+
40
+ def _wrap(text: str, max_chars: int) -> list[str]:
41
+ lines, cur = [], ""
42
+ for word in text.split():
43
+ if cur and len(cur) + 1 + len(word) > max_chars:
44
+ lines.append(cur)
45
+ cur = word
46
+ else:
47
+ cur = f"{cur} {word}".strip()
48
+ if cur:
49
+ lines.append(cur)
50
+ return lines or [""]
51
+
52
+
53
+ # Rough advance widths in em for a humanist sans (DejaVu Sans is the default
54
+ # font): capitals are a good third wider than lowercase, so an all-caps bubble
55
+ # must be measured as such or the text runs past its outline.
56
+ _EM = {"upper": 0.70, "lower": 0.56, "digit": 0.64, "space": 0.32, "other": 0.34}
57
+
58
+
59
+ def text_width(text: str, fs: float) -> float:
60
+ """Estimated rendered width of *text* at font size *fs*, in px."""
61
+
62
+ def em(ch):
63
+ if ch.isupper():
64
+ return _EM["upper"]
65
+ if ch.islower():
66
+ return _EM["lower"]
67
+ if ch.isdigit():
68
+ return _EM["digit"]
69
+ if ch.isspace():
70
+ return _EM["space"]
71
+ return _EM["other"]
72
+
73
+ return sum(em(ch) for ch in text) * fs
74
+
75
+
76
+ def _box(text, max_chars, fs, pad, em=1.0):
77
+ """Wrap *text* and return (lines, line_height, body_width, body_height)."""
78
+ lines = _wrap(text, max_chars)
79
+ lh = fs * 1.25
80
+ longest = max((text_width(ln, fs) * em for ln in lines), default=fs)
81
+ w = max(longest + 2 * pad, 60)
82
+ h = len(lines) * lh + 2 * pad
83
+ return lines, lh, w, h
84
+
85
+
86
+ # how far each bubble kind's outline reaches beyond the text body box
87
+ _OUTSET = {"thought": (12, 16), "shout": (16, 16)}
88
+
89
+
90
+ def bubble_size(text, kind="speech", max_chars=22, fs=None, pad=None, style=None):
91
+ """Outer (width, height) a `bubble` call will occupy, tail excluded.
92
+
93
+ `thought` and `shout` draw outside the text body, so callers that stack
94
+ bubbles or keep them inside a panel need this, not just the body box.
95
+ """
96
+ st = resolve_style(style)
97
+ fs = st["font_size"] if fs is None else fs
98
+ pad = st["pad"] if pad is None else pad
99
+ _lines, _lh, w, h = _box(text, max_chars, fs, pad, st["em"])
100
+ ow, oh = _OUTSET.get(kind, (0, 0))
101
+ return w + ow, h + oh
102
+
103
+
104
+ def _text_block(lines, cx, top, fs, lh, st):
105
+ spans = []
106
+ for i, ln in enumerate(lines):
107
+ spans.append(
108
+ f'<tspan x="{cx:.1f}" y="{top + fs + i * lh:.1f}">{escape(ln)}</tspan>'
109
+ )
110
+ return (
111
+ f'<text text-anchor="middle" font-family="{st["font"]}" '
112
+ f'font-size="{fs}" fill="{st["ink"]}">{"".join(spans)}</text>'
113
+ )
114
+
115
+
116
+ def _paint(st, scale=1.0):
117
+ """fill/stroke attributes shared by every outline a bubble draws."""
118
+ return (
119
+ f'fill="{st["fill"]}" stroke="{st["stroke"]}" '
120
+ f'stroke-width="{st["stroke_width"] * scale:.2f}"'
121
+ )
122
+
123
+
124
+ def bubble(
125
+ text, bx, by, tail=None, kind="speech", max_chars=22, fs=None, pad=None, style=None
126
+ ):
127
+ st = resolve_style(style)
128
+ fs = st["font_size"] if fs is None else fs
129
+ pad = st["pad"] if pad is None else pad
130
+ lines, lh, w, h = _box(text, max_chars, fs, pad, st["em"])
131
+ x, y = bx - w / 2, by - h / 2
132
+ txt = _text_block(lines, bx, y + pad, fs, lh, st)
133
+
134
+ if kind == "shout":
135
+ body = _burst(x, y, w, h, st)
136
+ elif kind == "thought":
137
+ rx, ry = w / 2 + 6, h / 2 + 8
138
+ body = (
139
+ f'<ellipse cx="{bx:.1f}" cy="{by:.1f}" rx="{rx:.1f}" ry="{ry:.1f}" '
140
+ f"{_paint(st)}/>"
141
+ )
142
+ else:
143
+ body = (
144
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
145
+ f'rx="{min(st["radius"], h / 2):.1f}" {_paint(st)}/>'
146
+ )
147
+
148
+ tail_svg = ""
149
+ if tail is not None:
150
+ tail_svg = _tail(bx, by, w, h, tail, kind, st)
151
+
152
+ return f"<g>{body}{tail_svg}{txt}</g>"
153
+
154
+
155
+ def _tail(bx, by, w, h, tail, kind, st):
156
+ """A slim tail from the bubble's underside (or its top, when the speaker is
157
+ above it) pointing toward the target — but stopping well short of it, so
158
+ the tip never reaches the figure."""
159
+ tx, ty = tail
160
+ # exit from the edge facing the target: a side edge when the target lies
161
+ # further out beside the bubble than above or below it (relative to the
162
+ # body's own size), else top/bottom — nudged toward the target but kept
163
+ # within the middle of that edge
164
+ over_x = (abs(tx - bx) - w / 2) / (w / 2)
165
+ over_y = (abs(ty - by) - h / 2) / (h / 2)
166
+ if over_x > 0 and over_x > over_y:
167
+ ex = bx - w / 2 if tx < bx else bx + w / 2
168
+ ey = min(max(ty, by - h * 0.3), by + h * 0.3)
169
+ else:
170
+ ex = min(max(tx, bx - w * 0.3), bx + w * 0.3)
171
+ ey = by - h / 2 if ty < by - h / 2 else by + h / 2
172
+ dx, dy = tx - ex, ty - ey
173
+ dist = math.hypot(dx, dy) or 1.0
174
+ reach = min(dist * 0.45, 46) # capped length keeps the tip off the figure
175
+ ux, uy = dx / dist, dy / dist
176
+ tipx, tipy = ex + ux * reach, ey + uy * reach
177
+
178
+ if kind == "thought":
179
+ dots = ""
180
+ for f in (0.45, 0.74, 1.0):
181
+ r = 6 * (1 - f) + 2.5
182
+ dots += (
183
+ f'<circle cx="{ex + ux * reach * f:.1f}" '
184
+ f'cy="{ey + uy * reach * f:.1f}" r="{r:.1f}" {_paint(st, 0.85)}/>'
185
+ )
186
+ return dots
187
+ # narrow tapered tail for speech/shout
188
+ perp = math.atan2(uy, ux) + math.pi / 2
189
+ base = 6
190
+ ax = ex + math.cos(perp) * base
191
+ ay = ey + math.sin(perp) * base
192
+ bx2 = ex - math.cos(perp) * base
193
+ by2 = ey - math.sin(perp) * base
194
+ return (
195
+ f'<path d="M{ax:.1f} {ay:.1f} L{tipx:.1f} {tipy:.1f} '
196
+ f'L{bx2:.1f} {by2:.1f} Z" {_paint(st, 0.85)} stroke-linejoin="round"/>'
197
+ )
198
+
199
+
200
+ def _cloud(x, y, w, h):
201
+ # rounded body + scalloped top edge via overlapping circles
202
+ rx = min(20, h / 2)
203
+ body = (
204
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
205
+ f'rx="{rx:.1f}" fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
206
+ )
207
+ bumps = ""
208
+ n = max(3, int(w // 34))
209
+ for i in range(n):
210
+ cx = x + (i + 0.5) * w / n
211
+ bumps += (
212
+ f'<circle cx="{cx:.1f}" cy="{y:.1f}" r="13" '
213
+ f'fill="#ffffff" stroke="{INK}" stroke-width="3"/>'
214
+ )
215
+ # mask the inner stroke segments by redrawing body fill on top edge
216
+ cover = (
217
+ f'<rect x="{x + 3:.1f}" y="{y:.1f}" width="{w - 6:.1f}" height="14" '
218
+ f'fill="#ffffff" stroke="none"/>'
219
+ )
220
+ return body + bumps + cover + body.replace('fill="#ffffff"', 'fill="none"')
221
+
222
+
223
+ def _burst(x, y, w, h, st):
224
+ cx, cy = x + w / 2, y + h / 2
225
+ rx, ry = w / 2 + 8, h / 2 + 8
226
+ n = 18
227
+ pts = []
228
+ for i in range(n * 2):
229
+ a = math.pi * i / n
230
+ rr = 1.0 if i % 2 == 0 else 0.78
231
+ pts.append(f"{cx + math.cos(a) * rx * rr:.1f},{cy + math.sin(a) * ry * rr:.1f}")
232
+ return f'<polygon points="{" ".join(pts)}" {_paint(st)} stroke-linejoin="round"/>'
@@ -0,0 +1,91 @@
1
+ """Narration captions: a text band inside the panel frame, under the art.
2
+
3
+ A panel's ``caption:`` is the narrator's voice ("Rain came. Snow came.") as
4
+ opposed to a bubble, which is a character's. It is drawn as a flat band along
5
+ the bottom of the panel, separated from the art by a hairline; the art box
6
+ shrinks to make room, so bubbles and actors keep their coordinates relative to
7
+ the picture, not the band. A page's ``caption_style:`` sets the look for every
8
+ caption; a panel can give ``caption: {text:, max_chars:}`` to wrap differently.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from xml.sax.saxutils import escape
14
+
15
+ from .bubbles import FONT, INK, _wrap
16
+
17
+ DEFAULT_STYLE = {
18
+ "font": FONT,
19
+ "font_size": 13,
20
+ "ink": INK, # text colour
21
+ "bg": "#ffffff", # band colour
22
+ "pad": 8, # text inset from the band edge
23
+ "max_chars": 60, # wrap width; a panel's caption can override
24
+ "align": "left", # left | center
25
+ "rule": True, # hairline between art and band (in the frame colour)
26
+ "uppercase": False,
27
+ }
28
+
29
+
30
+ def normalize(value) -> dict | None:
31
+ """``caption: text`` or ``caption: {text, max_chars}`` -> dict, or None."""
32
+ if value is None:
33
+ return None
34
+ if isinstance(value, str):
35
+ value = {"text": value}
36
+ if not isinstance(value, dict) or not value.get("text"):
37
+ raise ValueError("caption must be a string or a {text, max_chars} mapping")
38
+ return value
39
+
40
+
41
+ def resolve_style(*layers) -> dict:
42
+ out = dict(DEFAULT_STYLE)
43
+ for layer in layers:
44
+ if layer:
45
+ out.update({k: v for k, v in layer.items() if v is not None})
46
+ return out
47
+
48
+
49
+ def lines(caption: dict, style: dict) -> list[str]:
50
+ text = " ".join(caption["text"].split())
51
+ if caption.get("uppercase", style["uppercase"]):
52
+ text = text.upper()
53
+ return _wrap(text, caption.get("max_chars", style["max_chars"]))
54
+
55
+
56
+ def height(caption, style=None) -> float:
57
+ """Band height in px a caption will take, 0 when there is none."""
58
+ cap = normalize(caption)
59
+ if cap is None:
60
+ return 0.0
61
+ st = resolve_style(style)
62
+ return len(lines(cap, st)) * st["font_size"] * 1.25 + 2 * st["pad"]
63
+
64
+
65
+ def band(caption: dict, style: dict, x, y, w, h, rule_color, rule_width) -> str:
66
+ """SVG for the band occupying the (x, y, w, h) box."""
67
+ st = style
68
+ fs, lh = st["font_size"], st["font_size"] * 1.25
69
+ parts = [
70
+ f'<rect x="{x:.1f}" y="{y:.1f}" width="{w:.1f}" height="{h:.1f}" '
71
+ f'fill="{st["bg"]}"/>'
72
+ ]
73
+ if st["rule"] and rule_width > 0:
74
+ parts.append(
75
+ f'<line x1="{x:.1f}" y1="{y:.1f}" x2="{x + w:.1f}" y2="{y:.1f}" '
76
+ f'stroke="{rule_color}" stroke-width="{rule_width}"/>'
77
+ )
78
+ if st["align"] == "center":
79
+ tx, anchor = x + w / 2, "middle"
80
+ else:
81
+ tx, anchor = x + st["pad"], "start"
82
+ top = y + st["pad"] + fs
83
+ spans = "".join(
84
+ f'<tspan x="{tx:.1f}" y="{top + i * lh:.1f}">{escape(ln)}</tspan>'
85
+ for i, ln in enumerate(lines(caption, st))
86
+ )
87
+ parts.append(
88
+ f'<text text-anchor="{anchor}" font-family="{st["font"]}" font-size="{fs}" '
89
+ f'fill="{st["ink"]}">{spans}</text>'
90
+ )
91
+ return "".join(parts)