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/__init__.py +5 -0
- mkit/art.py +1220 -0
- mkit/aso.py +359 -0
- mkit/cli.py +176 -0
- mkit/cloud/__init__.py +1 -0
- mkit/cloud/auth.py +130 -0
- mkit/cloud/fbproject.py +180 -0
- mkit/cloud/firebase.py +164 -0
- mkit/cloud/insight.py +286 -0
- mkit/cloud/money.py +676 -0
- mkit/cloud/play.py +497 -0
- mkit/cloud/store.py +390 -0
- mkit/commands/__init__.py +2 -0
- mkit/commands/art.py +116 -0
- mkit/commands/aso.py +130 -0
- mkit/commands/dev.py +256 -0
- mkit/commands/doctor.py +191 -0
- mkit/commands/firebase_cmds.py +194 -0
- mkit/commands/init.py +213 -0
- mkit/commands/insight.py +110 -0
- mkit/commands/meta.py +84 -0
- mkit/commands/money.py +152 -0
- mkit/commands/release.py +430 -0
- mkit/commands/selfcmd.py +167 -0
- mkit/commands/store.py +263 -0
- mkit/config.py +146 -0
- mkit/data/art/art.toml +119 -0
- mkit/data/art/en.json +38 -0
- mkit/data/datasafety-questions.csv +783 -0
- mkit/datasafety.py +302 -0
- mkit/device.py +246 -0
- mkit/docgen.py +100 -0
- mkit/errors.py +78 -0
- mkit/git.py +95 -0
- mkit/hooks.py +32 -0
- mkit/kinds.py +350 -0
- mkit/notify.py +49 -0
- mkit/output.py +377 -0
- mkit/proc.py +154 -0
- mkit/registry.py +99 -0
- mkit/safety.py +60 -0
- mkit/secrets.py +148 -0
- mkit/toolchain.py +210 -0
- mkit_cli-0.3.1.dist-info/METADATA +179 -0
- mkit_cli-0.3.1.dist-info/RECORD +48 -0
- mkit_cli-0.3.1.dist-info/WHEEL +4 -0
- mkit_cli-0.3.1.dist-info/entry_points.txt +2 -0
- mkit_cli-0.3.1.dist-info/licenses/LICENSE +21 -0
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>• {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'])} · {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
|