nx_manga_bubble.nx source
↩ module page · 350 lines · 15909 B
1// nx_manga_bubble.nx -- R2 of the mangagen lane (comparewatch- contract mangagen_mb_bubble_draw):
2// SPEECH BUBBLES onto a manga page. Composes the incumbents rather than re-implementing any of them:
3// nx_aa_raster aa_render -- the anti-aliased polygon fill (nonzero winding, CW fills)
4// nx_nishi_font_core nf_draw_text_mem / nf_adv_px -- OUR OWN stroke letterforms and their metrics
5// nx_img_to_rgb / nx_png_write -- the page in, the page out
6// THE DONE-RULE, pre-declared on /compare/mangagen before a line of this was written:
7// "Bubble geometry + tails + sovereign TTF raster; overflow REFUSES, never silently clips."
8// So text that cannot fit the bubble's inner box is a REFUSAL naming the measured overflow -- never a
9// silent crop, never a shrink-to-fit that quietly rewrites the author's lettering.
10// usage: nx_manga_bubble <in.png|blank:WxH> <out.png> <cx> <cy> <rx> <ry> <tipx> <tipy> <tdir 0-23> <ch> <text...>
11// license_tier: ORIGINAL expect_exit: 0
12import "nx_str.nx"
13import "nx_syscalls.nx"
14import "nx_aa_raster.nx"
15import "nx_png_write.nx"
16import "nx_img_to_rgb.nx"
17import "nx_nishi_font_core.nx"
18const MB_MAGIC_8192: i64 = 8192
19// ---- POLICY vs MATHEMATICS (rule 11) -------------------------------------------------------------
20// The estate's rule-11 ruler (nx_magic) only sees literals at or above its threshold, so it reported ONE
21// site here. That is a FILTERED read: the numbers that actually decide how a balloon LOOKS are all small.
22// They are named below and split by kind, because the two kinds have different futures:
23// MATHEMATICS -- fixed by geometry, not by taste. Naming is all they need; they will never be tuned.
24// POLICY -- the lettering house style. Named here as the first step; the honest end state is a
25// style row beside the layout rows, and MB_STROKE already comes in as an argument.
26const MB_N: i64 = 24
27const MB_SS: i64 = 4
28// MATHEMATICS: the largest axis-aligned box inside an ellipse is 1/sqrt(2) of its diameter, x1000.
29const MB_INSCRIBED_PERMIL: i64 = 707
30// POLICY: house lettering style.
31const MB_STROKE_DEFAULT: i64 = 3
32const MB_LEAD_DIV: i64 = 4
33const MB_MARGIN_PX: i64 = 8
34const MB_INK_R: i64 = 16
35const MB_INK_G: i64 = 16
36const MB_INK_B: i64 = 20
37const MB_PAPER: i64 = 255
38const MB_TEXT_INK: i64 = 0
39const MB_MASK: i64 = 1048576
40const MB_PTS: i64 = 512
41const MB_LINES: i64 = 24
42func mb_puts(s: *u8) -> i64 { sys_write(1, s, nx_str_len(s)); return 0 }
43func mb_pi(v: i64) -> i64 {
44 let t: *u8 = sys_mmap(32)
45 let o: *u8 = sys_mmap(32)
46 var m: i64 = v
47 var k: i64 = 0
48 if m < 0 { m = 0 - m; sys_write(1, "-" as *u8, 1) }
49 if m == 0 { t[0] = 48; k = 1 }
50 while m > 0 { t[k] = 48 + (m % 10); m = m / 10; k = k + 1 }
51 var i: i64 = 0
52 while i < k { o[i] = t[k - 1 - i]; i = i + 1 }
53 sys_write(1, o, k)
54 return 0
55}
56// ⚠STOPS at the first non-digit. An atoi that merely SKIPS non-digits reads "900x600" as 900600 --
57// caught by the gate's T1 on the very first run, because blank:WxH parses both halves with this.
58func mb_atoi(s: *u8) -> i64 {
59 var i: i64 = 0
60 var v: i64 = 0
61 var neg: i64 = 0
62 if s[0] == (45 as u8) { neg = 1; i = 1 }
63 var go: i64 = 1
64 while go == 1 {
65 let c: i64 = s[i] as i64
66 if c < 48 { go = 0 } else {
67 if c > 57 { go = 0 } else { v = v * 10 + (c - 48); i = i + 1 }
68 }
69 }
70 if neg == 1 { return 0 - v }
71 return v
72}
73// 24-gon tables x1000, angle k*15deg, y-down so positive sin is DOWN the page.
74func mb_tables(cs: *i64, sn: *i64) -> i64 {
75 cs[0]=1000; cs[1]=966; cs[2]=866; cs[3]=707; cs[4]=500; cs[5]=259
76 cs[6]=0; cs[7]=0-259; cs[8]=0-500; cs[9]=0-707; cs[10]=0-866; cs[11]=0-966
77 cs[12]=0-1000; cs[13]=0-966; cs[14]=0-866; cs[15]=0-707; cs[16]=0-500; cs[17]=0-259
78 cs[18]=0; cs[19]=259; cs[20]=500; cs[21]=707; cs[22]=866; cs[23]=966
79 sn[0]=0; sn[1]=259; sn[2]=500; sn[3]=707; sn[4]=866; sn[5]=966
80 sn[6]=1000; sn[7]=966; sn[8]=866; sn[9]=707; sn[10]=500; sn[11]=259
81 sn[12]=0; sn[13]=0-259; sn[14]=0-500; sn[15]=0-707; sn[16]=0-866; sn[17]=0-966
82 sn[18]=0-1000; sn[19]=0-966; sn[20]=0-866; sn[21]=0-707; sn[22]=0-500; sn[23]=0-259
83 return 0
84}
85// append the bubble ring as one CW contour, in aa grid units (pixel*MB_SS).
86func mb_ring(xs: *i64, ys: *i64, cst: *i64, cln: *i64, gb: *i64, cs: *i64, sn: *i64, cx: i64, cy: i64, rx: i64, ry: i64) -> i64 {
87 let p: i64 = gb[0]
88 var k: i64 = 0
89 while k < MB_N {
90 xs[p+k] = (cx + (rx * cs[k]) / 1000) * MB_SS
91 ys[p+k] = (cy + (ry * sn[k]) / 1000) * MB_SS
92 k = k + 1
93 }
94 cst[gb[1]] = p
95 cln[gb[1]] = MB_N
96 gb[0] = p + MB_N
97 gb[1] = gb[1] + 1
98 return 0
99}
100// append the tail as one CW triangle: two base vertices on the ring either side of vertex tdir,
101// apex at the tip. Winding matches the ring so nonzero-fill UNIONS them into one silhouette.
102func mb_tail(xs: *i64, ys: *i64, cst: *i64, cln: *i64, gb: *i64, cs: *i64, sn: *i64, spec: *i64, grow: i64) -> i64 {
103 let cx: i64 = spec[0]
104 let cy: i64 = spec[1]
105 let rx: i64 = spec[2] + grow
106 let ry: i64 = spec[3] + grow
107 let td: i64 = spec[6]
108 var a: i64 = td - 1
109 if a < 0 { a = a + MB_N }
110 var b: i64 = td + 1
111 if b >= MB_N { b = b - MB_N }
112 let p: i64 = gb[0]
113 xs[p+0] = (cx + (rx * cs[a]) / 1000) * MB_SS
114 ys[p+0] = (cy + (ry * sn[a]) / 1000) * MB_SS
115 xs[p+1] = spec[4] * MB_SS
116 ys[p+1] = spec[5] * MB_SS
117 xs[p+2] = (cx + (rx * cs[b]) / 1000) * MB_SS
118 ys[p+2] = (cy + (ry * sn[b]) / 1000) * MB_SS
119 cst[gb[1]] = p
120 cln[gb[1]] = 3
121 gb[0] = p + 3
122 gb[1] = gb[1] + 1
123 return 0
124}
125// composite an aa coverage mask into the i64 framebuffer with a flat ink.
126// fb packing is the font core's: r | g<<8 | b<<16 (low byte RED), so the two agree by construction.
127func mb_cov(fb: *i64, W: i64, H: i64, cov: *u8, ir: i64, ig: i64, ib: i64) -> i64 {
128 var i: i64 = 0
129 let n: i64 = W * H
130 while i < n {
131 let a: i64 = cov[i] as i64
132 if a > 0 {
133 let bgv: i64 = fb[i]
134 let br: i64 = bgv & 255
135 let bg2: i64 = (bgv >> 8) & 255
136 let bb: i64 = (bgv >> 16) & 255
137 let nr: i64 = (ir * a + br * (255 - a)) / 255
138 let ng: i64 = (ig * a + bg2 * (255 - a)) / 255
139 let nb: i64 = (ib * a + bb * (255 - a)) / 255
140 fb[i] = (nr & 255) | ((ng & 255) << 8) | ((nb & 255) << 16)
141 }
142 i = i + 1
143 }
144 return 0
145}
146// greedy word wrap into line offsets. Returns line count, or a NEGATIVE refusal code:
147// -1 a single word is wider than the inner box (no wrap can save it)
148// -2 more lines than the caller's line table can hold
149func mb_wrap(txt: *u8, tlen: i64, adv: i64, boxw: i64, ls: *i64, le: *i64) -> i64 {
150 let percols: i64 = boxw / adv
151 if percols < 1 { return 0 - 1 }
152 var nl: i64 = 0
153 var i: i64 = 0
154 while i < tlen {
155 // skip leading spaces
156 var go1: i64 = 1
157 while go1 == 1 { if i >= tlen { go1 = 0 } else { if txt[i] == (32 as u8) { i = i + 1 } else { go1 = 0 } } }
158 if i >= tlen { return nl }
159 let start: i64 = i
160 var last: i64 = 0 - 1
161 var cur: i64 = i
162 var go2: i64 = 1
163 while go2 == 1 {
164 if cur >= tlen { go2 = 0 } else {
165 if cur - start >= percols { go2 = 0 } else {
166 if txt[cur] == (32 as u8) { last = cur }
167 cur = cur + 1
168 }
169 }
170 }
171 var endp: i64 = cur
172 if cur < tlen {
173 if txt[cur] != (32 as u8) {
174 if last > start { endp = last } else { return 0 - 1 }
175 }
176 }
177 if nl >= MB_LINES { return 0 - 2 }
178 ls[nl] = start
179 le[nl] = endp
180 nl = nl + 1
181 i = endp
182 }
183 return nl
184}
185// THE CONTRACT (watched as mangagen_mb_bubble_draw): draw one outlined speech bubble with its tail and
186// its lettering onto the framebuffer, or REFUSE. spec = cx,cy,rx,ry,tipx,tipy,tdir,ch,stroke.
187// Returns 0 drawn, or a named non-zero refusal -- never a silent clip.
188func mb_bubble_draw(fb: *i64, W: i64, H: i64, spec: *i64, txt: *u8, tlen: i64, mask: *u8) -> i64 {
189 let cs: *i64 = sys_mmap(8 * MB_N) as *i64
190 let sn: *i64 = sys_mmap(8 * MB_N) as *i64
191 mb_tables(cs, sn)
192 let xs: *i64 = sys_mmap(8 * MB_PTS) as *i64
193 let ys: *i64 = sys_mmap(8 * MB_PTS) as *i64
194 let cst: *i64 = sys_mmap(8 * 64) as *i64
195 let cln: *i64 = sys_mmap(8 * 64) as *i64
196 let gb: *i64 = sys_mmap(32) as *i64
197 let cov: *u8 = sys_mmap(W * H + 64)
198 let stroke: i64 = spec[8]
199 // FIT FIRST, DRAW SECOND: a refusal must not leave half a bubble on the page.
200 let ch: i64 = spec[7]
201 let adv: i64 = nf_adv_px(ch)
202 if adv < 1 { mb_puts("MANGA-BUBBLE-REFUSE cell height too small to advance a glyph\n" as *u8); return 4 }
203 // the largest axis-aligned box inside an ellipse is 2*r/sqrt(2) ~ 707 per-mille of the diameter,
204 // then a margin so the lettering does not kiss the stroke.
205 let boxw: i64 = (spec[2] * 2 * MB_INSCRIBED_PERMIL) / 1000 - 2 * stroke - MB_MARGIN_PX
206 let boxh: i64 = (spec[3] * 2 * MB_INSCRIBED_PERMIL) / 1000 - 2 * stroke - MB_MARGIN_PX
207 let lh: i64 = ch + ch / MB_LEAD_DIV
208 let ls: *i64 = sys_mmap(8 * MB_LINES) as *i64
209 let le: *i64 = sys_mmap(8 * MB_LINES) as *i64
210 let nl: i64 = mb_wrap(txt, tlen, adv, boxw, ls, le)
211 if nl == 0 - 1 {
212 mb_puts("MANGA-BUBBLE-REFUSE a word is wider than the bubble: inner box " as *u8); mb_pi(boxw)
213 mb_puts("px fits " as *u8); mb_pi(boxw / adv); mb_puts(" chars at cell " as *u8); mb_pi(ch)
214 mb_puts("px. Widen rx, shrink ch, or rewrite the line -- refusing rather than clipping.\n" as *u8)
215 return 5
216 }
217 if nl == 0 - 2 { mb_puts("MANGA-BUBBLE-REFUSE more lines than the bubble can hold\n" as *u8); return 5 }
218 if nl * lh > boxh {
219 mb_puts("MANGA-BUBBLE-REFUSE text overflows the bubble: " as *u8); mb_pi(nl)
220 mb_puts(" lines at " as *u8); mb_pi(lh); mb_puts("px need " as *u8); mb_pi(nl * lh)
221 mb_puts("px, inner box is " as *u8); mb_pi(boxh)
222 mb_puts("px. Grow ry, shrink ch, or split the balloon -- refusing rather than clipping.\n" as *u8)
223 return 5
224 }
225 // ---- outline pass: the silhouette grown by the stroke, inked dark ----
226 gb[0] = 0
227 gb[1] = 0
228 mb_ring(xs, ys, cst, cln, gb, cs, sn, spec[0], spec[1], spec[2] + stroke, spec[3] + stroke)
229 mb_tail(xs, ys, cst, cln, gb, cs, sn, spec, stroke)
230 aa_render(xs, ys, cst, cln, gb[1], cov, W, H, MB_SS)
231 mb_cov(fb, W, H, cov, MB_INK_R, MB_INK_G, MB_INK_B)
232 // ---- fill pass: the same silhouette at true size, inked paper-white ----
233 gb[0] = 0
234 gb[1] = 0
235 mb_ring(xs, ys, cst, cln, gb, cs, sn, spec[0], spec[1], spec[2], spec[3])
236 mb_tail(xs, ys, cst, cln, gb, cs, sn, spec, 0)
237 aa_render(xs, ys, cst, cln, gb[1], cov, W, H, MB_SS)
238 mb_cov(fb, W, H, cov, MB_PAPER, MB_PAPER, MB_PAPER)
239 // ---- lettering: centred, on the font core's own metrics ----
240 var y: i64 = spec[1] - (nl * lh) / 2
241 var li: i64 = 0
242 while li < nl {
243 let sl: i64 = le[li] - ls[li]
244 let wpx: i64 = sl * adv
245 let x: i64 = spec[0] - wpx / 2
246 let seg: *u8 = (txt as i64 + ls[li]) as *u8
247 nf_draw_text_mem(fb, W, H, mask, x, y, ch, seg, sl, MB_TEXT_INK)
248 y = y + lh
249 li = li + 1
250 }
251 mb_puts(" bubble at " as *u8); mb_pi(spec[0]); mb_puts("," as *u8); mb_pi(spec[1])
252 mb_puts(" r=" as *u8); mb_pi(spec[2]); mb_puts("x" as *u8); mb_pi(spec[3])
253 mb_puts(" tail->" as *u8); mb_pi(spec[4]); mb_puts("," as *u8); mb_pi(spec[5])
254 mb_puts(" lines=" as *u8); mb_pi(nl); mb_puts(" cell=" as *u8); mb_pi(ch)
255 mb_puts("px fits " as *u8); mb_pi(nl * lh); mb_puts("/" as *u8); mb_pi(boxh); mb_puts("px\n" as *u8)
256 return 0
257}
258func main(argc: i64, argv: *i64) -> i64 {
259 if argc < 12 {
260 mb_puts("usage: nx_manga_bubble <in.png|blank:WxH> <out.png> <cx> <cy> <rx> <ry> <tipx> <tipy> <tdir 0-23> <ch> <text...>\n" as *u8)
261 mb_puts(" tdir is the ring vertex the tail leaves from: 6 = straight down, 0 = right, 12 = up, 18 = left.\n" as *u8)
262 mb_puts(" text that cannot fit REFUSES with the measurement -- it is never clipped or shrunk.\n" as *u8)
263 sys_exit(2); return 2
264 }
265 let inp: *u8 = argv[1] as *u8
266 let outp: *u8 = argv[2] as *u8
267 var W: i64 = 0
268 var H: i64 = 0
269 let fbw: *i64 = sys_mmap(32) as *i64
270 var src: *u8 = 0 as *u8
271 if inp[0] == (98 as u8) {
272 // blank:WxH -- an empty page, for the gate and for a bubble sheet with no art under it
273 var q: i64 = 0
274 while inp[q] != (0 as u8) { if inp[q] == (58 as u8) { break } q = q + 1 }
275 let wp: *u8 = (inp as i64 + q + 1) as *u8
276 W = mb_atoi(wp)
277 var r: i64 = 0
278 while wp[r] != (0 as u8) { if wp[r] == (120 as u8) { break } r = r + 1 }
279 let hp: *u8 = (wp as i64 + r + 1) as *u8
280 H = mb_atoi(hp)
281 } else {
282 fbw[0] = 0
283 fbw[1] = 0
284 src = nx_img_to_rgb(inp, fbw)
285 if src == 0 as *u8 { mb_puts("MANGA-BUBBLE-REFUSE cannot decode input page: " as *u8); mb_puts(inp); mb_puts("\n" as *u8); sys_exit(3); return 3 }
286 W = fbw[0]
287 H = fbw[1]
288 }
289 if W < 16 { mb_puts("MANGA-BUBBLE-REFUSE page width too small\n" as *u8); sys_exit(3); return 3 }
290 if H < 16 { mb_puts("MANGA-BUBBLE-REFUSE page height too small\n" as *u8); sys_exit(3); return 3 }
291 let fb: *i64 = sys_mmap(8 * W * H + 64) as *i64
292 var i: i64 = 0
293 let n: i64 = W * H
294 if (src as i64) == 0 {
295 while i < n { fb[i] = 255 | (255 << 8) | (255 << 16); i = i + 1 }
296 } else {
297 while i < n {
298 let o: i64 = i * 3
299 fb[i] = (src[o] as i64) | ((src[o+1] as i64) << 8) | ((src[o+2] as i64) << 16)
300 i = i + 1
301 }
302 }
303 let spec: *i64 = sys_mmap(128) as *i64
304 spec[0] = mb_atoi(argv[3] as *u8)
305 spec[1] = mb_atoi(argv[4] as *u8)
306 spec[2] = mb_atoi(argv[5] as *u8)
307 spec[3] = mb_atoi(argv[6] as *u8)
308 spec[4] = mb_atoi(argv[7] as *u8)
309 spec[5] = mb_atoi(argv[8] as *u8)
310 spec[6] = mb_atoi(argv[9] as *u8)
311 spec[7] = mb_atoi(argv[10] as *u8)
312 // POLICY, NAMED: the stroke is the house default rather than a literal buried in the draw path.
313 // ⚠It is deliberately NOT read from argv[11]: that slot is the first WORD OF DIALOGUE, so a flag
314 // there would silently restyle any balloon whose line happened to start with the flag letter. The
315 // honest home for stroke, ink and leading is a style row beside layouts.conf -- filed, not faked.
316 spec[8] = MB_STROKE_DEFAULT
317 if spec[6] < 0 { spec[6] = 0 }
318 if spec[6] >= MB_N { spec[6] = MB_N - 1 }
319 // join argv[11..] with single spaces into one line of dialogue
320 let txt: *u8 = sys_mmap(MB_MAGIC_8192)
321 var tl: i64 = 0
322 var a: i64 = 11
323 while a < argc {
324 let w: *u8 = argv[a] as *u8
325 var k: i64 = 0
326 while w[k] != (0 as u8) { txt[tl] = w[k]; tl = tl + 1; k = k + 1 }
327 if a + 1 < argc { txt[tl] = 32 as u8; tl = tl + 1 }
328 a = a + 1
329 }
330 txt[tl] = 0 as u8
331 mb_puts("=== nx_manga_bubble page=" as *u8); mb_pi(W); mb_puts("x" as *u8); mb_pi(H); mb_puts("\n" as *u8)
332 let mask: *u8 = sys_mmap(MB_MASK)
333 let rc: i64 = mb_bubble_draw(fb, W, H, spec, txt, tl, mask)
334 if rc != 0 { sys_exit(rc); return rc }
335 let rgb: *u8 = sys_mmap(W * H * 3 + 64)
336 i = 0
337 while i < n {
338 let v: i64 = fb[i]
339 let o: i64 = i * 3
340 rgb[o] = (v & 255) as u8
341 rgb[o+1] = ((v >> 8) & 255) as u8
342 rgb[o+2] = ((v >> 16) & 255) as u8
343 i = i + 1
344 }
345 let wrc: i64 = nx_png_write_rgb(outp, rgb, W, H)
346 if wrc != 0 { mb_puts("MANGA-BUBBLE-REFUSE png write failed\n" as *u8); sys_exit(6); return 6 }
347 mb_puts("MANGA-BUBBLE-OK " as *u8); mb_puts(outp); mb_puts("\n" as *u8)
348 sys_exit(0)
349 return 0
350}