"""倒计时前缀文字位图(证据 analysis/web/timedjs2.js:180-236)。 与 JS 差异:JS 帧有 off-by-one(丢末字节),本实现发完整位图(设备缓冲兼容),见 DECISIONS.md。 """ from __future__ import annotations from PIL import Image, ImageDraw from .templates import _font GRID_CLOCK = 16 # 时钟界面字格 16px,≤8 单位(中文≤4+英文),>4 拆两包 GRID_CALENDAR = 24 # 日历界面字格 24px,≤4 单位,每字一包 def char_units(text: str) -> float: """中文=1 单位,其他=0.5(timedjs2.js charCount 语义)。""" return sum(1.0 if ord(ch) > 0x2E7F else 0.5 for ch in text) def render_text_bitmap(text: str, grid: int) -> bytes: """单行文本 → 1bpp MSB-first 行主序位图。宽度=units*grid,高度=grid。""" if grid not in (GRID_CLOCK, GRID_CALENDAR): raise ValueError("grid 必须是 16 或 24") units = char_units(text) if units != int(units): raise ValueError("中英混排产生 0.5 单位,JS 端会生成非法帧,CLI 拒绝") w, h = int(units) * grid, grid if w == 0: raise ValueError("空文本") img = Image.new("L", (w, h), 0) d = ImageDraw.Draw(img) font = _font(int(grid * 0.875)) # 16px格→14px 字 d.text((w // 2, h // 2), text, font=font, fill=255, anchor="mm") px = img.load() out = bytearray() for y in range(h): for xb in range(w // 8): byte = 0 for bit in range(8): if px[xb * 8 + bit, y] > 128: byte |= 1 << (7 - bit) out.append(byte) return bytes(out) def prefix_frames(text: str, mode: str) -> list[bytes]: """前缀文字 → 帧列表。 clock: ≤4 单位单包 0{n}+位图;>4 两包 04+前128B / 0{n}+余量。 calendar: 每字一包 990{j}+72B。""" units = char_units(text) if units != int(units): raise ValueError("中英混排产生 0.5 单位,不支持") n = int(units) if mode == "clock": if n > 8: raise ValueError("时钟界面最多 8 单位(纯中文 4 字)") bmp = render_text_bitmap(text, GRID_CLOCK) if n <= 4: return [bytes([n]) + bmp] return [bytes([0x04]) + bmp[:128], bytes([n]) + bmp[128:]] if mode == "calendar": if n > 4: raise ValueError("日历界面最多 4 字") bmp = render_text_bitmap(text, GRID_CALENDAR) return [bytes([0x99, j]) + bmp[j * 72:(j + 1) * 72] for j in range(n)] raise ValueError("mode 必须是 clock 或 calendar")