docs(algo): 5.3 节补窗首下标的圆周图(三种写法对照)

- 新增配图 采样环缓冲_窗首下标.svg:左侧 40 格圆环走一遍 head = 5、n = 20
  的「往回退 19 步」(跨过 0 与 39),右侧把直接减 / 16 位回绕后再取模 /
  先加 DEPTH 三种写法的结果并列,只有第三种落在 25。
- 与 5.2 的环形布局图分工:那张讲「窗在哪几格」,这张讲「下标怎么算出来」,
  不重复同一张图的语义。
- 生成器 gen_sample_ring_start_svg.py 顺带把 OVERFLOW 明细改到末尾统一打印
  (原先只在开头打,折行类告警只报计数、看不出是哪一条)。
- 门禁:doc-links --sections-strict 通过;id-check 新增行无未登记编号;
  generated-fresh 22 个生成器 17 一致、0 新漂移;产物连跑两次 sha1 不变。
This commit is contained in:
2026-10-29 17:28:48 +08:00
parent 929cacbd72
commit e71d623e60
4 changed files with 396 additions and 0 deletions
@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1060" height="600" viewBox="0 0 1060 600" font-family="Microsoft YaHei, Segoe UI, PingFang SC, sans-serif">
<rect x="0" y="0" width="1060" height="600" fill="#ffffff"/>
<text x="46.0" y="42.0" font-size="21" font-weight="700" fill="#0f172a">窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减</text>
<text x="46.0" y="68.0" font-size="13" fill="#64748b">例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。</text>
<text x="46.0" y="88.0" font-size="13" fill="#64748b">同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」:</text>
<circle cx="296.0" cy="352.0" r="148" fill="none" stroke="#cbd5e1" stroke-width="1.2"/>
<circle cx="296.0" cy="204.0" r="6" fill="#2563eb"/>
<circle cx="319.2" cy="205.8" r="6" fill="#2563eb"/>
<circle cx="341.7" cy="211.2" r="6" fill="#2563eb"/>
<circle cx="363.2" cy="220.1" r="6" fill="#2563eb"/>
<circle cx="383.0" cy="232.3" r="6" fill="#2563eb"/>
<circle cx="400.7" cy="247.3" r="6" fill="#d97706"/>
<circle cx="415.7" cy="265.0" r="5" fill="#94a3b8"/>
<circle cx="427.9" cy="284.8" r="5" fill="#94a3b8"/>
<circle cx="436.8" cy="306.3" r="5" fill="#94a3b8"/>
<circle cx="442.2" cy="328.8" r="5" fill="#94a3b8"/>
<circle cx="444.0" cy="352.0" r="5" fill="#94a3b8"/>
<circle cx="442.2" cy="375.2" r="5" fill="#94a3b8"/>
<circle cx="436.8" cy="397.7" r="5" fill="#94a3b8"/>
<circle cx="427.9" cy="419.2" r="5" fill="#94a3b8"/>
<circle cx="415.7" cy="439.0" r="5" fill="#94a3b8"/>
<circle cx="400.7" cy="456.7" r="5" fill="#94a3b8"/>
<circle cx="383.0" cy="471.7" r="5" fill="#94a3b8"/>
<circle cx="363.2" cy="483.9" r="5" fill="#94a3b8"/>
<circle cx="341.7" cy="492.8" r="5" fill="#94a3b8"/>
<circle cx="319.2" cy="498.2" r="5" fill="#94a3b8"/>
<circle cx="296.0" cy="500.0" r="5" fill="#94a3b8"/>
<circle cx="272.8" cy="498.2" r="5" fill="#94a3b8"/>
<circle cx="250.3" cy="492.8" r="5" fill="#94a3b8"/>
<circle cx="228.8" cy="483.9" r="5" fill="#94a3b8"/>
<circle cx="209.0" cy="471.7" r="5" fill="#94a3b8"/>
<circle cx="191.3" cy="456.7" r="6" fill="#2563eb"/>
<circle cx="176.3" cy="439.0" r="6" fill="#2563eb"/>
<circle cx="164.1" cy="419.2" r="6" fill="#2563eb"/>
<circle cx="155.2" cy="397.7" r="6" fill="#2563eb"/>
<circle cx="149.8" cy="375.2" r="6" fill="#2563eb"/>
<circle cx="148.0" cy="352.0" r="6" fill="#2563eb"/>
<circle cx="149.8" cy="328.8" r="6" fill="#2563eb"/>
<circle cx="155.2" cy="306.3" r="6" fill="#2563eb"/>
<circle cx="164.1" cy="284.8" r="6" fill="#2563eb"/>
<circle cx="176.3" cy="265.0" r="6" fill="#2563eb"/>
<circle cx="191.3" cy="247.3" r="6" fill="#2563eb"/>
<circle cx="209.0" cy="232.3" r="6" fill="#2563eb"/>
<circle cx="228.8" cy="220.1" r="6" fill="#2563eb"/>
<circle cx="250.3" cy="211.2" r="6" fill="#2563eb"/>
<circle cx="272.8" cy="205.8" r="6" fill="#2563eb"/>
<path d="M 372.4 246.8 A 130.0 130.0 0 0 0 204.1 443.9" fill="none" stroke="#e11d48" stroke-width="1.8"/><polygon points="204.1,443.9 193.8,440.0 200.3,433.6" fill="#e11d48"/>
<text x="226.5" y="316.6" font-size="11" text-anchor="middle" font-weight="700" fill="#e11d48">往回退 19 步</text>
<text x="296.0" y="186.0" font-size="12" text-anchor="middle" fill="#64748b">0</text>
<text x="466.0" y="356.0" font-size="12" text-anchor="start" fill="#64748b">10</text>
<text x="296.0" y="526.0" font-size="12" text-anchor="middle" fill="#64748b">20</text>
<text x="126.0" y="356.0" font-size="12" text-anchor="end" fill="#64748b">30</text>
<text x="419.0" y="229.0" font-size="11" text-anchor="start" font-weight="700" fill="#d97706">head = 5</text>
<text x="173.0" y="475.0" font-size="11" text-anchor="end" font-weight="700" fill="#2563eb">start = 25</text>
<text x="296.0" y="346.0" font-size="11" text-anchor="middle" fill="#94a3b8">环 = 40 格</text>
<text x="296.0" y="366.0" font-size="11" text-anchor="middle" fill="#94a3b8">窗 = 20 格(半圈)</text>
<rect x="596.0" y="186.0" width="418.0" height="96.0" rx="8" fill="#fff1f2" stroke="#e11d48" stroke-width="1.8"/>
<text x="610.0" y="212.0" font-size="13" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#e11d48">✗ 5 - 20</text>
<text x="1000.0" y="212.0" font-size="13" text-anchor="end" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#e11d48">= -15</text>
<text x="610.0" y="234.0" font-size="11.5" fill="#64748b">负数。C99 的 % 向零截断,(-15) % 40 仍是 -15;</text>
<text x="610.0" y="250.0" font-size="11.5" fill="#64748b">转成无符号就是 65521,拿去索引 s_ring 即越界读。</text>
<rect x="596.0" y="296.0" width="418.0" height="96.0" rx="8" fill="#fff1f2" stroke="#e11d48" stroke-width="1.8"/>
<text x="610.0" y="322.0" font-size="13" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#e11d48">✗ (uint16_t)(5 - 20) % 40</text>
<text x="1000.0" y="322.0" font-size="13" text-anchor="end" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#e11d48">= 65521 % 40 = 1</text>
<text x="610.0" y="344.0" font-size="11.5" fill="#64748b">65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。</text>
<text x="610.0" y="360.0" font-size="11.5" fill="#64748b">回绕后再取模不等价,读到的是环尾附近而不是窗首。</text>
<rect x="596.0" y="406.0" width="418.0" height="96.0" rx="8" fill="#f0fdf4" stroke="#16a34a" stroke-width="1.8"/>
<text x="610.0" y="432.0" font-size="13" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#16a34a">✓ (5 + 40 - 20) % 40</text>
<text x="1000.0" y="432.0" font-size="13" text-anchor="end" font-weight="700" font-family="Consolas, Cascadia Mono, DejaVu Sans Mono, monospace" fill="#16a34a">= 25 % 40 = 25</text>
<text x="610.0" y="454.0" font-size="11.5" fill="#64748b">就是窗首。加 40 不改同余类(40 ≡ 0 mod 40),</text>
<text x="610.0" y="470.0" font-size="11.5" fill="#64748b">中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。</text>
<line x1="46.0" y1="530.0" x2="1014.0" y2="530.0" stroke="#cbd5e1" stroke-width="1"/>
<text x="46.0" y="556.0" font-size="13" fill="#64748b">加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。</text>
</svg>

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -197,6 +197,11 @@ flowchart TD
最后写入的一点是 `s_head - 1`,于是"以最新点为终点的 n 点窗"= 下标 `[s_head-n, s_head-1]`,
窗首(最老点)就是 `s_head - n`,落到 `[0, DEPTH - 1]` 要取模。
取一个"head 不够减"的例子走一遍:`head = 5`、`n = 20`——最新点在 4,沿环往回退 19 步落在 25,
途中跨过 0 与 39。直接写 `5 - 20` 会得到 −15,压根不在环上。
![head 不够减时直接减会走到负数:沿环往回退 19 步跨过 0,加一圈 DEPTH 才回到 25](../images/采样环缓冲_窗首下标.svg)
代码里写成 `(s_head + SAMPLE_RING_DEPTH - n) % SAMPLE_RING_DEPTH` 而不是 `(s_head - n) % DEPTH`,
是因为后者有两个独立的坑:
+1
View File
@@ -23,6 +23,7 @@
| `gen_wave_record_flow_svg.py` | 故障录波数据流图 | 录波段数据的四个所有者与四条「不许」:谁独占写、谁只能读、标志怎么过界 |
| `gen_sample_ring_why_svg.py` | 采样环缓冲_为什么需要 | 上游 1 kHz 覆盖写只交最新一帧 vs 下游要 20 点滑窗:缺口 19 点与三个消费者 |
| `gen_sample_ring_layout_svg.py` | 采样环缓冲_环形布局 | 40 格环里哪 20 格是本窗、写指针指「下一格」、回绕与三条设计不变量 |
| `gen_sample_ring_start_svg.py` | 采样环缓冲_窗首下标 | 窗首 = head − n 的圆周走法,与「直接减 / 16 位回绕 / 先加 DEPTH」三种写法对照 |
## 使用方式
@@ -0,0 +1,316 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 docs/images/采样环缓冲_窗首下标.svg —— 窗首 start = head − n 为什么要先加一圈 DEPTH:
head 不够减时直接减会走到负数,取模就再也回不到环上。
为什么不用 Mermaid:本图要读的是"沿圆环往回退 19 格,途中跨过 0"这条**弧**,
以及三种写法算出来的三个**不同的数**。Mermaid 画不出圆周上的定位与角度,
盒子宽度也随文本变,"退半圈落在哪一格"这个关系就没了。
与 5.2 的环形布局图分工:那张讲「窗在哪几格」(线性排开 40 格 + 指针),
本图讲「这个下标怎么算出来」(圆周 + 三种写法对照),不重复。
用法(仓库根目录,任意 python3):
python tools/arch_diagram/gen_sample_ring_start_svg.py
输出:
docs/images/采样环缓冲_窗首下标.svg
改内容:示例(head / n)或三种写法的结论变了才重画;调坐标 / 文案编辑下方常量后重跑。
"""
import html
import math
import os
import sys
import xml.etree.ElementTree as ET
def emit(s):
"""控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩
(generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。"""
enc = getattr(sys.stdout, "encoding", None) or "ascii"
sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n")
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OUT = os.path.join(ROOT, "docs", "images", "采样环缓冲_窗首下标.svg")
# ----------------------------- 画布与尺寸 -----------------------------
W = 1060
H = 600
PAD = 46
RIGHT = W - PAD
FONT = "Microsoft YaHei, Segoe UI, PingFang SC, sans-serif"
MONO = "Consolas, Cascadia Mono, DejaVu Sans Mono, monospace"
TITLE_FONT = 21
SUB_FONT = 13
BOX_FONT = 13
BODY_FONT = 11.5
TINY_FONT = 11
DOT_LABEL_FONT = 12
INK = "#0f172a"
LINE = "#334155"
MUTED = "#64748b"
FAINT = "#94a3b8"
RULE = "#cbd5e1"
WHITE = "#ffffff"
C_BLUE = "#2563eb"
BG_BLUE = "#eff6ff"
C_ROSE = "#e11d48"
BG_ROSE = "#fff1f2"
C_GREEN = "#16a34a"
BG_GREEN = "#f0fdf4"
C_AMBER = "#d97706"
# ----------------------------- 数据(改这里) -----------------------------
DEPTH = 40
WIN_N = 20
HEAD = 5 # 示例写指针:故意取"不够减"的位置
START = (HEAD + DEPTH - WIN_N) % DEPTH # = 25
TITLE = "窗首 = head − n:head 不够减会走到负数,所以先加一圈 DEPTH 再减"
SUB1 = ("例:head = 5、n = 20 —— 最新点在 4,沿环往回退 19 步落在 25,途中跨过 0 与 39。"
"DEPTH = 40 时 20 点窗正好占半圈,所以这条弧看起来是个半圆。")
SUB2 = "同一个例子三种写法,只有第三种同时做到「同余正确」与「不发生下溢」:"
ARC_LABEL = "往回退 19 步"
CENTER_L1 = "环 = 40 格"
CENTER_L2 = "窗 = 20 格(半圈)"
HEAD_LABEL = "head = 5"
START_LABEL = "start = 25"
# (公式, 算出来是多少, 判定, 逐条理由, 主色, 底色)
ROWS = [
("5 - 20", "= -15", "✗",
["负数。C99 的 % 向零截断,(-15) % 40 仍是 -15;",
"转成无符号就是 65521,拿去索引 s_ring 即越界读。"],
C_ROSE, BG_ROSE),
("(uint16_t)(5 - 20) % 40", "= 65521 % 40 = 1", "✗",
["65536 不是 40 的倍数:(x mod 65536) mod 40 ≠ x mod 40。",
"回绕后再取模不等价,读到的是环尾附近而不是窗首。"],
C_ROSE, BG_ROSE),
("(5 + 40 - 20) % 40", "= 25 % 40 = 25", "✓",
["就是窗首。加 40 不改同余类(40 ≡ 0 mod 40),",
"中间值恒在 [0, 2·DEPTH - 2],两种提升规则都不下溢。"],
C_GREEN, BG_GREEN),
]
FOOTNOTE = ("加 DEPTH 不是「凑数」:它是环的周长,加一整圈等价于原地不动——"
"但把减法从「可能为负」搬到了「必然非负」,取模才恢复成数学上的同余解。")
# ----------------------------- 布局 -----------------------------
CX = 296.0
CY = 352.0
R = 148.0
DOT_R = 5.0
DOT_R_HL = 6.0
BOX_X = 596
BOX_W = RIGHT - BOX_X
BOX_TOP = 186
BOX_H = 96
BOX_GAP = 14
NOTE_Y = 556
# ----------------------------- 工具函数 -----------------------------
def esc(s):
return html.escape(s, quote=True)
def text_w(s, fs):
"""粗略估算文本像素宽:CJK/全角 ≈ 1.0em,其余 ≈ 0.58em(宁可偏大)。"""
return sum(fs * (1.0 if ord(ch) >= 0x2E80 else 0.58) for ch in s)
OVERFLOW = []
def check_fit(s, fs, avail, where):
if text_w(s, fs) > avail:
OVERFLOW.append("%s: %.0f > %.0f [%s]" % (where, text_w(s, fs), avail, s))
def txt(x, y, s, fs, fill, weight=400, anchor=None, mono=False):
a = "" if anchor is None else ' text-anchor="%s"' % anchor
w = "" if weight == 400 else ' font-weight="%d"' % weight
f = "" if not mono else ' font-family="%s"' % MONO
return ('<text x="%.1f" y="%.1f" font-size="%g"%s%s%s fill="%s">%s</text>'
% (x, y, fs, a, w, f, fill, esc(s)))
def line(x1, y1, x2, y2, color=LINE, width=2.0, dash=None):
d = "" if dash is None else ' stroke-dasharray="%s"' % dash
return ('<line x1="%.1f" y1="%.1f" x2="%.1f" y2="%.1f" stroke="%s" stroke-width="%g"%s/>'
% (x1, y1, x2, y2, color, width, d))
def rect(x, y, w, h, fill, stroke=None, rx=8, sw=1.6, dash=None):
s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw)
d = "" if dash is None else ' stroke-dasharray="%s"' % dash
return '<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" rx="%g" fill="%s"%s%s/>' % (
x, y, w, h, rx, fill, s, d)
def circle(cx, cy, r, fill, stroke=None, sw=1.5):
s = "" if stroke is None else ' stroke="%s" stroke-width="%g"' % (stroke, sw)
return '<circle cx="%.1f" cy="%.1f" r="%g" fill="%s"%s/>' % (cx, cy, r, fill, s)
def tri(points, fill):
return '<polygon points="%s" fill="%s"/>' % (
" ".join("%.1f,%.1f" % (px, py) for px, py in points), fill)
def pt(r, deg):
"""圆上一点:deg 以 +x 轴为 0、屏幕坐标 y 向下(故 90° 在正下方)。"""
a = math.radians(deg)
return CX + r * math.cos(a), CY + r * math.sin(a)
def angle_of(idx):
"""第 idx 格的角度:0 格在正上方,顺时针每格 360/DEPTH 度。"""
return -90.0 + idx * (360.0 / DEPTH)
def arc_arrow(r, a_from, a_to, color, width):
"""从 a_from 逆时针画到 a_to(a_to < a_from),末端带箭头。"""
x0, y0 = pt(r, a_from)
x1, y1 = pt(r, a_to)
large = 1 if abs(a_to - a_from) > 180.0 else 0
d = "M %.1f %.1f A %.1f %.1f 0 %d 0 %.1f %.1f" % (x0, y0, r, r, large, x1, y1)
out = '<path d="%s" fill="none" stroke="%s" stroke-width="%g"/>' % (d, color, width)
# 末端切向:用 a_to 与其后方 1° 两点之差,得到"行进方向"
xb, yb = pt(r, a_to + 1.0)
vx, vy = x1 - xb, y1 - yb
n = math.hypot(vx, vy) or 1.0
ux, uy = vx / n, vy / n
hl = 10.0
bx, by = x1 - ux * hl, y1 - uy * hl
px, py = -uy, ux
out += tri([(x1, y1), (bx + px * 4.6, by + py * 4.6), (bx - px * 4.6, by - py * 4.6)], color)
return out
def wrap(s, fs, avail, max_lines, where=""):
"""按估算宽度折行(CJK 逐字可断)。超出 max_lines 用省略号收尾——
宁可提示文案太长,也不让字溢出盒子。"""
if text_w(s, fs) <= avail:
return [s]
lines = []
cur = ""
for ch in s:
if text_w(cur + ch, fs) > avail:
lines.append(cur)
cur = ch
else:
cur += ch
if cur:
lines.append(cur)
if len(lines) > max_lines:
OVERFLOW.append("%s: 折行 %d > %d" % (where, len(lines), max_lines))
lines = lines[:max_lines]
lines[max_lines - 1] = lines[max_lines - 1][:-1] + "…"
return lines
# ----------------------------- 生成 -----------------------------
def build():
out = []
add = out.append
avail = RIGHT - PAD
for s, fs, where in ((TITLE, TITLE_FONT, "title"), (SUB1, SUB_FONT, "sub1"),
(SUB2, SUB_FONT, "sub2")):
check_fit(s, fs, avail, where)
if OVERFLOW:
for o in OVERFLOW:
emit("OVERFLOW WARN " + o)
add('<rect x="0" y="0" width="%d" height="%d" fill="%s"/>' % (W, H, WHITE))
# ---- 标题区 ----
add(txt(PAD, 42, TITLE, TITLE_FONT, INK, weight=700))
add(txt(PAD, 68, SUB1, SUB_FONT, MUTED))
add(txt(PAD, 88, SUB2, SUB_FONT, MUTED))
# ---- 左:40 格圆环 ----
win_lo = START
win_hi = HEAD - 1
add(circle(CX, CY, R, "none", RULE, 1.2))
for i in range(DEPTH):
x, y = pt(R, angle_of(i))
in_win = (i >= win_lo) or (i <= win_hi)
if i == HEAD:
add(circle(x, y, DOT_R_HL, C_AMBER))
elif in_win:
add(circle(x, y, DOT_R_HL, C_BLUE))
else:
add(circle(x, y, DOT_R, FAINT))
# 往回退的弧(贴着点圈内侧走,末端箭头落在 start 上)
add(arc_arrow(R - 18.0, angle_of(win_hi), angle_of(win_lo) - 360.0, C_ROSE, 1.8))
lx, ly = pt(R - 70.0, angle_of(33))
add(txt(lx, ly, ARC_LABEL, TINY_FONT, C_ROSE, weight=700, anchor="middle"))
# 圆周上的刻度名
for i in (0, 10, 20, 30):
x, y = pt(R + 22.0, angle_of(i))
anchor = "middle"
if i == 10:
anchor = "start"
elif i == 30:
anchor = "end"
add(txt(x, y + 4, str(i), DOT_LABEL_FONT, MUTED, anchor=anchor))
# head / start 两个关键点
hx, hy = pt(R + 26.0, angle_of(HEAD))
check_fit(HEAD_LABEL, TINY_FONT, 200, "head label")
add(txt(hx, hy, HEAD_LABEL, TINY_FONT, C_AMBER, weight=700, anchor="start"))
sx, sy = pt(R + 26.0, angle_of(START))
check_fit(START_LABEL, TINY_FONT, 200, "start label")
add(txt(sx, sy, START_LABEL, TINY_FONT, C_BLUE, weight=700, anchor="end"))
# 圆心说明
add(txt(CX, CY - 6, CENTER_L1, TINY_FONT, FAINT, anchor="middle"))
add(txt(CX, CY + 14, CENTER_L2, TINY_FONT, FAINT, anchor="middle"))
# ---- 右:三种写法 ----
inner = BOX_W - 32
for i, (formula, value, mark, why, c, bg) in enumerate(ROWS):
y = BOX_TOP + i * (BOX_H + BOX_GAP)
add(rect(BOX_X, y, BOX_W, BOX_H, bg, c, rx=8, sw=1.8))
add(txt(BOX_X + 14, y + 26, "%s %s" % (mark, formula), BOX_FONT, c, weight=700, mono=True))
add(txt(BOX_X + BOX_W - 14, y + 26, value, BOX_FONT, c, weight=700, anchor="end", mono=True))
yy = y + 48
for ln in why:
for sub in wrap(ln, BODY_FONT, inner, 1, "row %d why" % i):
add(txt(BOX_X + 14, yy, sub, BODY_FONT, MUTED))
yy += 16
# ---- 底部说明 ----
add(line(PAD, NOTE_Y - 26, RIGHT, NOTE_Y - 26, RULE, 1.0))
for i, ln in enumerate(wrap(FOOTNOTE, SUB_FONT, avail, 2, "foot wrap")):
add(txt(PAD, NOTE_Y + i * 19, ln, SUB_FONT, MUTED))
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" '
'viewBox="0 0 %d %d" font-family="%s">\n%s\n</svg>\n'
% (W, H, W, H, FONT, "\n".join(out)))
d = os.path.dirname(OUT)
if not os.path.isdir(d):
os.makedirs(d)
with open(OUT, "w", encoding="utf-8", newline="\n") as fp:
fp.write(svg)
ET.fromstring(svg)
emit("OK %d bytes, canvas %dx%d -> %s" % (len(svg.encode("utf-8")), W, H, OUT))
if OVERFLOW:
for o in OVERFLOW:
emit("OVERFLOW WARN " + o)
emit("!! %d overflow warnings" % len(OVERFLOW))
if __name__ == "__main__":
build()