Files
workbuddy_skills/oil-ui-pro/scripts/build_cards.py
T
admin d9445b5888 oil-ui-pro 0.15.4 → 0.22.2(上游 ui.oiloil.org 正式包)+ 记 P0-98 跨盘 rename 坑
- oil-ui-pro 升到 0.22.2:references 13→28 份(新增 16、删 imagery.md)、assets 增 cards/(22 组)与 fonts/、SKILL.md 顶层五步结构未改名。
- 升级路径:官方 `npx github:oil-oil/oil-cli update` 稳定失败(error=replace、报「检查目录权限」)。
  真身为 EXDEV —— 解压在 os.tmpdir()(C 盘)、技能目录在 E 盘,Windows 上 rename 不能跨盘;
  CLI 的 catch 把非 CliError 原生异常一律兜底成 replaceFailed,真错误一个字不吐。
  做法=借其 prepare() 只做下载+校验,再 cp 到同盘 stage、rename 交换;官方命令复验 up_to_date。
- 回归:product-planning 按名引用的 visual-review.md/tools.md/design-direction.md/style-explorer.md 全部健在;命名闸门 2061|0|27。
- session-mechanism/references/pitfalls.md 新增 P0-98(含通则:更新器把原生错误兜底成指错方向的人话)。
2026-10-14 10:21:46 +08:00

117 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""Build an offline gallery, including a 360x180 Natural Earth land bitmap.
Python 3.10+, standard library only. No network and no JS dependencies.
"""
from __future__ import annotations
import argparse
import base64
import math
from pathlib import Path
import re
import struct
import sys
import zipfile
ROOT = Path(__file__).resolve().parents[1]
CARDS = ROOT / 'assets/cards'
NAMES = ['particle-ring', 'flow-object', 'dot-globe', 'line-ribbons', 'showcase',
'ui-slice', 'terminal', 'metric', 'beam-graph', 'keycaps', 'promo-band',
'vector-space', 'file-mosaic', 'stream-window', 'portrait-story', 'capture-notes',
'answer-lens', 'responsive-preview', 'deploy-preview']
def land_mask() -> None:
with zipfile.ZipFile(CARDS / 'shared/ne_110m_land.zip') as archive:
data = archive.read('ne_110m_land.shp')
polygons = []
offset = 100
while offset < len(data):
length = struct.unpack_from('>i', data, offset + 4)[0] * 2
record = data[offset + 8:offset + 8 + length]
offset += 8 + length
if struct.unpack_from('<i', record)[0] != 5:
continue
parts, count = struct.unpack_from('<ii', record, 36)
starts = list(struct.unpack_from(f'<{parts}i', record, 44)) + [count]
coords = list(struct.iter_unpack('<dd', record[44 + parts * 4:]))
polygons.extend(coords[starts[i]:starts[i + 1]] for i in range(parts))
width, height = 360, 180
bits = bytearray(math.ceil(width * height / 8))
# Scanline even/odd fill within each ring. Land data contains exterior rings.
for y in range(height):
latitude = 90 - (y + .5) * 180 / height
for ring in polygons:
hits = []
for (x1, y1), (x2, y2) in zip(ring, ring[1:] + ring[:1]):
if (y1 > latitude) != (y2 > latitude):
hits.append(x1 + (latitude - y1) * (x2 - x1) / (y2 - y1))
hits.sort()
for lo, hi in zip(hits[::2], hits[1::2]):
start = max(0, math.ceil((lo + 180) / 360 * width - .5))
end = min(width, math.ceil((hi + 180) / 360 * width - .5))
for x in range(start, end):
i = y * width + x
bits[i // 8] |= 1 << (i % 8)
encoded = base64.b64encode(bits).decode()
(CARDS / 'shared/land-mask.js').write_text(
'// Generated from Natural Earth 110m land (public domain).\n'
f'export const LAND_WIDTH = {width};\nexport const LAND_HEIGHT = {height};\n'
f'export const LAND_BITS = "{encoded}";\n', encoding='utf-8', newline='\n')
def bundle(path: Path) -> str:
source = path.read_text(encoding='utf-8')
exports = re.findall(r'export\s+(?:const|let|function|class)\s+(\w+)', source)
def replace_import(match: re.Match) -> str:
target = (path.parent / match[2]).resolve().relative_to(CARDS).as_posix()
return f'const {{{match[1]}}} = modules["{target}"];'
source = re.sub(r'import\s*\{([^}]+)\}\s*from\s*[\'"]([^\'"]+)[\'"];?', replace_import, source)
source = re.sub(r'\bexport\s+(?=const|let|function|class)', '', source)
if re.search(r'^\s*(import|export)\b', source, re.M):
raise ValueError(f'Unsupported module syntax: {path}')
key = path.relative_to(CARDS).as_posix()
return f'modules["{key}"] = (() => {{\n{source}\nreturn {{{", ".join(exports)}}};\n}})();'
def utf8_output() -> None:
"""管道另一头按 UTF-8 读;控制台的代码页可能连中文都没有。"""
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, 'reconfigure') and not stream.isatty():
stream.reconfigure(encoding='utf-8')
def main(argv: list[str] | None = None) -> None:
utf8_output()
parser = argparse.ArgumentParser(
description='维护卡片库时用:把 assets/cards/ 下各个卡片原型重新打包成 assets/cards/gallery.html。',
epilog='不带参数运行。它会直接改写 Skill 目录里的 gallery.html,所以只在改过卡片原型的源文件之后运行;'
'做设计时不需要它,直接打开 assets/cards/gallery.html 或读各原型目录里的 card.html、card.css。')
parser.parse_args(argv)
land_mask()
html = (CARDS / 'gallery.template.html').read_text(encoding='utf-8')
for name in NAMES:
html = html.replace('{{' + name + '}}', (CARDS / name / 'card.html').read_text(encoding='utf-8'))
css = '\n'.join((CARDS / p).read_text(encoding='utf-8') for p in
['shared/shell.css', 'gallery.css'] + [f'{n}/card.css' for n in NAMES] + ['shared/finish.css'])
paths = [CARDS / 'gallery-palette.js', CARDS / 'gallery-catalog.js', CARDS / 'shared/content.js', CARDS / 'shared/runtime.js', CARDS / 'shared/land-mask.js', CARDS / 'line-ribbons/surface.js', CARDS / 'flow-object/material.js'] + [CARDS / n / 'card.js' for n in NAMES]
js = '(() => {\nconst modules = {};\n' + '\n'.join(bundle(p) for p in paths)
js += '\n' + (CARDS / 'gallery-content.js').read_text(encoding='utf-8') + '\n' + (CARDS / 'gallery.js').read_text(encoding='utf-8') + '\n})();'
html = html.replace('{{styles}}', css).replace('{{scripts}}', js.replace('</script', '<\\/script'))
# Local image slots are embedded at build time, preserving file:// portability.
def embed_image(match: re.Match) -> str:
path = (CARDS / match[1]).resolve()
path.relative_to(CARDS)
mime = {'.jpg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp'}[path.suffix]
return 'src="data:' + mime + ';base64,' + base64.b64encode(path.read_bytes()).decode() + '"'
html = re.sub(r'src="((?:showcase|portrait-story)/images/[^"<>]+)"', embed_image, html)
if re.search(r'\{\{[\w-]+\}\}', html):
raise ValueError('Unresolved template slot')
(CARDS / 'gallery.html').write_text(html, encoding='utf-8', newline='\n')
print(f'Built assets/cards/gallery.html · {len(html.encode()):,} bytes · {len(NAMES)} templates')
if __name__ == '__main__':
main()