mkit-cli 0.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mkit/art.py ADDED
@@ -0,0 +1,1220 @@
1
+ """Store art: Play screenshots and the feature graphic, built from real phone
2
+ captures and a words file per language (docs/store-art.md).
3
+
4
+ Everything that is the same for every app lives here: the page templates (a
5
+ landscape hero, portrait slides, the feature graphic) in two layouts (classic
6
+ and pop; art.toml `layout`), the phone frame, the
7
+ clean status bar, right-to-left mirroring, fonts for each script, and the
8
+ line-count check. An app keeps only what makes its art its own, in its art
9
+ folder (`[art] dir`, default store/art):
10
+
11
+ shots/<name>.png raw captures, 1080x2340, as `adb exec-out screencap -p` gives them
12
+ shots/<code>/<name>.png optional: a capture of the app in that language
13
+ captions/<code>.json the words, keyed by slide id; one file per listing code
14
+ art.toml slides, erase boxes, colours, font and mascot
15
+
16
+ Pages are HTML drawn by a headless Chromium browser (Edge or Chrome), so the
17
+ text is real type in the app's own font. Images go where `mkit listing` reads
18
+ them: <[store] art_dir>/screenshots/<code>/ and <art_dir>/feature/<code>.png.
19
+ """
20
+ import html
21
+ import json
22
+ import math
23
+ import os
24
+ import random
25
+ import shutil
26
+ import subprocess
27
+ import tempfile
28
+ import time
29
+ import tomllib
30
+ from concurrent.futures import ThreadPoolExecutor
31
+ from pathlib import Path
32
+
33
+ from PIL import Image
34
+
35
+ from .errors import NotFound, UsageError
36
+
37
+ PORTRAIT = (1080, 1920)
38
+ LANDSCAPE = (1920, 1080)
39
+ FEATURE = (1024, 500)
40
+ SHOT_W, SHOT_H = 1080, 2340
41
+ TEMPLATES = Path(__file__).parent / 'data' / 'art'
42
+
43
+ # Backdrops a page can draw behind its copy and phones (art.toml `decoration`).
44
+ DECORATIONS = ('contour', 'leaves', 'none')
45
+
46
+ # Page designs (art.toml `layout`). classic: words over phones on a soft
47
+ # gradient. pop: a vivid ground, short bold titles with an underlined word,
48
+ # big phones and floating pills and pictures the app places itself.
49
+ LAYOUTS = ('classic', 'pop')
50
+
51
+ # What the pop ground grows behind the phones (art.toml `ornament`), and how the title's [word]
52
+ # is marked (`underline_style`). The defaults are the pop look as it first shipped.
53
+ ORNAMENTS = ('sprigs', 'scatter', 'none')
54
+
55
+ # `scatter`: soft glass shapes over the whole ground, as an app might draw behind its own screens.
56
+ # (x, y) as fractions of the page, width as a fraction of its shorter side, tilt in degrees, and
57
+ # kind: 0 a rounded square, 1 a circle, 2 a pill.
58
+ SCATTER = (
59
+ (.06, .08, .16, -19, 0), (.59, .035, .10, 0, 1), (.86, .20, .22, 24, 0), (.37, .24, .13, -32, 2),
60
+ (-.07, .36, .21, 0, 1), (.64, .43, .18, 17, 0), (.18, .54, .12, 34, 0), (.91, .59, .15, -28, 2),
61
+ (.43, .69, .09, 0, 1), (-.04, .78, .18, -12, 0), (.74, .84, .20, 29, 0), (.29, .94, .16, -23, 2),
62
+ )
63
+ UNDERLINES = ('swoosh', 'colour', 'none')
64
+
65
+ # What happens to a capture's status bar ([status_bar] mode). redraw: its
66
+ # icons are erased and a 9:41 bar is drawn over it on the page. clean: the
67
+ # capture keeps its own bar, painted over in its own colour, with a neat 9:41,
68
+ # signal and battery drawn into the capture.
69
+ BAR_MODES = ('redraw', 'clean')
70
+
71
+ # Characters a caption may not use: dashes, arrows, bullets, invisible joiners.
72
+ BANNED = {'\u2014': 'em dash', '\u2013': 'en dash', '\u2192': 'arrow', '\u2190': 'arrow',
73
+ '\u2022': 'bullet', '\u00b7': 'middle dot', '\u2060': 'word joiner'}
74
+
75
+ THEME = {
76
+ 'light': dict(bg1='#F4F7EF', bg2='#DCEAD4', line='#4E9150', line_op=0.20, ink='#1F2A21',
77
+ sub='#4D5C50', accent='#2F7A3E', tile='#E3EFDD', chip_border='rgba(78,145,80,.28)',
78
+ phone_shadow='0 50px 90px rgba(20,40,25,.30),0 12px 28px rgba(20,40,25,.20),'
79
+ 'inset 0 0 0 3px #2B302D'),
80
+ 'dark': dict(bg1='#0E1210', bg2='#16261A', line='#A5D69B', line_op=0.13, ink='#F3F6F1',
81
+ sub='#A9B5AB', accent='#A5D69B', tile='#E3EFDD', chip_border='rgba(78,145,80,.28)',
82
+ phone_shadow='0 0 0 3px #3C4A40,0 40px 120px rgba(120,190,110,.22),'
83
+ 'inset 0 0 0 3px #2B302D'),
84
+ }
85
+
86
+ # The pop layout's colours (art.toml [theme.light] / [theme.dark] override any).
87
+ _POP_LIGHT = dict(
88
+ bg1='#55D27E', bg2='#26AC52', bg3='#168A3E', bg_mid=48, # ground: a radial glow from the top
89
+ blob_dark='#0F7A34', blob_light='#7BE39B', # soft shade bottom left, light top right
90
+ glow='#FFFFFF', # halo and ring behind the phones, sprigs, dots, chips
91
+ ink='#FFFFFF', title_shadow='0 4px 18px rgba(5,60,25,.25)',
92
+ underline='#FFD95A', # the hand-drawn line under the [word]
93
+ accent='#1E9E4A', # ticks, the go pill, the notification tile
94
+ pill='#FFFFFF', pill_ink='#1B2A1F',
95
+ shade='#043214', # every shadow, at its own strength
96
+ phone='#141816')
97
+ POP_THEME = {
98
+ 'light': _POP_LIGHT,
99
+ 'dark': {**_POP_LIGHT, 'bg1': '#1F3D29', 'bg2': '#122419', 'bg3': '#0B140E', 'bg_mid': 50,
100
+ 'title_shadow': 'none'},
101
+ }
102
+
103
+ # The app's font draws Latin; other scripts fall back, glyph by glyph, to the
104
+ # system font closest to it (Windows names; art.toml [script_fonts] overrides).
105
+ SCRIPT_FONTS = {
106
+ **dict.fromkeys(['be', 'bg', 'ru', 'sr', 'uk', 'el', 'kk', 'mk', 'mn'], 'Bahnschrift'),
107
+ **dict.fromkeys(['ar', 'fa', 'ur', 'he', 'iw', 'ka', 'hy'], 'Segoe UI'),
108
+ **dict.fromkeys(['hi', 'bn', 'mr', 'ne', 'ta', 'te', 'kn', 'ml', 'gu', 'pa', 'si'], 'Nirmala UI'),
109
+ 'th': 'Leelawadee UI', 'lo': 'Leelawadee UI', 'km': 'Leelawadee UI',
110
+ 'ja': 'Yu Gothic UI', 'ko': 'Malgun Gothic',
111
+ 'zh-CN': 'Microsoft YaHei UI', 'zh-TW': 'Microsoft JhengHei UI', 'zh-HK': 'Microsoft JhengHei UI',
112
+ }
113
+ RTL = {'ar', 'fa', 'ur', 'he', 'iw'}
114
+
115
+ # What a script needs beyond its font: Korean breaks only between words;
116
+ # Arabic's tall letters and marks need more room between lines.
117
+ LANG_CSS = {
118
+ 'ko': 'body{word-break:keep-all}',
119
+ 'ar': 'html[lang] h1{line-height:1.3}html[lang] p{line-height:1.45}',
120
+ 'fa': 'html[lang] h1{line-height:1.3}html[lang] p{line-height:1.45}',
121
+ 'ur': 'html[lang] h1{line-height:1.3}html[lang] p{line-height:1.45}',
122
+ 'he': 'html[lang] h1{line-height:1.12}',
123
+ }
124
+
125
+
126
+ # ---------------------------------------------------------------- the app's design
127
+
128
+ class Design:
129
+ """One app's art settings: its folder, art.toml and where images go."""
130
+
131
+ def __init__(self, project):
132
+ self.project = project
133
+ self.root = project.root
134
+ self.dir = project.path('art.dir', 'store/art')
135
+ self.out = project.path('art.out') or project.path('store.art_dir', 'store')
136
+ file = self.dir / 'art.toml'
137
+ if not file.is_file():
138
+ raise NotFound(f'no {file.relative_to(self.root)}',
139
+ hint='Run `mkit art init` to set up the art folder.', code='NO_ART')
140
+ with open(file, 'rb') as f:
141
+ try:
142
+ self.data = tomllib.load(f)
143
+ except tomllib.TOMLDecodeError as e:
144
+ raise UsageError(f'{file} is not valid TOML: {e}', code='BAD_ART') from e
145
+ self.slides = self.data.get('slide') or []
146
+ if not self.slides:
147
+ raise UsageError(f'{file}: no [[slide]] entries', code='BAD_ART')
148
+ self.reference = self.data.get('reference', 'en')
149
+ self.layout = self._choice(file, 'layout', self.data.get('layout', 'classic'), LAYOUTS)
150
+ self.decoration = self._choice(file, 'decoration', self.data.get('decoration', 'contour'), DECORATIONS)
151
+ self.ornament = self._choice(file, 'ornament', self.data.get('ornament', 'sprigs'), ORNAMENTS)
152
+ self.underline_style = self._choice(file, 'underline_style',
153
+ self.data.get('underline_style', 'swoosh'), UNDERLINES)
154
+ bar = self.data.get('status_bar') or {}
155
+ self.bar_mode = self._choice(file, '[status_bar] mode',
156
+ bar.get('mode', 'clean' if self.layout == 'pop' else 'redraw'), BAR_MODES)
157
+ base = POP_THEME if self.layout == 'pop' else THEME
158
+ self.theme = {k: {**base[k], **(self.data.get('theme', {}).get(k) or {})} for k in base}
159
+ self.font = self.data.get('font', 'Barlow')
160
+ self.font_dir = self._path(self.data.get('font_dir'))
161
+ self.mascot = self._path(self.data.get('mascot'))
162
+ self.script_fonts = {**SCRIPT_FONTS, **(self.data.get('script_fonts') or {})}
163
+
164
+ @staticmethod
165
+ def _choice(file, name, value, allowed):
166
+ if value not in allowed:
167
+ raise UsageError(f'{file}: {name} must be one of {", ".join(allowed)}, not {value!r}',
168
+ code='BAD_ART')
169
+ return value
170
+
171
+ def _path(self, rel):
172
+ return (self.root / rel) if rel else None
173
+
174
+ def font_file(self, *styles):
175
+ """The first of the app font's files in these styles, or None."""
176
+ for style in styles:
177
+ file = self.font_dir / f'{self.font}-{style}.ttf' if self.font_dir else None
178
+ if file and file.is_file():
179
+ return file
180
+ return None
181
+
182
+ def asset(self, rel):
183
+ """A picture a page places (art.toml `src`), relative to the app folder."""
184
+ path = self.root / rel
185
+ if not path.is_file():
186
+ raise NotFound(f'no {rel} (an art `src` in {self.dir.relative_to(self.root).as_posix()}/art.toml)',
187
+ code='NO_ART_ASSET')
188
+ return path
189
+
190
+ def codes(self):
191
+ return sorted(p.stem for p in (self.dir / 'captions').glob('*.json'))
192
+
193
+ def words(self, code):
194
+ file = self.dir / 'captions' / f'{code}.json'
195
+ if not file.is_file():
196
+ raise NotFound(f'no captions for {code}: {file.relative_to(self.root)}', code='NO_CAPTIONS')
197
+ try:
198
+ return json.loads(file.read_text(encoding='utf-8'))
199
+ except json.JSONDecodeError as e:
200
+ raise UsageError(f'{file}: {e}', code='BAD_CAPTIONS') from e
201
+
202
+ def shot(self, name, code):
203
+ own = self.dir / 'shots' / code / f'{name}.png'
204
+ path = own if own.is_file() else self.dir / 'shots' / f'{name}.png'
205
+ if not path.is_file():
206
+ raise NotFound(f'no capture {path.relative_to(self.root)}', code='NO_SHOT')
207
+ return path
208
+
209
+ def screenshot_dir(self, code):
210
+ return self.out / 'screenshots' / code
211
+
212
+ def feature_file(self, code):
213
+ return self.out / 'feature' / f'{code}.png'
214
+
215
+
216
+ # ---------------------------------------------------------------- captures
217
+
218
+ class Shots:
219
+ """Cleans each capture once per build: erases anything private, each
220
+ erased row blended from its left edge to its right, and tidies the
221
+ status bar. In redraw mode its icons are erased the same way and the
222
+ bar's ink (light or dark) is remembered for the 9:41 drawn over it; in
223
+ clean mode the bar is painted over in its own colour and a 9:41 drawn
224
+ into the capture."""
225
+
226
+ def __init__(self, design, code, work):
227
+ self.design = design
228
+ self.code = code
229
+ self.work = work / 'shots' / code
230
+ self.work.mkdir(parents=True, exist_ok=True)
231
+ self.ink = {}
232
+ bar = design.data.get('status_bar') or {}
233
+ self.mode = design.bar_mode
234
+ self.clean = self.mode == 'clean'
235
+ self.bar_height = bar.get('height', 96 if self.clean else 100)
236
+ self.bar_erase = bar.get('erase', [[36, 8, 250, 96], [760, 8, 1048, 96]])
237
+ self.sample = tuple(bar.get('sample', [540, 60]))
238
+
239
+ def get(self, name):
240
+ out = self.work / f'{name}.png'
241
+ if name not in self.ink:
242
+ src = self.design.shot(name, self.code)
243
+ im = Image.open(src).convert('RGB')
244
+ if im.size != (SHOT_W, SHOT_H):
245
+ raise UsageError(f'{src.name}: expected {SHOT_W}x{SHOT_H}, got {im.size[0]}x{im.size[1]}',
246
+ code='BAD_SHOT')
247
+ extra = (self.design.data.get('shots', {}).get(name) or {}).get('erase', [])
248
+ if self.clean:
249
+ for box in extra:
250
+ blend_out(im, *box)
251
+ self.ink[name] = clean_bar(im, self.bar_height, self.sample,
252
+ self.design.font_file('SemiBold', 'Medium', 'Bold', 'Regular'))
253
+ else:
254
+ for box in self.bar_erase + extra:
255
+ blend_out(im, *box)
256
+ self.ink[name] = ink_for(im, self.bar_height)
257
+ im.save(out)
258
+ return out, self.ink[name]
259
+
260
+
261
+ def blend_out(im, x0, y0, x1, y1):
262
+ """Replace a box with, row by row, a blend from its left edge to its right."""
263
+ px = im.load()
264
+ for y in range(y0, y1):
265
+ a, b = px[x0 - 1, y], px[x1, y]
266
+ for x in range(x0, x1):
267
+ t = (x - x0 + 1) / (x1 - x0 + 1)
268
+ px[x, y] = tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(3))
269
+
270
+
271
+ def ink_for(im, height):
272
+ """Dark icons on a light status bar, light ones on a dark one."""
273
+ r, g, b = im.crop((0, 0, im.width, height)).resize((1, 1), Image.BOX).getpixel((0, 0))
274
+ return '#1F2A21' if 0.2126 * r + 0.7152 * g + 0.0722 * b > 128 else '#F3F6F1'
275
+
276
+
277
+ def clean_bar(im, height, sample, font_file=None):
278
+ """Keep the capture's own status bar but paint out what is on it (the
279
+ real time, notification icons, battery) in the colour at [sample], then
280
+ draw a neat 9:41, signal and battery in an ink that reads on it: dark on
281
+ a light bar, light on a dark one. Returns the ink."""
282
+ from PIL import ImageDraw, ImageFont
283
+ bg = im.getpixel(sample)
284
+ light = 0.2126 * bg[0] + 0.7152 * bg[1] + 0.0722 * bg[2] > 128
285
+ ink = (31, 42, 33) if light else (236, 242, 236)
286
+ d = ImageDraw.Draw(im)
287
+ d.rectangle((0, 0, im.width, height), fill=bg)
288
+ font = ImageFont.truetype(str(font_file), 40) if font_file else ImageFont.load_default(40)
289
+ d.text((70, 26), '9:41', font=font, fill=ink)
290
+ for i, h in enumerate((14, 20, 26, 32)):
291
+ d.rounded_rectangle((880 + i * 14, 66 - h, 889 + i * 14, 66), 3, fill=ink)
292
+ d.rounded_rectangle((950, 40, 1006, 66), 6, outline=ink, width=3)
293
+ d.rounded_rectangle((955, 45, 990, 61), 3, fill=ink)
294
+ return '#%02X%02X%02X' % ink
295
+
296
+
297
+ # ---------------------------------------------------------------- caption rules
298
+
299
+ def lint(words, reference, slides):
300
+ """Problems a captions file has on its own: keys that differ from the
301
+ reference language, titles without exactly one [accent], banned characters."""
302
+ problems = []
303
+
304
+ def keys(node, prefix=''):
305
+ if isinstance(node, dict):
306
+ out = set()
307
+ for k, v in node.items():
308
+ if k in ('locale', 'dir', 'font'):
309
+ continue
310
+ out |= {f'{prefix}{k}'} | keys(v, f'{prefix}{k}.')
311
+ return out
312
+ return set()
313
+
314
+ have, want = keys(words), keys(reference)
315
+ for k in sorted(want - have):
316
+ problems.append(f'missing {k}')
317
+ for k in sorted(have - want):
318
+ problems.append(f'not in the reference: {k}')
319
+ for sid in [s['id'] for s in slides] + ['feature']:
320
+ title = (words.get(sid) or {}).get('title', '')
321
+ if title and (title.count('[') != 1 or title.count(']') != 1):
322
+ problems.append(f'{sid}.title: needs exactly one [accent]')
323
+
324
+ def walk(node, where):
325
+ if isinstance(node, dict):
326
+ for k, v in node.items():
327
+ walk(v, f'{where}{k}.')
328
+ elif isinstance(node, list):
329
+ for i, v in enumerate(node):
330
+ walk(v, f'{where}{i}.')
331
+ elif isinstance(node, str):
332
+ for ch, name in BANNED.items():
333
+ if ch in node:
334
+ problems.append(f'{where[:-1]}: {name}')
335
+
336
+ walk(words, '')
337
+ return problems
338
+
339
+
340
+ # ---------------------------------------------------------------- page pieces
341
+
342
+ def rich(text):
343
+ """Escape a caption and turn [words] into the accent span."""
344
+ return html.escape(text).replace('[', '<span class="hl">').replace(']', '</span>')
345
+
346
+
347
+ def contour(cx, cy, r0, rings, seed, colour, opacity, step=34):
348
+ """Wobbly concentric rings, a topographic backdrop."""
349
+ rnd = random.Random(seed)
350
+ p1, p2 = rnd.uniform(0, 6), rnd.uniform(0, 6)
351
+ paths = []
352
+ for i in range(rings):
353
+ r = r0 + i * step
354
+ pts = []
355
+ for k in range(97):
356
+ a = k / 96 * 2 * math.pi
357
+ w = 1 + 0.07 * math.sin(3 * a + p1 + i * 0.25) + 0.04 * math.sin(5 * a + p2)
358
+ pts.append(f'{cx + r * w * math.cos(a):.1f},{cy + r * w * 0.86 * math.sin(a):.1f}')
359
+ paths.append(f'<polyline points="{" ".join(pts)}"/>')
360
+ return (f'<g fill="none" stroke="{colour}" stroke-width="2.4" '
361
+ f'stroke-opacity="{opacity}">{"".join(paths)}</g>')
362
+
363
+
364
+ def ornament(kind, x, y, angle, length, seed, colour):
365
+ """One spot of the pop ground's ornament (art.toml `ornament`), growing in from (x, y)."""
366
+ if kind == 'sprigs':
367
+ return sprig(x, y, angle, length, seed, colour, .10, count=7, curl_by=.2, start=.16,
368
+ span=.76, shrink=.35, pulse=1.3, stem=1, width=6)
369
+ return ''
370
+
371
+
372
+ def scatter(w, h, colour, rtl=False):
373
+ """The `scatter` ornament: SCATTER's glass shapes, each a faint fill with a lighter rim."""
374
+ unit = min(w, h)
375
+ out = []
376
+ for fx, fy, fw, tilt, kind in SCATTER:
377
+ sw = fw * unit
378
+ sh = sw * .38 if kind == 2 else sw
379
+ r = sw * .25 if kind == 0 else sw / 2
380
+ x = (1 - fx) * w - sw if rtl else fx * w
381
+ y = fy * h
382
+ turn = -tilt if rtl else tilt
383
+ out.append(f'<rect x="{x:.1f}" y="{y:.1f}" width="{sw:.1f}" height="{sh:.1f}" rx="{r:.1f}" '
384
+ f'fill="{colour}" fill-opacity=".09" stroke="{colour}" stroke-opacity=".20" stroke-width="2" '
385
+ f'transform="rotate({turn} {x + sw / 2:.1f} {y + sh / 2:.1f})"/>')
386
+ return ''.join(out)
387
+
388
+
389
+ def sprig(x, y, angle, length, seed, colour, opacity,
390
+ count=6, curl_by=0.18, start=.2, span=.72, shrink=.4, pulse=1.25, stem=1.2, width=5):
391
+ """A stem with leaves along it, growing from (x, y) toward [angle]: a
392
+ garden backdrop, for apps whose look is leaves rather than contours.
393
+ The defaults are the classic sprig; the pop ground grows a fuller one."""
394
+ rnd = random.Random(seed)
395
+ dx, dy = math.cos(angle), math.sin(angle)
396
+ nx, ny = -dy, dx
397
+ curl = rnd.choice([-1, 1]) * length * curl_by
398
+ ex, ey = x + dx * length, y + dy * length
399
+ cx, cy = x + dx * length * 0.5 + nx * curl, y + dy * length * 0.5 + ny * curl
400
+
401
+ def at(t):
402
+ u = 1 - t
403
+ return (x * u * u + cx * 2 * u * t + ex * t * t, y * u * u + cy * 2 * u * t + ey * t * t)
404
+
405
+ def tangent(t):
406
+ tx = (cx - x) * 2 * (1 - t) + (ex - cx) * 2 * t
407
+ ty = (cy - y) * 2 * (1 - t) + (ey - cy) * 2 * t
408
+ m = math.hypot(tx, ty) or 1
409
+ return tx / m, ty / m
410
+
411
+ def leaf(bx, by, a, ln, op):
412
+ ux, uy = math.cos(a), math.sin(a)
413
+ vx, vy = -uy, ux
414
+ w = ln * 0.42
415
+
416
+ def p(t, s):
417
+ return f'{bx + ux * ln * t + vx * w * s:.1f},{by + uy * ln * t + vy * w * s:.1f}'
418
+ return (f'<path d="M{bx:.1f},{by:.1f} C{p(.25, 1)} {p(.75, .6)} {p(1, 0)} '
419
+ f'C{p(.75, -.6)} {p(.25, -1)} {bx:.1f},{by:.1f}Z" fill-opacity="{op:.3f}"/>')
420
+
421
+ leaves = []
422
+ for i in range(count):
423
+ t = start + span * i / (count - 1)
424
+ side = 1 if i % 2 == 0 else -1
425
+ tx, ty = tangent(t)
426
+ a = math.atan2(ty, tx) + side * .8
427
+ bx, by = at(t)
428
+ leaves.append(leaf(bx, by, a, length * .3 * (1 - shrink * i / count), opacity * (pulse if i % 2 else 1)))
429
+ tx, ty = tangent(1)
430
+ leaves.append(leaf(ex, ey, math.atan2(ty, tx), length * .2, opacity * pulse))
431
+ return (f'<g fill="{colour}"><path d="M{x:.1f},{y:.1f} Q{cx:.1f},{cy:.1f} {ex:.1f},{ey:.1f}" fill="none" '
432
+ f'stroke="{colour}" stroke-opacity="{opacity * stem:.3f}" stroke-width="{width}" stroke-linecap="round"/>'
433
+ f'{"".join(leaves)}</g>')
434
+
435
+
436
+ def backdrop(style, size, theme, spots):
437
+ """The page's decoration. Each spot is (x, y, size, rings, seed, step): a
438
+ set of contour rings there, or a sprig growing in from there."""
439
+ if style == 'none':
440
+ return ''
441
+ w, h = size
442
+ out = []
443
+ for x, y, r0, rings, seed, step in spots:
444
+ if style == 'leaves':
445
+ bx, by = min(max(x, 0), w), min(max(y, 0), h)
446
+ angle = math.atan2(h / 2 - by, w / 2 - bx) + random.Random(seed).uniform(-.5, .5)
447
+ out.append(sprig(bx, by, angle, (r0 + rings * step) * .62, seed,
448
+ theme['line'], theme['line_op'] * 1.6))
449
+ else:
450
+ out.append(contour(x, y, r0, rings, seed, theme['line'], theme['line_op'], step=step))
451
+ return ''.join(out)
452
+
453
+
454
+ def status_icons(ink):
455
+ return f'''
456
+ <div class="time" style="color:{ink}">9:41</div>
457
+ <svg class="icons" viewBox="0 0 120 30" fill="{ink}">
458
+ <rect x="0" y="18" width="5" height="8" rx="1.5"/><rect x="8" y="13" width="5" height="13" rx="1.5"/>
459
+ <rect x="16" y="8" width="5" height="18" rx="1.5"/><rect x="24" y="3" width="5" height="23" rx="1.5"/>
460
+ <path d="M47 25l-4.2-4.6a6.2 6.2 0 0 1 8.4 0z"/>
461
+ <path d="M38.6 15.8a12.4 12.4 0 0 1 16.8 0l-2.4 2.6a8.8 8.8 0 0 0-12 0z"/>
462
+ <path d="M34.4 11.2a18.6 18.6 0 0 1 25.2 0l-2.4 2.6a15 15 0 0 0-20.4 0z"/>
463
+ <rect x="72" y="6" width="38" height="19" rx="5" fill="none" stroke="{ink}" stroke-width="2.4"/>
464
+ <rect x="75.5" y="9.5" width="28" height="12" rx="2.5"/>
465
+ <rect x="112" y="12" width="3" height="7" rx="1.2"/>
466
+ </svg>'''
467
+
468
+
469
+ def mirror(rtl, width):
470
+ """For right-to-left pages: flip an x position (and a tilt) across the page."""
471
+ def x(pos, w=0):
472
+ return width - pos - w if rtl else pos
473
+
474
+ def turn(deg):
475
+ return -deg if rtl else deg
476
+ return x, turn
477
+
478
+
479
+ # The 9:41 bar drawn over a capture in redraw mode.
480
+ BAR_CSS = '''.bar{position:absolute;left:0;top:0;right:0;display:flex;align-items:center;justify-content:space-between;
481
+ padding:0 calc(46px * var(--s)) 0 calc(56px * var(--s))}
482
+ .time{font:600 calc(34px * var(--s))/1 var(--ff);letter-spacing:.3px}
483
+ .icons{width:calc(106px * var(--s));height:calc(27px * var(--s))}
484
+ .cam{position:absolute;left:50%%;top:50%%;width:calc(30px * var(--s));height:calc(30px * var(--s));
485
+ margin:calc(-15px * var(--s)) 0 0 calc(-15px * var(--s));border-radius:50%%;background:#050605;
486
+ box-shadow:inset 0 0 0 calc(5px * var(--s)) #1A1D1B}
487
+ '''
488
+
489
+ BASE_CSS = '''
490
+ *{margin:0;padding:0;box-sizing:border-box}
491
+ html,body{width:%(w)dpx;height:%(h)dpx;overflow:hidden}
492
+ body{font-family:var(--ff);position:relative;background:linear-gradient(170deg,%(bg1)s 0%%,%(bg2)s 100%%)}
493
+ .deco{position:absolute;inset:0}
494
+ .hl{color:%(accent)s}
495
+ .phone{position:absolute;background:#0B0D0C;box-shadow:%(phone_shadow)s}
496
+ .screen{position:relative;overflow:hidden;background:#fff}
497
+ .screen img{display:block}
498
+ ''' + BAR_CSS + '''.chips{display:flex;flex-wrap:wrap;gap:12px}
499
+ .chips span{font-weight:600;color:%(ink)s;background:rgba(255,255,255,.72);
500
+ border:2px solid %(chip_border)s;border-radius:99px}
501
+ .mascot{display:block}
502
+ '''
503
+
504
+ PORTRAIT_CSS = '''
505
+ .head{position:absolute;left:70px;right:70px;top:40px;height:480px;display:flex;flex-direction:column;
506
+ justify-content:center;text-align:center}
507
+ h1{font-weight:700;font-size:88px;line-height:1.04;letter-spacing:-1px;color:%(ink)s;text-wrap:balance}
508
+ p{margin-top:26px;font-weight:500;font-size:40px;line-height:1.28;color:%(sub)s;text-wrap:balance}
509
+ .notify{position:absolute;left:62px;right:62px;top:634px;display:flex;gap:26px;align-items:flex-start;
510
+ padding:30px 34px;border-radius:40px;background:#FFFFFF;
511
+ box-shadow:0 30px 60px rgba(20,40,25,.28),0 6px 16px rgba(20,40,25,.14)}
512
+ .nicon{flex:none;width:92px;height:92px;border-radius:26px;background:%(tile)s;display:grid;place-items:center}
513
+ .nicon .mascot{width:96px;height:96px}
514
+ .ntext{min-width:0}
515
+ .nhead{font:600 28px/1.2 var(--ff);color:%(accent)s}
516
+ .nhead span{color:#7A857C;font-weight:500}
517
+ .ntitle{margin-top:6px;font:700 36px/1.2 var(--ff);color:#1F2A21;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
518
+ .nbody{margin-top:4px;font:500 31px/1.3 var(--ff);color:#4D5C50}
519
+ '''
520
+
521
+ LANDSCAPE_CSS = '''
522
+ .copy{position:absolute;left:120px;top:0;bottom:0;width:780px;display:flex;flex-direction:column;justify-content:center}
523
+ .brand{display:flex;align-items:center;gap:20px}
524
+ .brand .mascot{width:104px;height:104px;margin:-10px}
525
+ .brand b{font:600 34px/1 var(--ff);letter-spacing:4px;text-transform:uppercase;color:%(accent)s}
526
+ h1{margin-top:34px;font:700 92px/1.02 var(--ff);letter-spacing:-1.2px;color:%(ink)s;text-wrap:balance}
527
+ p{margin-top:28px;font:500 40px/1.3 var(--ff);color:%(sub)s;text-wrap:balance}
528
+ .chips{margin-top:40px}
529
+ .chips span{font-size:28px;padding:14px 24px}
530
+ '''
531
+
532
+ FEATURE_CSS = '''
533
+ .guard{position:absolute;left:34px;top:118px;width:250px;height:250px;
534
+ filter:drop-shadow(0 16px 22px rgba(31,74,42,.22))}
535
+ .guard .mascot{width:100%%;height:100%%}
536
+ .copy{position:absolute;left:286px;top:0;bottom:0;width:440px;display:flex;flex-direction:column;justify-content:center}
537
+ .name{font:600 26px/1 var(--ff);letter-spacing:3px;text-transform:uppercase;color:%(accent)s}
538
+ .copy h1{margin-top:14px;font:700 58px/1.02 var(--ff);letter-spacing:-.6px;color:%(ink)s;text-wrap:balance}
539
+ .copy p{margin-top:14px;font:500 25px/1.25 var(--ff);color:%(sub)s}
540
+ .chips{margin-top:20px;gap:9px}
541
+ .chips span{font-size:18px;padding:8px 14px}
542
+ '''
543
+
544
+ # ---- the pop layout. Colours come from pop_vals(theme); positions the app
545
+ # chooses (pills, pictures) are written on each element.
546
+
547
+ POP_CSS = '''
548
+ *{margin:0;padding:0;box-sizing:border-box}
549
+ html,body{width:%(w)dpx;height:%(h)dpx;overflow:hidden}
550
+ body{font-family:var(--ff);position:relative;background:%(ground)s}
551
+ .blob{position:absolute;border-radius:50%%}
552
+ .leaves{position:absolute;inset:0}
553
+ .dots{position:absolute;inset:0;background-image:radial-gradient(%(dots)s 2px,transparent 2.5px);
554
+ background-size:46px 46px;-webkit-mask-image:linear-gradient(#000,transparent 55%%);
555
+ mask-image:linear-gradient(#000,transparent 55%%)}
556
+ .t{color:%(ink)s;font-weight:700;letter-spacing:-.01em;text-shadow:%(title_shadow)s}
557
+ .u{position:relative;display:inline-block}
558
+ .u svg{position:absolute;left:-2%%;width:104%%;bottom:-.42em;height:.3em;overflow:visible}
559
+ .u path{fill:none;stroke:%(underline)s;stroke-width:7;stroke-linecap:round}
560
+ .u.u-colour{color:%(underline)s}
561
+ .sub{color:%(ink)s;font-weight:500;text-shadow:%(title_shadow)s}
562
+ .mascot{display:block}
563
+ .phone{position:absolute;background:%(phone)s;border-radius:var(--r);padding:var(--b);
564
+ box-shadow:0 50px 90px %(shade40)s,0 12px 26px %(shade25)s}
565
+ .screen{position:relative;border-radius:calc(var(--r) - var(--b));overflow:hidden;background:#F3F8F2}
566
+ .screen img{display:block;width:100%%}
567
+ ''' + BAR_CSS + '''.pill{position:absolute;display:flex;align-items:center;gap:.5em;background:%(pill)s;border-radius:999px;
568
+ padding-block:.42em;padding-inline:.42em .9em;font-weight:600;line-height:1.2;color:%(pill_ink)s;
569
+ white-space:nowrap;z-index:5;box-shadow:0 16px 30px %(shade30)s,0 0 0 3px %(ring)s}
570
+ .pill.go{background:%(accent)s;color:%(pill)s;padding-block:.5em;padding-inline:.6em 1.1em;
571
+ box-shadow:0 16px 30px %(shade35)s,0 0 0 4px %(pill)s}
572
+ .ck{width:1.5em;height:1.5em;border-radius:50%%;background:%(accent)s;display:grid;place-items:center;flex:none}
573
+ .go .ck{background:%(pill)s}
574
+ .ck:after{content:"";width:28%%;height:52%%;border:solid %(pill)s;border-width:0 .16em .16em 0;
575
+ transform:translateY(-12%%) rotate(45deg)}
576
+ .go .ck:after{border-color:%(accent)s}
577
+ .art{position:absolute;filter:drop-shadow(0 14px 18px %(shade35)s)}
578
+ .chips{display:flex;flex-wrap:wrap;gap:12px}
579
+ .chip{color:%(ink)s;font-weight:600;line-height:1.2;border-radius:999px;background:%(glass)s;
580
+ border:2px solid %(glass_line)s}
581
+ .copy{position:absolute;top:0;bottom:0;display:flex;flex-direction:column;justify-content:center}
582
+ .brand{display:flex;align-items:center}
583
+ .brand .mascot{filter:drop-shadow(0 8px 14px %(shade35)s)}
584
+ .name{line-height:1.2}
585
+ .copy h1{line-height:1.02}
586
+ .notify{position:absolute;z-index:6;background:%(pill)s;border-radius:34px;padding:28px 30px;display:flex;
587
+ gap:24px;align-items:center;box-shadow:0 26px 50px %(shade35)s}
588
+ .nicon{width:88px;height:88px;border-radius:24px;background:%(accent)s;display:grid;place-items:center;flex:none}
589
+ .nicon .mascot{height:60px}
590
+ .ntext{min-width:0}
591
+ .nhead{font-size:28px;line-height:1.2;color:#6B7A6E;font-weight:500}
592
+ .ntitle{margin-top:4px;font-size:38px;line-height:1.2;font-weight:700;color:%(pill_ink)s;
593
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
594
+ .nbody{margin-top:2px;font-size:34px;line-height:1.2;font-weight:500;color:#3B4A3F}
595
+ '''
596
+
597
+ POP_PORTRAIT_CSS = '''
598
+ .head{position:absolute;left:40px;right:40px;top:120px;height:380px;text-align:center}
599
+ .head h1{font-size:124px;line-height:1.02}
600
+ .head p{margin-top:24px;font-size:44px;line-height:1.2}
601
+ '''
602
+
603
+ POP_LANDSCAPE_CSS = '''
604
+ .brand{gap:18px}
605
+ .brand .mascot{height:87px}
606
+ .name{font-size:60px}
607
+ .copy h1{font-size:100px;margin-top:46px}
608
+ .copy p{margin-top:28px;font-size:40px;line-height:1.25}
609
+ .chips{margin-top:52px}
610
+ .chip{font-size:27px;padding:10px 22px}
611
+ '''
612
+
613
+ POP_FEATURE_CSS = '''
614
+ .brand{gap:10px}
615
+ .brand .mascot{height:49px}
616
+ .name{font-size:34px}
617
+ .copy h1{font-size:52px;margin-top:24px}
618
+ .copy p{margin-top:14px;font-size:24px;line-height:1.25}
619
+ .chips{margin-top:20px;gap:9px}
620
+ .chip{font-size:18px;padding:7px 14px}
621
+ '''
622
+
623
+ # The hand-drawn line under a title's [word].
624
+ SWOOSH = '<svg viewBox="0 0 200 20" preserveAspectRatio="none"><path d="M3 13 C 50 4, 120 3, 197 9"/></svg>'
625
+
626
+ # The mark on a pop title's [word], per `underline_style`: the swoosh, drawn clear of the
627
+ # descenders, or `colour`, no line and the word itself in the underline colour.
628
+ UNDERLINE_SVG = {
629
+ 'swoosh': SWOOSH,
630
+ 'colour': '',
631
+ 'none': '',
632
+ }
633
+
634
+ # Where the pop pages put their phones: (left, top, width, tilt, z) for 1, 2
635
+ # or 3 captures, and the halo (x, y, radius) and sprigs (x, y, angle, length,
636
+ # seed) of the ground. All left-to-right; right-to-left pages mirror them.
637
+ POP_HERO_PHONES = {2: [(150, 260, 390, -9, 1), (470, 150, 450, 5, 2)],
638
+ 3: [(90, 300, 360, -10, 1), (310, 150, 440, 0, 2), (580, 300, 360, 10, 1)]}
639
+ POP_FEATURE_PHONES = {1: [(700, 50, 250, 6, 2)],
640
+ 2: [(560, 90, 220, -8, 1), (740, 50, 240, 6, 2)]}
641
+ POP_GROUND = {
642
+ 'portrait': ((540, 1150, 560), [(1080, 520, 160, 300, 3), (0, 1920, -50, 360, 5)]),
643
+ 'landscape': ((560, 560, 500), [(0, 1080, -60, 330, 2), (980, 0, 110, 230, 7), (1920, 1080, 210, 280, 4)]),
644
+ 'feature': ((780, 260, 290), [(0, 500, -40, 200, 6)]),
645
+ }
646
+
647
+
648
+ def rgba(colour, alpha):
649
+ """'#RRGGBB' (or '#RGB') at an opacity."""
650
+ c = str(colour).lstrip('#')
651
+ if len(c) == 3:
652
+ c = ''.join(ch * 2 for ch in c)
653
+ try:
654
+ r, g, b = (int(c[i:i + 2], 16) for i in (0, 2, 4))
655
+ except ValueError:
656
+ raise UsageError(f'art.toml: {colour!r} is not a #RRGGBB colour', code='BAD_ART') from None
657
+ return f'rgba({r},{g},{b},{alpha:g})'
658
+
659
+
660
+ def pop_vals(theme):
661
+ """A pop theme plus the values its CSS derives from it."""
662
+ s, g = theme['shade'], theme['glow']
663
+ return dict(theme,
664
+ ground=(f"radial-gradient(130% 80% at 50% 0%,{theme['bg1']} 0%,{theme['bg2']} "
665
+ f"{theme['bg_mid']}%,{theme['bg3']} 100%)"),
666
+ ring=rgba(theme['accent'], .9), dots=rgba(g, .10), glass=rgba(g, .16), glass_line=rgba(g, .55),
667
+ **{f'shade{n}': rgba(s, n / 100) for n in (25, 30, 35, 40)})
668
+
669
+
670
+ def pop_rich(text, style='swoosh'):
671
+ """Escape a pop title: [word] gets its underline, a newline breaks the line."""
672
+ return (html.escape(text).replace('\n', '<br>')
673
+ .replace('[', '<span class="u">' if style == 'swoosh' else f'<span class="u u-{style}">')
674
+ .replace(']', UNDERLINE_SVG[style] + '</span>'))
675
+
676
+
677
+ class Pages:
678
+ """Every page for one language: (file stem, html, size, target file)."""
679
+
680
+ def __init__(self, design, code, work):
681
+ self.d = design
682
+ self.code = code
683
+ self.words = design.words(code)
684
+ self.rtl = self.words.get('dir') == 'rtl' or code.split('-')[0] in RTL and 'dir' not in self.words
685
+ self.dir = 'rtl' if self.rtl else 'ltr'
686
+ self.shots = Shots(design, code, work)
687
+
688
+ def font_stack(self):
689
+ extra = self.words.get('font') or self.d.script_fonts.get(self.code) \
690
+ or self.d.script_fonts.get(self.code.split('-')[0])
691
+ return f"'{self.d.font}', " + (f"'{extra}', " if extra else '') + 'sans-serif'
692
+
693
+ def font_faces(self):
694
+ if not self.d.font_dir:
695
+ return ''
696
+ faces = []
697
+ for weight, style in [(400, 'Regular'), (500, 'Medium'), (600, 'SemiBold'), (700, 'Bold')]:
698
+ file = self.d.font_dir / f'{self.d.font}-{style}.ttf'
699
+ if file.is_file():
700
+ faces.append(f"@font-face{{font-family:'{self.d.font}';font-weight:{weight};"
701
+ f"src:url('{file.as_uri()}')}}")
702
+ return '\n'.join(faces)
703
+
704
+ def mascot(self):
705
+ if not self.d.mascot or not self.d.mascot.is_file():
706
+ return ''
707
+ return f'<img class="mascot" src="{self.d.mascot.as_uri()}" alt="">'
708
+
709
+ def page(self, css, body, size, theme, base=BASE_CSS):
710
+ vals = dict(theme, w=size[0], h=size[1])
711
+ return (f'<!doctype html><html lang="{self.code}"><head><meta charset="utf-8"><style>\n'
712
+ f'{self.font_faces()}\n:root{{--ff:{self.font_stack()}}}\n{base % vals}\n{css % vals}\n'
713
+ f'{LANG_CSS.get(self.code) or LANG_CSS.get(self.code.split("-")[0], "")}'
714
+ f'</style></head><body>{body}</body></html>')
715
+
716
+ def phone(self, name, x, y, width, bezel=16, offset=0, rotate=0, extra=''):
717
+ path, ink = self.shots.get(name)
718
+ screen_w = width - 2 * bezel
719
+ screen_h = round(screen_w * SHOT_H / SHOT_W)
720
+ scale = screen_w / SHOT_W
721
+ bar_h = round(self.shots.bar_height * scale)
722
+ turn = f'transform:rotate({rotate}deg);' if rotate else ''
723
+ bar = '' if self.shots.clean else f'''
724
+ <div class="bar" style="height:{bar_h}px;--s:{screen_w / 808:.3f}">
725
+ {status_icons(ink)}
726
+ <div class="cam"></div>
727
+ </div>'''
728
+ return f'''
729
+ <div class="phone" style="left:{x}px;top:{y}px;width:{width}px;height:{screen_h + 2 * bezel}px;
730
+ padding:{bezel}px;border-radius:{round(width * .105)}px;{turn}{extra}">
731
+ <div class="screen" style="width:{screen_w}px;height:{screen_h}px;border-radius:{round(width * .085)}px">
732
+ <img src="{path.as_uri()}" style="width:{screen_w}px;height:{screen_h}px;margin-top:-{round(offset * scale)}px">{bar}
733
+ </div>
734
+ </div>'''
735
+
736
+ def chips(self, words):
737
+ return ''.join(f'<span>{html.escape(c)}</span>' for c in words)
738
+
739
+ def notification(self, n):
740
+ return f'''
741
+ <div class="notify" dir="{self.dir}">
742
+ <div class="nicon">{self.mascot()}</div>
743
+ <div class="ntext">
744
+ <div class="nhead">{html.escape(n['app'])} <span>&#8226; {html.escape(n['when'])}</span></div>
745
+ <div class="ntitle" data-m="notify.title">{html.escape(n['title'])}</div>
746
+ <div class="nbody" data-m="notify.body">{html.escape(n['body'])}</div>
747
+ </div>
748
+ </div>'''
749
+
750
+ def portrait(self, i, slide, words):
751
+ theme = self.d.theme['dark' if slide.get('dark') else 'light']
752
+ w, h = PORTRAIT
753
+ rnd = random.Random(i * 7 + 3)
754
+ deco = backdrop(self.d.decoration, (w, h), theme, [
755
+ (rnd.choice([-40, w + 40]), rnd.uniform(200, 700), 60, 9, i, 34),
756
+ (rnd.choice([-60, w + 60]), rnd.uniform(1300, 1800), 80, 10, i + 11, 34)])
757
+ notify = self.notification(words['notify']) if slide.get('notify') else ''
758
+ body = f'''
759
+ <svg class="deco" viewBox="0 0 {w} {h}">{deco}</svg>
760
+ <div class="head" dir="{self.dir}"><h1 data-m="title">{rich(words['title'])}</h1><p data-m="sub">{rich(words['sub'])}</p></div>
761
+ {self.phone(slide['shot'], 118, 540, 844, offset=slide.get('offset', 0))}
762
+ {notify}'''
763
+ return self.page(PORTRAIT_CSS, body, PORTRAIT, theme)
764
+
765
+ def landscape(self, slide, words):
766
+ theme = self.d.theme['light']
767
+ w, h = LANDSCAPE
768
+ x, turn = mirror(self.rtl, w)
769
+ left, mid, right = slide['shots']
770
+ deco = backdrop(self.d.decoration, (w, h), theme, [
771
+ (x(1500), 560, 120, 13, 4, 40), (x(-40), 1040, 60, 8, 8, 34)])
772
+ body = f'''
773
+ <svg class="deco" viewBox="0 0 {w} {h}">{deco}</svg>
774
+ <div class="copy" dir="{self.dir}">
775
+ <div class="brand">{self.mascot()}<b data-m="name">{html.escape(words['name'])}</b></div>
776
+ <h1 data-m="title">{rich(words['title'])}</h1>
777
+ <p data-m="sub">{rich(words['sub'])}</p>
778
+ <div class="chips" data-m="chips">{self.chips(words.get('chips', []))}</div>
779
+ </div>
780
+ {self.phone(left, x(1010, 400), 230, 400, bezel=11, rotate=turn(-7))}
781
+ {self.phone(right, x(1445, 400), 230, 400, bezel=11, rotate=turn(7))}
782
+ {self.phone(mid, x(1215, 450), 110, 450, bezel=12, extra="z-index:2")}'''
783
+ css = LANDSCAPE_CSS + ('.copy{left:auto;right:120px}' if self.rtl else '')
784
+ return self.page(css, body, LANDSCAPE, theme)
785
+
786
+ def feature(self, slide, words):
787
+ theme = self.d.theme['light']
788
+ w, h = FEATURE
789
+ x, turn = mirror(self.rtl, w)
790
+ deco = backdrop(self.d.decoration, (w, h), theme, [
791
+ (x(930), 70, 40, 10, 5, 34), (x(40), 480, 50, 8, 9, 34)])
792
+ body = f'''
793
+ <svg class="deco" viewBox="0 0 {w} {h}">{deco}</svg>
794
+ <div class="guard">{self.mascot()}</div>
795
+ <div class="copy" dir="{self.dir}">
796
+ <div class="name" data-m="name">{html.escape(words['name'])}</div>
797
+ <h1 data-m="title">{rich(words['title'])}</h1>
798
+ <p data-m="sub">{rich(words['sub'])}</p>
799
+ <div class="chips" data-m="chips">{self.chips(words.get('chips', []))}</div>
800
+ </div>
801
+ {self.phone(slide['shot'], x(740, 230), 58, 230, bezel=7, rotate=turn(5))}'''
802
+ css = FEATURE_CSS + ('.guard{left:auto;right:34px}.copy{left:auto;right:286px}' if self.rtl else '')
803
+ return self.page(css, body, FEATURE, theme)
804
+
805
+ # ------------------------------------------------ the pop layout
806
+
807
+ def pop_ground(self, kind, size, theme):
808
+ """Depth behind everything: a soft dark and a soft light blob, a halo
809
+ and ring where the phones stand, faint sprigs, and a dot texture that
810
+ fades out down the page."""
811
+ w, h = size
812
+ (hx, hy, hr), sprigs = POP_GROUND[kind]
813
+ if self.rtl:
814
+ hx = w - hx
815
+ sprigs = [(w - x, y, 180 - a, ln, seed) for x, y, a, ln, seed in sprigs]
816
+ g = theme['glow']
817
+ blob = ('<div class="blob" style="left:{:.0f}px;top:{:.0f}px;width:{:.0f}px;height:{:.0f}px;'
818
+ 'background:radial-gradient({},transparent {}%);opacity:{}"></div>')
819
+ if self.d.ornament == 'scatter':
820
+ leaves = scatter(w, h, g, self.rtl)
821
+ else:
822
+ leaves = ''.join(ornament(self.d.ornament, x, y, math.radians(a), ln, seed, g)
823
+ for x, y, a, ln, seed in sprigs)
824
+ dots = '' if self.d.ornament == 'scatter' else '<div class="dots"></div>'
825
+ return (blob.format(-w * .15, h * .55, w * .6, w * .6, theme['blob_dark'], 68, .55)
826
+ + blob.format(w * .55, -h * .35, w * .6, w * .6, theme['blob_light'], 66, .45)
827
+ + f'<div class="blob" style="left:{hx - hr}px;top:{hy - hr}px;width:{2 * hr}px;height:{2 * hr}px;'
828
+ f'background:radial-gradient({rgba(g, .32)},{rgba(g, .10)} 45%,transparent 70%)"></div>'
829
+ + f'<div class="blob" style="left:{hx - hr * .78:.0f}px;top:{hy - hr * .78:.0f}px;'
830
+ f'width:{hr * 1.56:.0f}px;height:{hr * 1.56:.0f}px;border:3px solid {rgba(g, .16)}"></div>'
831
+ + f'<svg class="leaves" viewBox="0 0 {w} {h}">{leaves}</svg>{dots}')
832
+
833
+ def pop_phone(self, name, x, y, width, rotate=0, z=1, offset=0):
834
+ path, ink = self.shots.get(name)
835
+ b, r = width * .02, width * .12
836
+ screen_w = width - 2 * b
837
+ scale = screen_w / SHOT_W
838
+ bar = '' if self.shots.clean else (
839
+ f'<div class="bar" style="height:{round(self.shots.bar_height * scale)}px;--s:{screen_w / 808:.3f}">'
840
+ f'{status_icons(ink)}<div class="cam"></div></div>')
841
+ shift = f' style="margin-top:-{offset * scale:.1f}px"' if offset else ''
842
+ return (f'<div class="phone" style="left:{x}px;top:{y}px;width:{width}px;--r:{r:.0f}px;--b:{b:.1f}px;'
843
+ f'transform:rotate({rotate}deg);z-index:{z}"><div class="screen" style="height:'
844
+ f'{screen_w * SHOT_H / SHOT_W:.1f}px"><img src="{path.as_uri()}"{shift}>{bar}</div></div>')
845
+
846
+ def pop_phones(self, names, spots, size):
847
+ x, turn = mirror(self.rtl, size[0])
848
+ if len(names) not in spots:
849
+ raise UsageError(f'art.toml: this page takes {" or ".join(map(str, spots))} shots, '
850
+ f'not {len(names)}', code='BAD_ART')
851
+ return ''.join(self.pop_phone(n, x(px, pw), py, pw, turn(rot), z)
852
+ for n, (px, py, pw, rot, z) in zip(names, spots[len(names)]))
853
+
854
+ def pop_extras(self, place, words, where, size):
855
+ """The app's own pictures ([[...art]]) and pills ([[...pill]]) on a
856
+ page, at the page pixels it gave; mirrored on right-to-left pages."""
857
+ _, turn = mirror(self.rtl, size[0])
858
+ side = 'right' if self.rtl else 'left'
859
+ out = []
860
+ for a in place.get('art') or []:
861
+ out.append(f'<img class="art" src="{self.d.asset(a["src"]).as_uri()}" alt="" style="{side}:{a["x"]}px;'
862
+ f'top:{a["y"]}px;height:{a["height"]}px;transform:rotate({turn(a.get("rotate", 0))}deg);'
863
+ f'z-index:{a.get("z", 4)}">')
864
+ for i, p in enumerate(place.get('pill') or []):
865
+ if 'text' in p:
866
+ text, mark = str(p['text']), f'pill.{i + 1}'
867
+ elif 'key' in p:
868
+ try:
869
+ text = words['pills'][p['key']]
870
+ except (KeyError, TypeError):
871
+ raise UsageError(f'captions/{self.code}.json has no {where}.pills.{p["key"]}',
872
+ code='BAD_CAPTIONS') from None
873
+ mark = f'pill.{p["key"]}'
874
+ else:
875
+ raise UsageError(f'art.toml: a pill on {where} needs key or text', code='BAD_ART')
876
+ out.append(f'<div class="pill{" go" if p.get("go") else ""}" dir="{self.dir}" data-m="{html.escape(mark)}" '
877
+ f'style="{side}:{p["x"]}px;top:{p["y"]}px;font-size:{p.get("size", 44)}px;'
878
+ f'transform:rotate({turn(p.get("rotate", 0))}deg)"><span class="ck"></span>'
879
+ f'<span class="pt">{html.escape(text)}</span></div>')
880
+ return '\n'.join(out)
881
+
882
+ def pop_copy(self, words, left, width, size):
883
+ """Brand (mascot and name), title, an optional line and chips."""
884
+ x, _ = mirror(self.rtl, size[0])
885
+ mark = self.d.underline_style
886
+ sub = f'<p class="sub" data-m="sub">{pop_rich(words["sub"], mark)}</p>' if words.get('sub') else ''
887
+ chips = ''.join(f'<span class="chip">{html.escape(c)}</span>' for c in words.get('chips') or [])
888
+ chips = f'<div class="chips" data-m="chips">{chips}</div>' if chips else ''
889
+ return (f'<div class="copy" dir="{self.dir}" style="left:{x(left, width)}px;width:{width}px">'
890
+ f'<div class="brand">{self.mascot()}<span class="t name" data-m="name">'
891
+ f'{html.escape(words["name"])}</span></div>'
892
+ f'<h1 class="t" data-m="title">{pop_rich(words["title"], mark)}</h1>{sub}{chips}</div>')
893
+
894
+ def pop_notification(self, n, y, width):
895
+ _, turn = mirror(self.rtl, width)
896
+ return f'''
897
+ <div class="notify" dir="{self.dir}" style="left:70px;top:{y}px;width:{width - 140}px;transform:rotate({turn(-2)}deg)">
898
+ <div class="nicon">{self.mascot()}</div>
899
+ <div class="ntext">
900
+ <div class="nhead">{html.escape(n['app'])} &nbsp;&#183;&nbsp; {html.escape(n['when'])}</div>
901
+ <div class="ntitle" data-m="notify.title">{html.escape(n['title'])}</div>
902
+ <div class="nbody" data-m="notify.body">{html.escape(n['body'])}</div>
903
+ </div>
904
+ </div>'''
905
+
906
+ def pop_portrait(self, slide, words):
907
+ theme = self.d.theme['dark' if slide.get('dark') else 'light']
908
+ w = PORTRAIT[0]
909
+ x, _ = mirror(self.rtl, w)
910
+ mark = self.d.underline_style
911
+ sub = f'<p class="sub" data-m="sub">{pop_rich(words["sub"], mark)}</p>' if words.get('sub') else ''
912
+ notify = self.pop_notification(words['notify'], slide.get('notify_y', 600), w) if slide.get('notify') else ''
913
+ body = f'''{self.pop_ground('portrait', PORTRAIT, theme)}
914
+ <div class="head" dir="{self.dir}"><h1 class="t" data-m="title" data-max="2">{pop_rich(words['title'], mark)}</h1>{sub}</div>
915
+ {self.pop_phone(slide['shot'], x(160, 760), 520, 760, offset=slide.get('offset', 0))}
916
+ {self.pop_extras(slide, words, slide['id'], PORTRAIT)}
917
+ {notify}'''
918
+ return self.page(POP_PORTRAIT_CSS, body, PORTRAIT, pop_vals(theme), base=POP_CSS)
919
+
920
+ def pop_landscape(self, slide, words):
921
+ theme = self.d.theme['dark' if slide.get('dark') else 'light']
922
+ body = f'''{self.pop_ground('landscape', LANDSCAPE, theme)}
923
+ {self.pop_phones(slide['shots'], POP_HERO_PHONES, LANDSCAPE)}
924
+ {self.pop_extras(slide, words, slide['id'], LANDSCAPE)}
925
+ {self.pop_copy(words, 1030, 780, LANDSCAPE)}'''
926
+ return self.page(POP_LANDSCAPE_CSS, body, LANDSCAPE, pop_vals(theme), base=POP_CSS)
927
+
928
+ def pop_feature(self, feature, words):
929
+ theme = self.d.theme['light']
930
+ shots = feature.get('shots') or [feature['shot']]
931
+ body = f'''{self.pop_ground('feature', FEATURE, theme)}
932
+ {self.pop_phones(shots, POP_FEATURE_PHONES, FEATURE)}
933
+ {self.pop_extras(feature, words, 'feature', FEATURE)}
934
+ {self.pop_copy(words, 60, 470, FEATURE)}'''
935
+ return self.page(POP_FEATURE_CSS, body, FEATURE, pop_vals(theme), base=POP_CSS)
936
+
937
+ def all(self):
938
+ jobs = []
939
+ shots_dir = self.d.screenshot_dir(self.code)
940
+ pop = self.d.layout == 'pop'
941
+ for i, slide in enumerate(self.d.slides):
942
+ try:
943
+ text = self.words[slide['id']]
944
+ except KeyError:
945
+ raise UsageError(f'captions/{self.code}.json has no "{slide["id"]}"', code='BAD_CAPTIONS')
946
+ stem = f'{i + 1:02d}_{slide["id"]}'
947
+ if slide.get('kind') == 'landscape':
948
+ doc = self.pop_landscape(slide, text) if pop else self.landscape(slide, text)
949
+ size = LANDSCAPE
950
+ else:
951
+ doc = self.pop_portrait(slide, text) if pop else self.portrait(i, slide, text)
952
+ size = PORTRAIT
953
+ jobs.append((stem, doc, size, shots_dir / f'{stem}.png'))
954
+ feature = self.d.data.get('feature') or {'shot': self.d.slides[0].get('shot', 'home')}
955
+ draw = self.pop_feature if pop else self.feature
956
+ jobs.append(('feature', draw(feature, self.words['feature']), FEATURE,
957
+ self.d.feature_file(self.code)))
958
+ return jobs
959
+
960
+
961
+ # ---------------------------------------------------------------- the browser
962
+
963
+ def _playwright(pattern):
964
+ """Browsers Playwright downloaded, newest first."""
965
+ root = Path(os.path.expandvars(r'%LOCALAPPDATA%\ms-playwright'))
966
+ return [str(p) for p in sorted(root.glob(pattern), reverse=True)] if root.is_dir() else []
967
+
968
+
969
+ def _answers(exe):
970
+ """Whether a browser draws a page and hands back its DOM before it exits.
971
+ Some launchers return at once and leave the work to a process they start
972
+ (Edge does while an update waits for a restart); those cannot be used."""
973
+ profile = tempfile.mkdtemp(prefix='mkit-art-browser-')
974
+ try:
975
+ dom = subprocess.run(
976
+ [str(exe), '--headless=new', '--disable-gpu', f'--user-data-dir={profile}',
977
+ '--dump-dom', 'data:text/html,<p>@@ready@@</p>'],
978
+ capture_output=True, timeout=60, text=True, encoding='utf-8', errors='replace').stdout
979
+ return '@@ready@@' in dom
980
+ except (OSError, subprocess.SubprocessError):
981
+ return False
982
+ finally:
983
+ shutil.rmtree(profile, ignore_errors=True)
984
+
985
+
986
+ def browser(project):
987
+ """A Chromium-based browser to draw pages: [art] browser, else the first of
988
+ Edge, Chrome or a Playwright Chromium that answers."""
989
+ named = project.get('art.browser')
990
+ if named:
991
+ if Path(named).is_file():
992
+ return Path(named)
993
+ raise NotFound(f'[art] browser not found: {named}', code='NO_BROWSER')
994
+ exe = '.exe' if os.name == 'nt' else ''
995
+ candidates = [
996
+ r'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe',
997
+ r'C:\Program Files\Microsoft\Edge\Application\msedge.exe',
998
+ r'C:\Program Files\Google\Chrome\Application\chrome.exe',
999
+ os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
1000
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
1001
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
1002
+ *(shutil.which(n) or '' for n in ('msedge', 'microsoft-edge', 'google-chrome', 'chromium',
1003
+ 'chromium-browser', 'chrome')),
1004
+ *_playwright('chromium_headless_shell-*/*/chrome-headless-shell' + exe),
1005
+ *_playwright('chromium-*/*/chrome' + exe),
1006
+ ]
1007
+ found = list(dict.fromkeys(c for c in candidates if c and Path(c).is_file()))
1008
+ for c in found:
1009
+ if _answers(c):
1010
+ return Path(c)
1011
+ if found:
1012
+ raise NotFound('no browser answered: ' + ', '.join(found),
1013
+ hint='Restart Edge if it has an update waiting, install Chrome, run '
1014
+ '`uvx playwright install chromium-headless-shell`, or set '
1015
+ 'browser = "<path>" under [art].', code='NO_BROWSER')
1016
+ raise NotFound('no Chromium browser found to draw the art (Edge or Chrome)',
1017
+ hint='Install Edge or Chrome, or set browser = "<path>" under [art].',
1018
+ code='NO_BROWSER')
1019
+
1020
+
1021
+ # Added to a page for check: once the fonts are in, write each marked block's
1022
+ # line count (chips: rows) and whether it runs past its box (a pill: past the
1023
+ # page's edge), plus its line cap (data-max) when it has one.
1024
+ MEASURE_JS = '''<script>
1025
+ document.fonts.ready.then(() => {
1026
+ const r = {};
1027
+ const W = document.body.offsetWidth, H = document.body.offsetHeight;
1028
+ const count = e => Math.round(e.getBoundingClientRect().height / parseFloat(getComputedStyle(e).lineHeight));
1029
+ document.querySelectorAll('[data-m]').forEach(e => {
1030
+ let lines, over = e.scrollWidth > e.clientWidth + 1;
1031
+ if (e.classList.contains('chips')) {
1032
+ lines = new Set([...e.children].map(c => Math.round(c.getBoundingClientRect().top))).size;
1033
+ } else if (e.classList.contains('pill')) {
1034
+ const t = e.querySelector('.pt'); // its layout height: a tilt would inflate the box
1035
+ lines = Math.round(t.offsetHeight / parseFloat(getComputedStyle(t).lineHeight));
1036
+ const b = e.getBoundingClientRect();
1037
+ over = b.left < -1 || b.top < -1 || b.right > W + 1 || b.bottom > H + 1;
1038
+ } else {
1039
+ lines = count(e);
1040
+ }
1041
+ r[e.dataset.m] = {lines, over};
1042
+ if (e.dataset.max) r[e.dataset.m].max = +e.dataset.max;
1043
+ });
1044
+ const head = document.querySelector('.head');
1045
+ if (head) r['head'] = {lines: 0, over: head.scrollHeight > head.clientHeight + 1};
1046
+ const pre = document.createElement('pre');
1047
+ pre.textContent = '@@' + JSON.stringify(r) + '@@';
1048
+ document.body.appendChild(pre);
1049
+ });
1050
+ </script>'''
1051
+
1052
+
1053
+ class Renderer:
1054
+ def __init__(self, exe):
1055
+ self.exe = exe
1056
+
1057
+ def _run(self, args):
1058
+ profile = tempfile.mkdtemp(prefix='mkit-art-browser-')
1059
+ try:
1060
+ return subprocess.run(
1061
+ [str(self.exe), '--headless=new', '--disable-gpu', '--hide-scrollbars',
1062
+ '--force-device-scale-factor=1', '--allow-file-access-from-files',
1063
+ '--virtual-time-budget=3000', f'--user-data-dir={profile}', *args],
1064
+ check=True, capture_output=True, timeout=180, text=True, encoding='utf-8', errors='replace')
1065
+ finally:
1066
+ shutil.rmtree(profile, ignore_errors=True)
1067
+
1068
+ def png(self, page, target, size):
1069
+ w, h = size
1070
+ target.parent.mkdir(parents=True, exist_ok=True)
1071
+ tmp = target.with_suffix('.raw.png')
1072
+ self._run([f'--window-size={w},{h}', f'--screenshot={tmp}', page.as_uri()])
1073
+ for _ in range(100): # a launcher can return just before the file lands
1074
+ if tmp.is_file():
1075
+ break
1076
+ time.sleep(0.1)
1077
+ im = Image.open(tmp).convert('RGB')
1078
+ if im.size != (w, h):
1079
+ im = im.crop((0, 0, w, h))
1080
+ im.save(target, optimize=True)
1081
+ tmp.unlink()
1082
+
1083
+ def measure(self, page, size):
1084
+ w, h = size
1085
+ dom = self._run([f'--window-size={w},{h}', '--dump-dom', page.as_uri()]).stdout
1086
+ parts = dom.rsplit('@@', 2)
1087
+ if len(parts) < 3:
1088
+ raise UsageError(f'{page.name}: the page did not report its layout', code='MEASURE_FAILED')
1089
+ return json.loads(parts[1])
1090
+
1091
+
1092
+ # ---------------------------------------------------------------- check and build
1093
+
1094
+ def compare(got, ref):
1095
+ """Where a language's line counts differ from the reference's, or pass a
1096
+ block's line cap. With an empty reference, only boxes and caps are checked
1097
+ (how the reference itself is checked)."""
1098
+ problems = []
1099
+ for stem, blocks in got.items():
1100
+ for key, m in blocks.items():
1101
+ want = ref.get(stem, {}).get(key, {}).get('lines')
1102
+ # A notification title ends in "..." when too long, as on a phone.
1103
+ if m['over'] and key != 'notify.title':
1104
+ problems.append(f'{stem} {key}: runs past its box')
1105
+ if m.get('max') and m['lines'] > m['max']:
1106
+ problems.append(f'{stem} {key}: {m["lines"]} lines, at most {m["max"]}')
1107
+ elif key != 'head' and want is not None and m['lines'] != want:
1108
+ problems.append(f'{stem} {key}: {m["lines"]} lines, reference has {want}')
1109
+ return problems
1110
+
1111
+
1112
+ def _measure_all(design, code, work, renderer, pool):
1113
+ runs = {}
1114
+ for stem, doc, size, _ in Pages(design, code, work).all():
1115
+ page = work / code / f'{stem}.check.html'
1116
+ page.parent.mkdir(parents=True, exist_ok=True)
1117
+ page.write_text(doc.replace('</body>', MEASURE_JS + '</body>'), encoding='utf-8')
1118
+ runs[stem] = pool.submit(renderer.measure, page, size)
1119
+ return {stem: r.result() for stem, r in runs.items()}
1120
+
1121
+
1122
+ def check(design, codes, workers=4, on_result=None):
1123
+ """{code: [problem, ...]} for each language; the reference is measured too."""
1124
+ renderer = Renderer(browser(design.project))
1125
+ work = Path(tempfile.mkdtemp(prefix='mkit-art-'))
1126
+ results = {}
1127
+ try:
1128
+ ref_words = design.words(design.reference)
1129
+ with ThreadPoolExecutor(max_workers=workers) as pool:
1130
+ ref = _measure_all(design, design.reference, work, renderer, pool)
1131
+ results['_reference'] = ref
1132
+ for code in codes:
1133
+ if code == design.reference:
1134
+ # The reference is measured against its own boxes and line caps.
1135
+ problems = lint(ref_words, ref_words, design.slides) or compare(ref, {})
1136
+ results[code] = problems
1137
+ if on_result:
1138
+ on_result(code, problems)
1139
+ continue
1140
+ words = design.words(code)
1141
+ problems = lint(words, ref_words, design.slides)
1142
+ if not problems:
1143
+ problems = compare(_measure_all(design, code, work, renderer, pool), ref)
1144
+ problems = [p.replace('reference has', f'{design.reference} has') for p in problems]
1145
+ results[code] = problems
1146
+ if on_result:
1147
+ on_result(code, problems)
1148
+ finally:
1149
+ shutil.rmtree(work, ignore_errors=True)
1150
+ return results
1151
+
1152
+
1153
+ def build(design, codes, workers=4, keep_pages=False, on_result=None):
1154
+ """Draw every image for each language; returns {code: [files]} and the
1155
+ folder the pages were kept in (or None)."""
1156
+ renderer = Renderer(browser(design.project))
1157
+ work = Path(tempfile.mkdtemp(prefix='mkit-art-'))
1158
+ written = {}
1159
+ try:
1160
+ with ThreadPoolExecutor(max_workers=workers) as pool:
1161
+ for code in codes:
1162
+ shots_dir = design.screenshot_dir(code)
1163
+ shots_dir.mkdir(parents=True, exist_ok=True)
1164
+ for old in shots_dir.glob('*.png'):
1165
+ old.unlink()
1166
+ runs = []
1167
+ for stem, doc, size, target in Pages(design, code, work).all():
1168
+ page = work / code / f'{stem}.html'
1169
+ page.parent.mkdir(parents=True, exist_ok=True)
1170
+ page.write_text(doc, encoding='utf-8')
1171
+ runs.append((target, pool.submit(renderer.png, page, target, size)))
1172
+ for target, r in runs:
1173
+ r.result()
1174
+ written[code] = [str(t) for t, _ in runs]
1175
+ if on_result:
1176
+ on_result(code, written[code])
1177
+ finally:
1178
+ if not keep_pages:
1179
+ shutil.rmtree(work, ignore_errors=True)
1180
+ return written, (work if keep_pages else None)
1181
+
1182
+
1183
+ # ---------------------------------------------------------------- setting an app up
1184
+
1185
+ GITIGNORE_NOTE = '# Store art built by `mkit art build` (sources: {src})'
1186
+
1187
+
1188
+ def init(project):
1189
+ """Create the art folder from the starter files, switch [art] on in
1190
+ mkit.toml and keep the built images out of git. Touches only what is
1191
+ missing; returns what it did."""
1192
+ done = []
1193
+ folder = project.path('art.dir', 'store/art')
1194
+ (folder / 'shots').mkdir(parents=True, exist_ok=True)
1195
+ (folder / 'captions').mkdir(parents=True, exist_ok=True)
1196
+ for src, dst in [(TEMPLATES / 'art.toml', folder / 'art.toml'),
1197
+ (TEMPLATES / 'en.json', folder / 'captions' / 'en.json')]:
1198
+ if not dst.exists():
1199
+ shutil.copyfile(src, dst)
1200
+ done.append(f'wrote {dst.relative_to(project.root).as_posix()}')
1201
+ toml = project.root / 'mkit.toml'
1202
+ if not project.has('art'):
1203
+ text = toml.read_text(encoding='utf-8')
1204
+ text += ('' if text.endswith('\n') else '\n') + '\n[art] # store art: mkit art build\n'
1205
+ toml.write_text(text, encoding='utf-8')
1206
+ done.append('added [art] to mkit.toml')
1207
+ out = project.path('art.out') or project.path('store.art_dir', 'store')
1208
+ rel = out.relative_to(project.root).as_posix()
1209
+ rel = '' if rel == '.' else rel + '/'
1210
+ lines = [f'/{rel}screenshots/', f'/{rel}feature/']
1211
+ gi = project.root / '.gitignore'
1212
+ have = gi.read_text(encoding='utf-8').splitlines() if gi.is_file() else []
1213
+ missing = [line for line in lines if line not in have]
1214
+ if missing:
1215
+ note = GITIGNORE_NOTE.format(src=folder.relative_to(project.root).as_posix())
1216
+ block = '\n'.join([note, *missing]) + '\n'
1217
+ text = '\n'.join(have)
1218
+ gi.write_text((text + '\n\n' if text else '') + block, encoding='utf-8')
1219
+ done.append(f'.gitignore: {", ".join(missing)}')
1220
+ return done