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.
- {comicforge-0.1.0 → comicforge-0.2.0}/.claude/skills/comicforge/SKILL.md +29 -1
- {comicforge-0.1.0 → comicforge-0.2.0}/.claude/skills/comicforge/reference.md +24 -10
- {comicforge-0.1.0 → comicforge-0.2.0}/PKG-INFO +1 -1
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/__init__.py +1 -1
- comicforge-0.2.0/comicforge/bubbles.py +232 -0
- comicforge-0.2.0/comicforge/caption.py +91 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/render.py +169 -45
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/validate.py +23 -3
- {comicforge-0.1.0 → comicforge-0.2.0}/pyproject.toml +1 -1
- {comicforge-0.1.0 → comicforge-0.2.0}/skills/comicforge/SKILL.md +29 -1
- {comicforge-0.1.0 → comicforge-0.2.0}/skills/comicforge/reference.md +24 -10
- comicforge-0.2.0/tests/test_bubbles.py +62 -0
- comicforge-0.2.0/tests/test_caption.py +68 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_render.py +121 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/uv.lock +59 -55
- comicforge-0.1.0/comicforge/bubbles.py +0 -167
- comicforge-0.1.0/tests/test_bubbles.py +0 -32
- {comicforge-0.1.0 → comicforge-0.2.0}/.github/dependabot.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/.github/workflows/ci.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/.github/workflows/release.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/.gitignore +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/.python-version +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/CLAUDE.md +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/README.md +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/__main__.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/cli.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/inspire.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/library.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/pixelart.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/raster.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/scaffold.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/comicforge/scene.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/docs/starting-a-project.md +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/README.md +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/README.md.old +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/character.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/face-happy.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/face-neutral.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-crossed.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-down.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-hips.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-point.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/arms-wave.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/character.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-angry.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-happy.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-laugh.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-neutral.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-sad.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-surprised.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/characters/tom/face-wink.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/dvur-scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/kosticka.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pages/slepice.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/bone.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/heart.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/star.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/pixel/sun.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/references.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/pokoj/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/scenes/pokoj/scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/examples/pes/theme.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/poe_tasks.toml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/__init__.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/conftest.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_cli.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_inspire.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_library.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_pixelart.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_raster.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_scaffold.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.0}/tests/test_scene.py +0 -0
- {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`, `
|
|
596
|
-
`
|
|
597
|
-
`
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
`
|
|
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`).
|
|
614
|
-
|
|
615
|
-
|
|
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
|
---
|
|
@@ -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)
|