comicforge 0.1.0__tar.gz → 0.2.1__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.1}/.claude/skills/comicforge/SKILL.md +31 -2
- {comicforge-0.1.0 → comicforge-0.2.1}/.claude/skills/comicforge/reference.md +27 -10
- comicforge-0.2.1/.github/workflows/docs.yml +69 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/.gitignore +9 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/CLAUDE.md +26 -1
- {comicforge-0.1.0 → comicforge-0.2.1}/PKG-INFO +20 -8
- {comicforge-0.1.0 → comicforge-0.2.1}/README.md +19 -7
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/__init__.py +1 -1
- comicforge-0.2.1/comicforge/bubbles.py +232 -0
- comicforge-0.2.1/comicforge/caption.py +91 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/render.py +169 -45
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/scaffold.py +2 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/validate.py +23 -3
- comicforge-0.2.1/docs/art/characters.md +205 -0
- comicforge-0.2.1/docs/art/inspire.md +147 -0
- comicforge-0.2.1/docs/art/pixel.md +91 -0
- comicforge-0.2.1/docs/art/scenes.md +145 -0
- comicforge-0.2.1/docs/assets/favicon.svg +5 -0
- comicforge-0.2.1/docs/assets/logo.svg +6 -0
- comicforge-0.2.1/docs/claude.md +118 -0
- comicforge-0.2.1/docs/concepts.md +144 -0
- comicforge-0.2.1/docs/contributing.md +129 -0
- comicforge-0.2.1/docs/demos/arms.yaml +15 -0
- comicforge-0.2.1/docs/demos/bubble-at.yaml +21 -0
- comicforge-0.2.1/docs/demos/bubble-stack.yaml +15 -0
- comicforge-0.2.1/docs/demos/bubbles.yaml +19 -0
- comicforge-0.2.1/docs/demos/captions.yaml +18 -0
- comicforge-0.2.1/docs/demos/coords.yaml +17 -0
- comicforge-0.2.1/docs/demos/faces.yaml +18 -0
- comicforge-0.2.1/docs/demos/frames.yaml +19 -0
- comicforge-0.2.1/docs/demos/grid.yaml +24 -0
- comicforge-0.2.1/docs/demos/illustration.yaml +14 -0
- comicforge-0.2.1/docs/demos/images.yaml +24 -0
- comicforge-0.2.1/docs/demos/lettering.yaml +23 -0
- comicforge-0.2.1/docs/demos/pixel.yaml +28 -0
- comicforge-0.2.1/docs/demos/poses.yaml +12 -0
- comicforge-0.2.1/docs/demos/raster-art/backdrop.png +0 -0
- comicforge-0.2.1/docs/demos/scenes.yaml +13 -0
- comicforge-0.2.1/docs/demos/strip.yaml +37 -0
- comicforge-0.2.1/docs/demos/tutorial-art/characters/pip/base.svg +12 -0
- comicforge-0.2.1/docs/demos/tutorial-art/characters/pip/character.yaml +6 -0
- comicforge-0.2.1/docs/demos/tutorial-art/characters/pip/face-happy.svg +7 -0
- comicforge-0.2.1/docs/demos/tutorial-art/characters/pip/face-neutral.svg +7 -0
- comicforge-0.2.1/docs/demos/tutorial-art/pages/first.yaml +16 -0
- comicforge-0.2.1/docs/demos/tutorial-art/pixel/heart.yaml +10 -0
- comicforge-0.2.1/docs/gallery.md +133 -0
- comicforge-0.2.1/docs/guide/actors.md +157 -0
- comicforge-0.2.1/docs/guide/bubbles.md +201 -0
- comicforge-0.2.1/docs/guide/captions-frames.md +153 -0
- comicforge-0.2.1/docs/guide/illustrations.md +113 -0
- comicforge-0.2.1/docs/guide/images.md +116 -0
- comicforge-0.2.1/docs/guide/pages.md +159 -0
- comicforge-0.2.1/docs/guide/pixel-art.md +126 -0
- comicforge-0.2.1/docs/guide/scenes.md +91 -0
- comicforge-0.2.1/docs/hooks/render_demos.py +105 -0
- comicforge-0.2.1/docs/index.md +127 -0
- comicforge-0.2.1/docs/install.md +126 -0
- comicforge-0.2.1/docs/quickstart.md +191 -0
- comicforge-0.2.1/docs/reference/cli.md +331 -0
- comicforge-0.2.1/docs/reference/python-api.md +179 -0
- comicforge-0.2.1/docs/reference/spec.md +259 -0
- comicforge-0.2.1/docs/reference/troubleshooting.md +182 -0
- comicforge-0.2.1/docs/starting-a-project.md +118 -0
- comicforge-0.2.1/docs/stylesheets/extra.css +188 -0
- comicforge-0.2.1/mkdocs.yml +164 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/poe_tasks.toml +8 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/pyproject.toml +4 -1
- {comicforge-0.1.0 → comicforge-0.2.1}/skills/comicforge/SKILL.md +31 -2
- {comicforge-0.1.0 → comicforge-0.2.1}/skills/comicforge/reference.md +27 -10
- comicforge-0.2.1/tests/test_bubbles.py +62 -0
- comicforge-0.2.1/tests/test_caption.py +68 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_render.py +121 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/uv.lock +481 -55
- comicforge-0.1.0/comicforge/bubbles.py +0 -167
- comicforge-0.1.0/docs/starting-a-project.md +0 -70
- comicforge-0.1.0/tests/test_bubbles.py +0 -32
- {comicforge-0.1.0 → comicforge-0.2.1}/.github/dependabot.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/.github/workflows/ci.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/.github/workflows/release.yml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/.python-version +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/__main__.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/cli.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/inspire.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/library.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/pixelart.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/raster.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/comicforge/scene.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/README.md +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/README.md.old +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/character.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/face-happy.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/face-neutral.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/poses/sit/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/poses/sit/pose.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/poses/walk/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/bara/poses/walk/pose.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-crossed.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-down.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-hips.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-point.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-thumbsup.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/arms-wave.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/character.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-angry.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-happy.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-laugh.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-neutral.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-sad.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-surprised.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/characters/tom/face-wink.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pages/dvur-scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pages/kosticka.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pages/slepice.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pixel/bone.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pixel/heart.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pixel/star.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/pixel/sun.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/references.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/dvur/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/dvur/scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/dvur/weather-clear.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/dvur/weather-rain.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/pokoj/base.svg +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/scenes/pokoj/scene.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/examples/pes/theme.yaml +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/__init__.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/conftest.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_cli.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_inspire.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_library.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_pixelart.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_raster.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_scaffold.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_scene.py +0 -0
- {comicforge-0.1.0 → comicforge-0.2.1}/tests/test_validate.py +0 -0
|
@@ -17,7 +17,8 @@ description: >
|
|
|
17
17
|
You write a **comic page as a YAML spec**; the engine renders it to SVG/PNG/PDF.
|
|
18
18
|
This file is the authoring contract. For deeper explanations (path resolution,
|
|
19
19
|
how to add characters/scenes, pixel-art format, Python API, full CLI reference)
|
|
20
|
-
see [`reference.md`](reference.md) in this skill directory
|
|
20
|
+
see [`reference.md`](reference.md) in this skill directory, or the documentation
|
|
21
|
+
site at <https://mojzis.github.io/comicforge/>.
|
|
21
22
|
|
|
22
23
|
## Loop
|
|
23
24
|
|
|
@@ -75,20 +76,31 @@ Every position inside a panel is a **fraction 0..1 of that panel**:
|
|
|
75
76
|
|
|
76
77
|
```yaml
|
|
77
78
|
title: "Optional page title" # bold caption strip at the top
|
|
79
|
+
title_style: {font_size: 26, color: "#21304a"} # optional
|
|
78
80
|
type: page # page (default) | scene — see below
|
|
79
81
|
page: A4 # A4 | A5 | letter | [w_mm, h_mm]
|
|
82
|
+
bg: "#ffffff" # paper colour
|
|
80
83
|
px_per_mm: 4 # raster scale (vector PDF ignores it)
|
|
81
84
|
margin_mm: 14
|
|
82
85
|
gutter_mm: 6
|
|
86
|
+
frame: # panel outline, page-wide; a panel's own
|
|
87
|
+
width: 3.5 # `frame:` overrides. width 0 = no line
|
|
88
|
+
color: "#21304a"
|
|
89
|
+
radius: 10 # corner radius (also clips the art)
|
|
83
90
|
library: "../characters" # path to character dir
|
|
84
91
|
scenes_dir: "../scenes" # path to scenes dir (omit if no scenes used)
|
|
85
92
|
pixel_dir: "../pixel" # path to pixel-art dir (omit if inline only)
|
|
86
93
|
|
|
87
94
|
rows: # page is a stack of rows…
|
|
88
|
-
- height: 1.0 # relative row height (default 1)
|
|
95
|
+
- height: 1.0 # relative row height (default 1), or
|
|
96
|
+
# height_mm: 60 for a fixed height —
|
|
97
|
+
# weighted rows share what is left
|
|
89
98
|
panels: # …each row is a left→right list of panels
|
|
90
99
|
- width: 1.0 # relative panel width (default 1)
|
|
91
100
|
bg: "#fbfaf6" # optional flat panel background
|
|
101
|
+
frame: {width: 0} # optional per-panel outline override
|
|
102
|
+
caption: "Rain came." # narration band under the art, inside the
|
|
103
|
+
# frame; or {text:, max_chars:}
|
|
92
104
|
scene: dvur # optional scene background (see below)
|
|
93
105
|
image: "art/01.png" # optional raster background (see below)
|
|
94
106
|
actors: [ ... ] # characters (drawn back→front in list order)
|
|
@@ -200,6 +212,9 @@ bubbles: [ ... ]
|
|
|
200
212
|
kind: speech # speech | thought | shout
|
|
201
213
|
speaker: tom # OPTIONAL: auto-place above this actor + aim the tail at
|
|
202
214
|
# their head. Prefer this over manual x/y/to.
|
|
215
|
+
at: tr # OPTIONAL corner/edge to hug: tl t tr l c r bl b br.
|
|
216
|
+
# Each column (l/c/r) stacks its own top and
|
|
217
|
+
# bottom, so tl + tr sit side by side.
|
|
203
218
|
x: 0.5 # OPTIONAL bubble centre (panel fraction); else from speaker
|
|
204
219
|
y: 0.18 # OPTIONAL; omit and bubbles stack downward by measured
|
|
205
220
|
# height, so they never overlap. All bubbles are
|
|
@@ -218,9 +233,23 @@ page or scene spec to style every bubble at once; per-bubble keys override:
|
|
|
218
233
|
bubble_style:
|
|
219
234
|
uppercase: true # render all bubble text in CAPS (classic comic lettering)
|
|
220
235
|
font_size: 16 # default font size px for all bubbles
|
|
236
|
+
pad: 14 # text inset from the outline
|
|
237
|
+
radius: 18 # speech-bubble corner radius
|
|
238
|
+
stroke: "#21304a" # outline colour; stroke_width: 3; fill: "#ffffff"
|
|
239
|
+
ink: "#21304a" # text colour; font: "DejaVu Sans, sans-serif"
|
|
240
|
+
em: 1.0 # width scale of the text measure — 0.8 for a narrow
|
|
241
|
+
# handwriting font, so bubbles hug the words
|
|
221
242
|
rows: [ ... ]
|
|
222
243
|
```
|
|
223
244
|
|
|
245
|
+
**Captions** — narration, as opposed to a character's bubble — go in a band
|
|
246
|
+
along the bottom of the panel, inside the frame, separated from the art by a
|
|
247
|
+
hairline in the frame colour. The art box shrinks to make room, so bubble and
|
|
248
|
+
actor coordinates stay relative to the picture. Page-wide look via
|
|
249
|
+
`caption_style` (`font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`,
|
|
250
|
+
`align: left|center`, `rule`, `uppercase`); `comicforge.caption.height(text, style)` tells
|
|
251
|
+
you how tall a band will be, for sizing rows.
|
|
252
|
+
|
|
224
253
|
`thought` draws an ellipse with a trail of dots; `shout` draws a spiky burst.
|
|
225
254
|
The tail is a slim line dropping from the bubble underside; its tip is capped
|
|
226
255
|
short so it never overlaps the figure.
|
|
@@ -4,6 +4,9 @@ This guide covers everything you need to author comics with ComicForge — for b
|
|
|
4
4
|
humans and LLMs. The [SKILL.md](SKILL.md) file is the quick-reference contract;
|
|
5
5
|
this document goes deeper on structure, paths, and how all the pieces fit together.
|
|
6
6
|
|
|
7
|
+
The same material, with rendered examples beside every spec, is published at
|
|
8
|
+
<https://mojzis.github.io/comicforge/>.
|
|
9
|
+
|
|
7
10
|
---
|
|
8
11
|
|
|
9
12
|
## Engine + self-contained projects
|
|
@@ -592,12 +595,22 @@ Do **not** auto-vectorize the result — hand/LLM-author the SVG using it as a g
|
|
|
592
595
|
|
|
593
596
|
For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
|
|
594
597
|
|
|
595
|
-
- **Page-level keys**: `title`, `
|
|
596
|
-
`
|
|
597
|
-
`
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
`
|
|
598
|
+
- **Page-level keys**: `title`, `title_style` (`font_size`, `color`, `font`),
|
|
599
|
+
`page` (A4/A5/letter/[w,h]), `bg` (paper colour), `px_per_mm`, `margin_mm`,
|
|
600
|
+
`gutter_mm`, `library`, `scenes_dir`, `pixel_dir`,
|
|
601
|
+
`frame` (panel outline: `width` in px, 0 for none; `color`; `radius` — the
|
|
602
|
+
corner radius also clips the art),
|
|
603
|
+
`bubble_style` (page-wide bubble look: `font`, `font_size`, `pad`, `radius`,
|
|
604
|
+
`stroke`, `stroke_width`, `fill`, `ink`, `uppercase`, `em` — width scale of
|
|
605
|
+
the text measure for narrower fonts)
|
|
606
|
+
- **Rows and panels**: `rows[].height` (relative weight) or `rows[].height_mm`
|
|
607
|
+
(fixed; weighted rows share the rest, all-fixed leaves the bottom blank),
|
|
608
|
+
`rows[].panels[].width` (relative weight), panel keys: `bg`, `frame`
|
|
609
|
+
(per-panel override), `caption` (narration band under the art, inside the
|
|
610
|
+
frame: a string or `{text, max_chars}`; page-wide `caption_style` with
|
|
611
|
+
`font`, `font_size`, `ink`, `bg`, `pad`, `max_chars`, `align`, `rule`, `uppercase`),
|
|
612
|
+
`scene`, `image`, `actors`, `pixel`, `bubbles` — `validate` flags any other
|
|
613
|
+
panel key as a typo
|
|
601
614
|
- **Image keys**: `image: path.png` or `image: {src:, fit:}` with
|
|
602
615
|
`fit: cover` (default; scale-to-fill + centre-crop) or `contain`
|
|
603
616
|
(fit inside + letterbox). The path resolves against the spec file's dir and
|
|
@@ -607,12 +620,16 @@ For the full spec grammar, see [SKILL.md](SKILL.md). Key points:
|
|
|
607
620
|
- **Actor keys**: `char`, `pose` (optional; defaults to the character's default
|
|
608
621
|
pose), per-slot variant keys (`face`, `arms`, etc.), `x`, `y`, `scale`, `flip`
|
|
609
622
|
- **Bubble keys**: `text`, `kind` (speech/thought/shout), `speaker`, optional
|
|
610
|
-
`x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
|
|
623
|
+
`at`/`x`/`y`/`to`/`max_chars`/`fs`/`uppercase` (`fs` and `uppercase` override
|
|
611
624
|
the page-level `bubble_style`). Omit `y` and bubbles stack down by their
|
|
612
625
|
measured height without overlapping; omit `x` and they centre (or sit above
|
|
613
|
-
their `speaker`).
|
|
614
|
-
|
|
615
|
-
|
|
626
|
+
their `speaker`). `at` hugs a corner or edge instead — `tl`, `t`, `tr`, `l`,
|
|
627
|
+
`c`, `r`, `bl`, `b`, `br` — and each column keeps its own top and bottom
|
|
628
|
+
stack, so `tl` + `tr` sit side by side and `bl` climbs up from the bottom.
|
|
629
|
+
`to: [x, y]` (panel fractions) aims a tail without a speaker. Every bubble is
|
|
630
|
+
kept inside the panel — so a generated spec over raster panels, which has no
|
|
631
|
+
`speaker` to anchor to, can omit coordinates entirely, or place each with `at`
|
|
632
|
+
to keep it off the faces
|
|
616
633
|
- **Pixel keys**: `art` (library name) or `grid`+`palette`, plus `x`/`y`/`scale`
|
|
617
634
|
|
|
618
635
|
---
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
name: Docs
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
paths:
|
|
7
|
+
- "docs/**"
|
|
8
|
+
- "mkdocs.yml"
|
|
9
|
+
- "examples/**" # the gallery renders from the demo project
|
|
10
|
+
- "comicforge/**" # …through the engine, so a renderer change matters
|
|
11
|
+
- "pyproject.toml"
|
|
12
|
+
- ".github/workflows/docs.yml"
|
|
13
|
+
pull_request:
|
|
14
|
+
paths:
|
|
15
|
+
- "docs/**"
|
|
16
|
+
- "mkdocs.yml"
|
|
17
|
+
- "examples/**"
|
|
18
|
+
- "comicforge/**"
|
|
19
|
+
workflow_dispatch:
|
|
20
|
+
|
|
21
|
+
concurrency:
|
|
22
|
+
group: docs-${{ github.ref }}
|
|
23
|
+
cancel-in-progress: true
|
|
24
|
+
|
|
25
|
+
permissions: {}
|
|
26
|
+
|
|
27
|
+
jobs:
|
|
28
|
+
build:
|
|
29
|
+
runs-on: ubuntu-latest
|
|
30
|
+
steps:
|
|
31
|
+
- uses: actions/checkout@v7
|
|
32
|
+
|
|
33
|
+
# Every image on the site is rendered at build time by
|
|
34
|
+
# docs/hooks/render_demos.py, so the docs build needs cairo just like a
|
|
35
|
+
# normal render does.
|
|
36
|
+
- name: Install cairo
|
|
37
|
+
run: sudo apt-get update && sudo apt-get install -y libcairo2
|
|
38
|
+
|
|
39
|
+
- name: Install uv
|
|
40
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
41
|
+
with:
|
|
42
|
+
enable-cache: true
|
|
43
|
+
|
|
44
|
+
- name: Sync docs dependencies
|
|
45
|
+
run: uv sync --group docs
|
|
46
|
+
|
|
47
|
+
# --strict fails the build on a broken cross-reference, a dead anchor, a
|
|
48
|
+
# missing snippet, or a demo spec that no longer renders.
|
|
49
|
+
- name: Build site
|
|
50
|
+
run: uv run mkdocs build --strict
|
|
51
|
+
|
|
52
|
+
- uses: actions/upload-pages-artifact@v4
|
|
53
|
+
with:
|
|
54
|
+
path: site/
|
|
55
|
+
|
|
56
|
+
deploy:
|
|
57
|
+
# Pull requests get the build as a check; only main publishes.
|
|
58
|
+
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
|
|
59
|
+
needs: build
|
|
60
|
+
runs-on: ubuntu-latest
|
|
61
|
+
environment:
|
|
62
|
+
name: github-pages
|
|
63
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
64
|
+
permissions:
|
|
65
|
+
pages: write
|
|
66
|
+
id-token: write
|
|
67
|
+
steps:
|
|
68
|
+
- id: deployment
|
|
69
|
+
uses: actions/deploy-pages@v4
|
|
@@ -34,3 +34,12 @@ review.html
|
|
|
34
34
|
|
|
35
35
|
# Source archive (redundant after flattening)
|
|
36
36
|
*.zip
|
|
37
|
+
|
|
38
|
+
# Docs site: `mkdocs build` output, and the demo renders the build hook draws
|
|
39
|
+
# (every image on the site is regenerated from a spec — never checked in).
|
|
40
|
+
site/
|
|
41
|
+
docs/assets/renders/
|
|
42
|
+
|
|
43
|
+
# …except the one raster the `image:` demo needs as *input*, which `*.png` above
|
|
44
|
+
# would otherwise swallow.
|
|
45
|
+
!docs/demos/raster-art/*.png
|
|
@@ -21,6 +21,7 @@ comicforge/ The engine — pure code, no bundled art.
|
|
|
21
21
|
examples/pes/ A self-contained demo project (characters, scenes, pixel, pages).
|
|
22
22
|
Develop against the engine the same way a downstream project would.
|
|
23
23
|
skills/comicforge/ The portable authoring skill (SKILL.md + reference.md).
|
|
24
|
+
docs/ The documentation site (MkDocs Material) — see below.
|
|
24
25
|
tests/ pytest suite.
|
|
25
26
|
```
|
|
26
27
|
|
|
@@ -82,6 +83,29 @@ cmf scene examples/pes/pages/dvur-scene.yaml -o dvur.png
|
|
|
82
83
|
cmf panel examples/pes/pages/slepice.yaml -o panels/ --all
|
|
83
84
|
```
|
|
84
85
|
|
|
86
|
+
## Docs site
|
|
87
|
+
|
|
88
|
+
`docs/` is a MkDocs Material site published to GitHub Pages by
|
|
89
|
+
`.github/workflows/docs.yml`.
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
uv sync --group docs
|
|
93
|
+
poe docs # serve with live reload
|
|
94
|
+
poe docs-build # mkdocs build --strict — what CI runs
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
**Every image on the site is rendered at build time** by
|
|
98
|
+
`docs/hooks/render_demos.py`, from a spec in `docs/demos/` or from
|
|
99
|
+
`examples/pes/`. Nothing is a checked-in screenshot, so a renderer change that
|
|
100
|
+
breaks a demo breaks the docs build. To add an illustrated example: write
|
|
101
|
+
`docs/demos/<name>.yaml` (small custom `page:`, `library:` pointing at
|
|
102
|
+
`../../examples/pes/characters`), `cmf validate` it, then reference
|
|
103
|
+
`assets/renders/<name>.png` and include the spec with a `pymdownx.snippets`
|
|
104
|
+
include — see `docs/guide/bubbles.md` for the pattern.
|
|
105
|
+
|
|
106
|
+
`--strict` plus the `validation:` block in `mkdocs.yml` fails the build on a
|
|
107
|
+
broken cross-reference, a dead anchor, a missing snippet or a missing render.
|
|
108
|
+
|
|
85
109
|
## Conventions / invariants
|
|
86
110
|
|
|
87
111
|
- **Content-free engine:** never hardcode an asset name, path, or default project
|
|
@@ -91,7 +115,8 @@ cmf panel examples/pes/pages/slepice.yaml -o panels/ --all
|
|
|
91
115
|
- The CLI subcommands (`render`/`scene`/`panel`/`characters`/`scenes`) are the
|
|
92
116
|
stable surface; `characters`/`scenes` emit JSON manifests other tools depend on.
|
|
93
117
|
- When you change the spec grammar or CLI, update the authoring skill in
|
|
94
|
-
`skills/comicforge/` (both `SKILL.md` and `reference.md`)
|
|
118
|
+
`skills/comicforge/` (both `SKILL.md` and `reference.md`) **and** the affected
|
|
119
|
+
pages under `docs/` in the same change.
|
|
95
120
|
- Demo art is hand/LLM-authored crisp SVG, edited directly in `examples/pes/`
|
|
96
121
|
(there is no procedural generator). Use `comicforge inspire` for *reference*
|
|
97
122
|
images only — never auto-vectorize them; it breaks overlay/anchor registration.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: comicforge
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
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
|
|
@@ -13,12 +13,18 @@ Description-Content-Type: text/markdown
|
|
|
13
13
|
|
|
14
14
|
# ComicForge
|
|
15
15
|
|
|
16
|
+
**📖 Documentation: <https://mojzis.github.io/comicforge/>**
|
|
17
|
+
|
|
16
18
|
A tiny, scriptable comic-page engine. Characters are a **base SVG + stackable
|
|
17
19
|
variant overlays** (faces, arms, …); a comic page is a **declarative YAML spec**;
|
|
18
20
|
output is **SVG / PNG / PDF**. Built so an LLM (or you) can author pages as plain
|
|
19
|
-
text
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
text.
|
|
22
|
+
|
|
23
|
+
- [Your first comic](https://mojzis.github.io/comicforge/quickstart/) — a project, a character, a strip
|
|
24
|
+
- [Spec reference](https://mojzis.github.io/comicforge/reference/spec/) · [CLI reference](https://mojzis.github.io/comicforge/reference/cli/) · [Gallery](https://mojzis.github.io/comicforge/gallery/)
|
|
25
|
+
- [`skills/comicforge/SKILL.md`](skills/comicforge/SKILL.md) — the authoring contract an LLM reads
|
|
26
|
+
- Working **on the engine**? See [`CLAUDE.md`](CLAUDE.md) and
|
|
27
|
+
[Contributing](https://mojzis.github.io/comicforge/contributing/).
|
|
22
28
|
|
|
23
29
|
## Start a project
|
|
24
30
|
|
|
@@ -32,13 +38,17 @@ cmf init my-comic # scaffold characters/ scenes/ pixel/ pages/
|
|
|
32
38
|
cd my-comic && cmf render pages/hello.yaml
|
|
33
39
|
```
|
|
34
40
|
|
|
35
|
-
See [
|
|
36
|
-
setup, version pinning, and when to make it a real Python
|
|
41
|
+
See [Starting a project](https://mojzis.github.io/comicforge/starting-a-project/)
|
|
42
|
+
for the data-only setup, version pinning, and when to make it a real Python
|
|
43
|
+
project.
|
|
37
44
|
|
|
38
45
|
## Working on the engine
|
|
39
46
|
|
|
40
47
|
```bash
|
|
41
48
|
uv sync # create .venv and install deps
|
|
49
|
+
uv run poe test # the tight loop
|
|
50
|
+
uv run poe check # everything, before opening a PR
|
|
51
|
+
uv run poe docs # serve the documentation site locally
|
|
42
52
|
```
|
|
43
53
|
|
|
44
54
|
## Use
|
|
@@ -114,8 +124,10 @@ pixel_dir: "../pixel" # pixel-art sprites
|
|
|
114
124
|
CLI flags (`--library`, `--scenes`, `--pixel-dir`) override spec keys and are
|
|
115
125
|
treated as relative to the current working directory.
|
|
116
126
|
|
|
117
|
-
|
|
118
|
-
resolution rule, how to add characters/scenes/sprites, and the complete
|
|
127
|
+
The [documentation site](https://mojzis.github.io/comicforge/) covers the full
|
|
128
|
+
path-resolution rule, how to add characters / scenes / sprites, and the complete
|
|
129
|
+
CLI and spec references — every picture on it is rendered from a spec at build
|
|
130
|
+
time.
|
|
119
131
|
|
|
120
132
|
## Design choices
|
|
121
133
|
|
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# ComicForge
|
|
2
2
|
|
|
3
|
+
**📖 Documentation: <https://mojzis.github.io/comicforge/>**
|
|
4
|
+
|
|
3
5
|
A tiny, scriptable comic-page engine. Characters are a **base SVG + stackable
|
|
4
6
|
variant overlays** (faces, arms, …); a comic page is a **declarative YAML spec**;
|
|
5
7
|
output is **SVG / PNG / PDF**. Built so an LLM (or you) can author pages as plain
|
|
6
|
-
text
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
text.
|
|
9
|
+
|
|
10
|
+
- [Your first comic](https://mojzis.github.io/comicforge/quickstart/) — a project, a character, a strip
|
|
11
|
+
- [Spec reference](https://mojzis.github.io/comicforge/reference/spec/) · [CLI reference](https://mojzis.github.io/comicforge/reference/cli/) · [Gallery](https://mojzis.github.io/comicforge/gallery/)
|
|
12
|
+
- [`skills/comicforge/SKILL.md`](skills/comicforge/SKILL.md) — the authoring contract an LLM reads
|
|
13
|
+
- Working **on the engine**? See [`CLAUDE.md`](CLAUDE.md) and
|
|
14
|
+
[Contributing](https://mojzis.github.io/comicforge/contributing/).
|
|
9
15
|
|
|
10
16
|
## Start a project
|
|
11
17
|
|
|
@@ -19,13 +25,17 @@ cmf init my-comic # scaffold characters/ scenes/ pixel/ pages/
|
|
|
19
25
|
cd my-comic && cmf render pages/hello.yaml
|
|
20
26
|
```
|
|
21
27
|
|
|
22
|
-
See [
|
|
23
|
-
setup, version pinning, and when to make it a real Python
|
|
28
|
+
See [Starting a project](https://mojzis.github.io/comicforge/starting-a-project/)
|
|
29
|
+
for the data-only setup, version pinning, and when to make it a real Python
|
|
30
|
+
project.
|
|
24
31
|
|
|
25
32
|
## Working on the engine
|
|
26
33
|
|
|
27
34
|
```bash
|
|
28
35
|
uv sync # create .venv and install deps
|
|
36
|
+
uv run poe test # the tight loop
|
|
37
|
+
uv run poe check # everything, before opening a PR
|
|
38
|
+
uv run poe docs # serve the documentation site locally
|
|
29
39
|
```
|
|
30
40
|
|
|
31
41
|
## Use
|
|
@@ -101,8 +111,10 @@ pixel_dir: "../pixel" # pixel-art sprites
|
|
|
101
111
|
CLI flags (`--library`, `--scenes`, `--pixel-dir`) override spec keys and are
|
|
102
112
|
treated as relative to the current working directory.
|
|
103
113
|
|
|
104
|
-
|
|
105
|
-
resolution rule, how to add characters/scenes/sprites, and the complete
|
|
114
|
+
The [documentation site](https://mojzis.github.io/comicforge/) covers the full
|
|
115
|
+
path-resolution rule, how to add characters / scenes / sprites, and the complete
|
|
116
|
+
CLI and spec references — every picture on it is rendered from a spec at build
|
|
117
|
+
time.
|
|
106
118
|
|
|
107
119
|
## Design choices
|
|
108
120
|
|
|
@@ -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"/>'
|