docs(logo): 新增 logo v5.1 正式资产、使用规范与构建脚本

- 白文印「标」(AI 生成原图经脚本抠图、统一为 #D9291C、矢量化)
- 字标:思源宋体 SemiBold + Libre Caslon;比例 H2,英文视觉内缩 C2
- svg/、png/、favicon:横版、竖版、反白、单色、印章、各尺寸图标
- scripts/:logo_lib.py 统一排版参数,build_assets.sh 一键生成
- logo-spec.md、logo-sheet.html、字体许可证(OFL)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
YANG JIANKUAN
2026-11-02 20:28:18 +08:00
parent f4ef7af542
commit 5753b51131
51 changed files with 1160 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# 生成全部正式 logo 资产到 svg/、png/ 和 favicon.svg。
# 前置:v5/ 里有 process_seal.py 处理好的印章;.work/venv 和 .work/fonts 已准备好。
# 用法:docs/design/logo/scripts/build_assets.sh
set -euo pipefail
cd "$(dirname "$0")/.."
RED='#D9291C'
PY=.work/venv/bin/python
mkdir -p svg png
rm -f svg/*.svg png/*.png
# 1. 印章(来自 v5/,由 process_seal.py 生成)
cp v5/seal-clean.svg svg/seal.svg
cp v5/seal-textured.svg svg/seal-textured.svg
cp v5/seal-small.svg svg/seal-small.svg
cp v5/seal-paper.svg svg/seal-paper.svg
# 2. 字标与组合(H2 比例 + 思源宋体 SemiBold + Libre Caslon + C2 视觉对齐)
"$PY" scripts/build_logo.py
# 3. 图标:16px 用方案 B(加粗简化的「标」字),32px 起用小尺寸印章
sed "s/#D2282E/$RED/Ig" archive/v1/svg/favicon-16-b.svg > svg/favicon-16.svg
cp svg/seal-small.svg favicon.svg
# 4. PNG:组合与印章(透明背景,由矢量渲染)
rsvg-convert -w 1200 svg/logo-horizontal-textured.svg -o png/logo-horizontal-1200.png
rsvg-convert -w 2400 svg/logo-horizontal-textured.svg -o png/logo-horizontal-2400.png
rsvg-convert -w 1200 svg/logo-horizontal-textured-reverse.svg -o png/logo-horizontal-reverse-1200.png
rsvg-convert -w 600 svg/logo-vertical-textured.svg -o png/logo-vertical-600.png
rsvg-convert -w 600 svg/logo-vertical-textured-reverse.svg -o png/logo-vertical-reverse-600.png
rsvg-convert -w 1024 svg/seal-textured.svg -o png/seal-1024.png
# 5. PNG:图标
rsvg-convert -w 16 svg/favicon-16.svg -o png/favicon-16.png
rsvg-convert -w 32 svg/seal-small.svg -o png/favicon-32.png
# 应用图标:白底,印章占 80%(iOS 不支持透明图标)
for s in 180 192 512; do
inner=$((s * 80 / 100))
rsvg-convert -w "$inner" svg/seal-textured.svg -o "png/.tmp-$s.png"
magick -size "${s}x${s}" xc:white "png/.tmp-$s.png" -gravity center -composite "png/icon-$s.png"
rm "png/.tmp-$s.png"
done
mv png/icon-180.png png/apple-touch-icon-180.png
ls svg png

View File

@@ -0,0 +1,34 @@
"""生成正式字标和全部组合 SVG 到 svg/(决策 B4:H2 比例 + F2 中文 + E5 英文 + C2 视觉对齐)。
用法:.work/venv/bin/python scripts/build_logo.py
通常由 scripts/build_assets.sh 调用;运行前 svg/ 里要已有 seal*.svg。
"""
from logo_lib import FINAL, FINAL_INSET, LOGO, NAVY, WHITE, lockup_h2, lockup_vertical, wordmark
SVG = LOGO / "svg"
def write(name, svg):
(SVG / name).write_text(svg)
def wordmark_svg(color, pad=2):
body, w, h = wordmark(FINAL, color, inset=FINAL_INSET)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{-pad} {-pad} {w + 2 * pad:.2f} {h + 2 * pad:.2f}">{body}</svg>'
write("wordmark.svg", wordmark_svg(NAVY))
write("wordmark-reverse.svg", wordmark_svg(WHITE))
for kind, suffix in (("textured", "-textured"), ("clean", "")):
for color, dark, rev in ((NAVY, False, ""), (WHITE, True, "-reverse")):
body, w, h = wordmark(FINAL, color, inset=FINAL_INSET)
write(f"logo-horizontal{suffix}{rev}.svg", lockup_h2(body, w, h, on_dark=dark, kind=kind))
write(f"logo-vertical{suffix}{rev}.svg", lockup_vertical(body, w, h, on_dark=dark, kind=kind))
# 单色:干净版印章 + 字标同色
for color, name in (("#000000", "logo-mono-black.svg"), ("#FFFFFF", "logo-mono-white.svg")):
body, w, h = wordmark(FINAL, color, inset=FINAL_INSET)
write(name, lockup_h2(body, w, h, kind="clean", seal_color=color))
print("ok", sorted(p.name for p in SVG.glob("logo-*.svg")) + ["wordmark.svg", "wordmark-reverse.svg"])

View File

@@ -0,0 +1,149 @@
"""标博士 logo 的公共排版逻辑:字标排字、印章几何、横版 / 竖版拼合。
被 build_logo.py(正式资产)和 wordmark_compare.py(方案对比)共用。
字体文件在 .work/fonts/(不进 git),许可证见 licenses/。
"""
import re
import subprocess
from pathlib import Path
import numpy as np
from fontTools.pens.boundsPen import BoundsPen
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.ttLib import TTFont
from PIL import Image
LOGO = Path(__file__).resolve().parent.parent
FONTS = LOGO / ".work/fonts"
NAVY, WHITE, SEAL_RED = "#1C2554", "#FFFFFF", "#D9291C"
CN_TEXT, PY_TEXT = "标博士", "BIAOBOSHI"
CN_H = 100.0 # 中文字形实际高度(单位)
PY_CAP = 0.20 # 拼音大写字母高度 / 中文高度
GAP = 0.14 # 中文底部到拼音顶部的距离 / 中文高度
SEAL_SCALE = 1.02 # H2:印章实心高度 = 文字块高度 × 1.02(补偿颗粒边缘显虚)
SEAL_GAP = 0.3 # 横版:印章与文字的间距 / 印章高度
# 正式方案(决策 B4):F2 中文 + E5 英文 + C2 视觉对齐补偿
FINAL = ("FINAL", "NotoSerifSC[wght].ttf", 600, 0.04, "LibreCaslonText[wght].ttf", 600)
FINAL_INSET = (5.0, 5.8)
_font_cache = {}
def glyphset(file, wght):
key = (file, wght)
if key not in _font_cache:
f = TTFont(FONTS / file)
loc = {"wght": wght} if wght and "fvar" in f else None
_font_cache[key] = (f, f.getGlyphSet(location=loc) if loc else f.getGlyphSet())
return _font_cache[key]
def outline(file, wght, text, tracking_em):
"""返回 [(glyph, x_offset)] 和整体墨迹边界(字体单位,y 向上)。"""
font, gs = glyphset(file, wght)
cmap = font.getBestCmap()
upm = font["head"].unitsPerEm
x, items, bounds = 0.0, [], None
for ch in text:
g = cmap[ord(ch)]
bp = BoundsPen(gs)
gs[g].draw(bp)
if bp.bounds:
b = (bp.bounds[0] + x, bp.bounds[1], bp.bounds[2] + x, bp.bounds[3])
bounds = b if bounds is None else (min(bounds[0], b[0]), min(bounds[1], b[1]), max(bounds[2], b[2]), max(bounds[3], b[3]))
items.append((g, x))
x += gs[g].width + tracking_em * upm
return gs, items, bounds
def path_for(gs, items, scale, dx, dy):
"""把字形变换到目标坐标(y 向下):X = (x+off)*s + dx,Y = -y*s + dy。"""
pen = SVGPathPen(gs)
for g, off in items:
tp = TransformPen(pen, (scale, 0, 0, -scale, off * scale + dx, dy))
gs[g].draw(tp)
return pen.getCommands()
def wordmark(pair, color, inset=(0.0, 0.0)):
"""排出「标博士 / BIAOBOSHI」两行字标,返回 (SVG 片段, 宽, 高)。
pair:(编号, 中文字体, 中文字重, 中文字距 em, 拼音字体, 拼音字重, ...)
inset:拼音左右各内缩多少(单位同中文字高 100),用于视觉对齐补偿。
"""
_, cnf, cnw, cntr, pyf, pyw, *_ = pair
gs, items, b = outline(cnf, cnw, CN_TEXT, cntr)
s = CN_H / (b[3] - b[1])
cn_w = (b[2] - b[0]) * s
cn_d = path_for(gs, items, s, -b[0] * s, b[3] * s)
# 拼音:先量大写字母高度,再按(中文宽度 − 内缩)两端对齐(逐字母分配字距)
pgs, pitems, pb = outline(pyf, pyw, PY_TEXT, 0)
ps = CN_H * PY_CAP / (pb[3] - pb[1])
natural = (pb[2] - pb[0]) * ps
extra = (cn_w - inset[0] - inset[1] - natural) / (len(PY_TEXT) - 1)
pitems = [(g, off + i * extra / ps) for i, (g, off) in enumerate(pitems)]
py_top = CN_H * (1 + GAP)
py_d = path_for(pgs, pitems, ps, -pb[0] * ps + inset[0], py_top + pb[3] * ps)
height = py_top + CN_H * PY_CAP
body = f'<path fill="{color}" d="{cn_d}"/><path fill="{color}" d="{py_d}"/>'
return body, cn_w, height
def _seal_geometry():
"""量出印章在自身画布中的实心边界(用干净版),返回 (画布边长, 上, 下, 左, 右)。"""
svg = (LOGO / "svg/seal.svg").read_text()
size = float(re.search(r'viewBox="0 0 ([\d.]+)', svg).group(1))
probe = LOGO / ".work/seal-probe.png"
subprocess.run(["rsvg-convert", "-w", "2000", str(LOGO / "svg/seal.svg"), "-o", str(probe)], check=True)
a = np.asarray(Image.open(probe))[..., 3] > 128
ys, xs = np.where(a)
k = size / 2000
return size, ys.min() * k, (ys.max() + 1) * k, xs.min() * k, (xs.max() + 1) * k
SEAL_SIZE, ST, SB, SL, SR = _seal_geometry()
_SEAL_FILES = (("textured", "seal-textured.svg"), ("clean", "seal.svg"), ("paper", "seal-paper.svg"))
SEAL_BODY = {k: re.sub(r"^.*?<svg[^>]*>|</svg>\s*$", "", (LOGO / f"svg/{f}").read_text(), flags=re.S) for k, f in _SEAL_FILES}
SEAL_VB = {k: float(re.search(r'viewBox="0 0 ([\d.]+)', (LOGO / f"svg/{f}").read_text()).group(1)) for k, f in _SEAL_FILES}
def seal_group(kind, x, y, solid_h, on_dark=False, seal_color=None):
"""把印章放到 (x, y),使其实心部分左上角落在该点、实心高度为 solid_h。返回 (SVG 片段, 实心宽)。"""
s = solid_h / (SB - ST)
x0, y0 = x - SL * s, y - ST * s
body = SEAL_BODY[kind]
if seal_color:
body = body.replace(SEAL_RED, seal_color)
paper = (f'<g transform="translate({x0:.2f} {y0:.2f}) scale({s * SEAL_SIZE / SEAL_VB["paper"]:.6f})">{SEAL_BODY["paper"]}</g>'
if on_dark else "")
seal = f'<g transform="translate({x0:.2f} {y0:.2f}) scale({s * SEAL_SIZE / SEAL_VB[kind]:.6f})">{body}</g>'
return paper + seal, (SR - SL) * s
def lockup(body, wm_w, wm_h, seal_h, seal_top, on_dark=False, kind="textured", seal_color=None, pad=6):
"""横版:印章在左、字标在右。seal_h 是印章实心高度,seal_top 是实心顶端的 y。"""
seal, seal_w = seal_group(kind, 0, seal_top, seal_h, on_dark, seal_color)
tx = seal_w + max(seal_h, wm_h) * SEAL_GAP
top, bottom = min(0, seal_top), max(wm_h, seal_top + seal_h)
w = tx + wm_w
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{-pad:.1f} {top - pad:.1f} {w + 2 * pad:.1f} {bottom - top + 2 * pad:.1f}">'
f'{seal}<g transform="translate({tx:.2f} 0)">{body}</g></svg>')
def lockup_h2(body, wm_w, wm_h, **kw):
"""按 H2 比例拼横版:印章实心高度 = 文字块 × SEAL_SCALE,上下居中。"""
seal_h = wm_h * SEAL_SCALE
return lockup(body, wm_w, wm_h, seal_h, -(seal_h - wm_h) / 2, **kw)
def lockup_vertical(body, wm_w, wm_h, on_dark=False, kind="textured", seal_color=None, pad=6):
"""竖版:印章在上(实心边长 = 字标宽度的 50%),字标居中在下,间距 = 印章边长的 18%。"""
seal_h = wm_w * 0.5
seal, _ = seal_group(kind, (wm_w - seal_h) / 2, 0, seal_h, on_dark, seal_color)
gap = seal_h * 0.18
h = seal_h + gap + wm_h
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{-pad:.1f} {-pad:.1f} {wm_w + 2 * pad:.1f} {h + 2 * pad:.1f}">'
f'{seal}<g transform="translate(0 {seal_h + gap:.2f})">{body}</g></svg>')

View File

@@ -0,0 +1,97 @@
"""把位图生成的印章处理成 logo 资产:抠图、统一颜色、矢量化、小尺寸版本。
用法:.work/venv/bin/python scripts/process_seal.py <源图> <输出目录> [品牌红 HEX]
"""
import sys
from pathlib import Path
import numpy as np
import potrace
from PIL import Image
from scipy import ndimage
src, out_dir = Path(sys.argv[1]), Path(sys.argv[2])
brand = sys.argv[3] if len(sys.argv) > 3 else "#D9291C"
out_dir.mkdir(parents=True, exist_ok=True)
rgb = tuple(int(brand[i:i + 2], 16) for i in (1, 3, 5))
im = np.asarray(Image.open(src).convert("RGB")).astype(float)
redness = (im[..., 0] - (im[..., 1] + im[..., 2]) / 2) / 255
# 印泥浓淡 → 透明度:保留颗粒和毛边,背景完全透明
alpha = np.clip((redness - 0.12) / (0.55 - 0.12), 0, 1)
alpha = alpha * alpha * (3 - 2 * alpha) # smoothstep,让颗粒边缘柔和
ys, xs = np.where(redness > 0.35)
y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1
side = max(y1 - y0, x1 - x0)
pad = round(side * 0.02)
canvas = side + 2 * pad
cy, cx = (y0 + y1) // 2, (x0 + x1) // 2
half = canvas // 2
def crop(a):
out = np.zeros((canvas, canvas), dtype=a.dtype)
sy0, sx0 = cy - half, cx - half
ys_, xs_ = slice(max(sy0, 0), min(sy0 + canvas, a.shape[0])), slice(max(sx0, 0), min(sx0 + canvas, a.shape[1]))
out[ys_.start - sy0:ys_.stop - sy0, xs_.start - sx0:xs_.stop - sx0] = a[ys_, xs_]
return out
alpha_c = crop(alpha)
red_c = crop(redness)
# 1. 质感主图(透明 PNG)
rgba = np.zeros((canvas, canvas, 4), dtype=np.uint8)
rgba[..., :3] = rgb
rgba[..., 3] = (alpha_c * 255).round().astype(np.uint8)
Image.fromarray(rgba).save(out_dir / "seal-textured.png")
on_white = np.full((canvas, canvas, 3), 255.0)
on_white = on_white * (1 - alpha_c[..., None]) + np.array(rgb) * alpha_c[..., None]
Image.fromarray(on_white.round().astype(np.uint8)).save(out_dir / "seal-textured-on-white.png")
def trace(mask, name, turdsize, alphamax=1.0, fill=brand):
bm = potrace.Bitmap(~mask) # potracer 把 False 当作前景,这里取反
path = bm.trace(turdsize=turdsize, alphamax=alphamax, opticurve=True, opttolerance=0.2)
parts = []
for curve in path:
s = curve.start_point
d = [f"M{s.x:.1f} {s.y:.1f}"]
for seg in curve.segments:
if seg.is_corner:
d.append(f"L{seg.c.x:.1f} {seg.c.y:.1f}L{seg.end_point.x:.1f} {seg.end_point.y:.1f}")
else:
d.append(f"C{seg.c1.x:.1f} {seg.c1.y:.1f} {seg.c2.x:.1f} {seg.c2.y:.1f} {seg.end_point.x:.1f} {seg.end_point.y:.1f}")
d.append("Z")
parts.append("".join(d))
svg = (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {canvas} {canvas}">'
f'<path fill="{fill}" fill-rule="evenodd" d="{"".join(parts)}"/></svg>')
(out_dir / name).write_text(svg)
return len(svg)
# 2. 质感矢量版:放大 2 倍再描摹,保留颗粒
big = np.asarray(Image.fromarray((alpha_c * 255).astype(np.uint8)).resize((canvas * 2, canvas * 2), Image.LANCZOS)) / 255
n_tex = trace(big > 0.5, "seal-textured-2x.svg", turdsize=6)
# 描摹坐标是 2 倍的,修正 viewBox
p = out_dir / "seal-textured-2x.svg"
p.write_text(p.read_text().replace(f'viewBox="0 0 {canvas} {canvas}"', f'viewBox="0 0 {canvas * 2} {canvas * 2}"'))
p.rename(out_dir / "seal-textured.svg")
# 3. 干净版:先模糊去掉颗粒,再二值化描摹,得到平滑的印面和字形
solid = ndimage.gaussian_filter((red_c > 0.35).astype(float), sigma=canvas * 0.006) > 0.5
solid = ndimage.binary_fill_holes(solid) & solid # 保留白字(它们与外部不连通时是「洞」)
solid = ndimage.binary_opening(solid, iterations=2)
n_clean = trace(solid, "seal-clean.svg", turdsize=canvas * 0.0005 * canvas, alphamax=1.1)
# 4. 小尺寸版:白色笔画加粗(墨色腐蚀),保证 32px 下笔画不消失
thick = ndimage.binary_erosion(solid, iterations=max(2, round(canvas * 0.012)))
n_small = trace(thick, "seal-small.svg", turdsize=canvas * 0.0008 * canvas, alphamax=1.1)
# 5. 纸底:印面外轮廓(不含白字),向内收一点,深色背景上垫在印章下面,让白字和颗粒显示为纸色
paper = ndimage.binary_erosion(ndimage.binary_fill_holes(solid), iterations=max(2, round(canvas * 0.006)))
trace(paper, "seal-paper.svg", turdsize=canvas * 0.001 * canvas, alphamax=1.2, fill="#FFFFFF")
print(f"canvas={canvas}px brand={brand} textured.svg={n_tex // 1024}KB clean.svg={n_clean // 1024}KB small.svg={n_small // 1024}KB")

View File

@@ -0,0 +1,114 @@
"""生成 VI 手册里的标志构成与安全区标注图(docs/design/vi/diagrams/)。
标注数值直接取自 logo_lib 的实际几何,保证和正式资产一致。
用法:.work/venv/bin/python scripts/vi_diagrams.py
"""
from logo_lib import (FINAL, FINAL_INSET, LOGO, NAVY, SB, SEAL_GAP, SEAL_SCALE, SL, SR, ST, CN_H, GAP, PY_CAP,
seal_group, wordmark)
OUT = LOGO.parent / "vi/diagrams"
OUT.mkdir(parents=True, exist_ok=True)
BLUE = "#2F5BD3"
FONT = 'font-family="PingFang SC, Noto Sans SC, sans-serif"'
body, W, H = wordmark(FINAL, NAVY, inset=FINAL_INSET)
SEAL_H = H * SEAL_SCALE
SEAL_TOP = -(SEAL_H - H) / 2
SEAL_W = (SR - SL) * SEAL_H / (SB - ST)
GAP_X = SEAL_H * SEAL_GAP
TX = SEAL_W + GAP_X
def label(x, y, text, anchor="middle", size=7.5):
return f'<text x="{x:.1f}" y="{y:.1f}" fill="{BLUE}" font-size="{size}" text-anchor="{anchor}" {FONT}>{text}</text>'
def hline(x1, x2, y, dash=True):
d = ' stroke-dasharray="2 2"' if dash else ""
return f'<line x1="{x1:.1f}" y1="{y:.1f}" x2="{x2:.1f}" y2="{y:.1f}" stroke="{BLUE}" stroke-width="0.5"{d}/>'
def vline(x, y1, y2, dash=True):
d = ' stroke-dasharray="2 2"' if dash else ""
return f'<line x1="{x:.1f}" y1="{y1:.1f}" x2="{x:.1f}" y2="{y2:.1f}" stroke="{BLUE}" stroke-width="0.5"{d}/>'
def dim_v(x, y1, y2, text, side="left"):
"""竖向尺寸线,带两端短横。"""
t = 2.5
lx = x - 4 if side == "left" else x + 4
return (vline(x, y1, y2, dash=False) + hline(x - t, x + t, y1, dash=False) + hline(x - t, x + t, y2, dash=False)
+ label(lx, (y1 + y2) / 2 + 2.6, text, "end" if side == "left" else "start"))
def dim_h(x1, x2, y, text, below=False):
t = 2.5
ly = y + 9 if below else y - 3.5
return (hline(x1, x2, y, dash=False) + vline(x1, y - t, y + t, dash=False) + vline(x2, y - t, y + t, dash=False)
+ label((x1 + x2) / 2, ly, text))
def construction_h():
seal, _ = seal_group("clean", 0, SEAL_TOP, SEAL_H)
py_top = CN_H * (1 + GAP)
x_end = TX + W
parts = [seal, f'<g transform="translate({TX:.2f} 0)">{body}</g>']
# 文字块上下边界与印章边界
parts += [hline(-30, x_end + 12, 0), hline(-30, x_end + 12, H),
hline(-30, SEAL_W + 4, SEAL_TOP), hline(-30, SEAL_W + 4, SEAL_TOP + SEAL_H)]
parts.append(dim_v(-12, 0, H, "H"))
parts.append(dim_v(-26, SEAL_TOP, SEAL_TOP + SEAL_H, "1.02H"))
# 印章与文字间距
parts.append(dim_h(SEAL_W, TX, H + 18, "0.3 × 印章高", below=True))
parts += [vline(SEAL_W, SEAL_TOP + SEAL_H, H + 22), vline(TX, H, H + 22)]
# 字标内部:中文字高、间距、拼音高
rx = x_end + 10
parts.append(dim_v(rx, 0, CN_H, "中文字高 100", side="right"))
parts.append(dim_v(rx, CN_H, py_top, "间距 14", side="right"))
parts.append(dim_v(rx, py_top, H, "拼音 20", side="right"))
# 英文视觉内缩
parts += [vline(TX, py_top - 6, H + 4), vline(x_end, py_top - 6, H + 4)]
parts.append(vline(TX + FINAL_INSET[0], py_top - 6, H + 4))
parts.append(vline(x_end - FINAL_INSET[1], py_top - 6, H + 4))
parts.append(label(TX + 2.5, H + 12, f"内缩 {FINAL_INSET[0]:g}", "start", 6.5))
parts.append(label(x_end - 2.5, H + 12, f"内缩 {FINAL_INSET[1]:g}", "end", 6.5))
vb = (-60, SEAL_TOP - 14, x_end + 150, SEAL_H + 56)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{" ".join(f"{v:.1f}" for v in vb)}">{"".join(parts)}</svg>'
def construction_v():
seal_h = W * 0.5
seal, _ = seal_group("clean", (W - seal_h) / 2, 0, seal_h)
gap = seal_h * 0.18
y_wm = seal_h + gap
parts = [seal, f'<g transform="translate(0 {y_wm:.2f})">{body}</g>']
parts += [vline(0, -8, y_wm + H + 6), vline(W, -8, y_wm + H + 6)]
parts.append(dim_h(0, W, -14, "字标宽 W"))
parts.append(dim_h((W - seal_h) / 2, (W + seal_h) / 2, seal_h + 5, "0.5W", below=True))
parts.append(dim_v(W + 12, seal_h, y_wm, "0.18 × 印章边长", side="right"))
parts += [hline((W + seal_h) / 2, W + 14, seal_h), hline(W, W + 14, y_wm)]
vb = (-20, -30, W + 130, y_wm + H + 40)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{" ".join(f"{v:.1f}" for v in vb)}">{"".join(parts)}</svg>'
def clearspace():
seal, _ = seal_group("clean", 0, SEAL_TOP, SEAL_H)
m = H / 4
x0, y0 = -m, SEAL_TOP - m
x1, y1 = TX + W + m, SEAL_TOP + SEAL_H + m
parts = [f'<rect x="{x0:.1f}" y="{y0:.1f}" width="{x1 - x0:.1f}" height="{y1 - y0:.1f}" fill="#E7EDFB" stroke="{BLUE}" stroke-width="0.6" stroke-dasharray="3 2"/>',
f'<rect x="0" y="{SEAL_TOP:.1f}" width="{TX + W:.1f}" height="{SEAL_H:.1f}" fill="#fff"/>',
seal, f'<g transform="translate({TX:.2f} 0)">{body}</g>']
# 四边标注 H/4
parts.append(dim_h(x0, 0, SEAL_TOP + SEAL_H / 2, "H/4"))
parts.append(dim_h(TX + W, x1, SEAL_TOP + SEAL_H / 2, "H/4"))
parts.append(dim_v(TX + W / 2, y0, SEAL_TOP, "H/4", side="right"))
parts.append(dim_v(TX + W / 2, SEAL_TOP + SEAL_H, y1, "H/4", side="right"))
vb = (x0 - 10, y0 - 10, x1 - x0 + 20, y1 - y0 + 20)
return f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="{" ".join(f"{v:.1f}" for v in vb)}">{"".join(parts)}</svg>'
(OUT / "construction-horizontal.svg").write_text(construction_h())
(OUT / "construction-vertical.svg").write_text(construction_v())
(OUT / "clearspace.svg").write_text(clearspace())
print("ok", sorted(p.name for p in OUT.iterdir()))

View File

@@ -0,0 +1,160 @@
"""生成字标字体对比与印章高度对比(docs/design/logo/compare/)。
用法:.work/venv/bin/python scripts/wordmark_compare.py
字体文件在 .work/fonts/(不进 git),许可证见 compare/index.html 与 logo-spec.md。
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from logo_lib import CN_H, GAP, LOGO, NAVY, PY_CAP, WHITE, lockup, wordmark # noqa: E402
OUT = LOGO / "compare"
OUT.mkdir(exist_ok=True)
# (编号, 中文字体, 中文字重, 中文字距(em), 拼音字体, 拼音字重, 名称, 许可证, 说明)
PAIRS = [
("F1", "NotoSerifSC[wght].ttf", 900, 0.00, "Cinzel[wght].ttf", 600, "思源宋体 Black + Cinzel", "OFL", "现用。最粗的宋体,庄重、有分量"),
("F2", "NotoSerifSC[wght].ttf", 600, 0.04, "CormorantGaramond[wght].ttf", 600, "思源宋体 SemiBold + Cormorant Garamond", "OFL", "同一家族的细一档,更清秀,和印章的轻重对比更明显"),
("F3", "ZhuqueFangsong.ttf", None, 0.06, "CormorantSC-SemiBold.ttf", None, "朱雀仿宋 + Cormorant SC", "OFL", "仿宋,招标文件和公文的正文字体,文书感最强"),
("F4", "HuiwenMincho.ttf", None, 0.04, "LibreCaslonText[wght].ttf", 700, "汇文明朝体 + Libre Caslon", "授权需复核", "老铅字印刷的宋体,带油墨感,和印章最搭;授权说明不规范"),
("F5", "LXGWWenKai-Medium.ttf", None, 0.06, "EBGaramond[wght].ttf", 600, "霞鹜文楷 + EB Garamond", "OFL", "楷书,书卷气,亲切但不够硬朗"),
("F6", "ZCOOLXiaoWei-Regular.ttf", None, 0.04, "MarcellusSC-Regular.ttf", None, "站酷小薇 LOGO 体 + Marcellus SC", "OFL", "为标志设计的宋体,笔画更有个性;但「标」的木字旁写成带钩的连笔,容易被看成「扌」,不建议"),
("F7", "NotoSansSC[wght].ttf", 900, 0.02, "Montserrat[wght].ttf", 600, "思源黑体 Black + Montserrat", "OFL", "黑体,现代、稳重,和传统印章形成反差"),
("F8", "SmileySans-Oblique.ttf", None, 0.02, "SmileySans-Oblique.ttf", None, "得意黑(中英同一字体)", "OFL", "带倾斜的现代黑体,年轻、有速度感"),
]
# 1. 印章高度对比(字体 F1)
body, w, h = wordmark(PAIRS[0], NAVY)
cn_h = CN_H
variants = [
("H0", "现状:96%,上下没对齐", lockup(body, w, h, h * 0.961, (h - h * 0.961) / 2 + 0.6)),
("H1", "等高对齐:印章上下边对齐中文顶端和拼音底部", lockup(body, w, h, h, 0)),
("H2", "等高 + 光学补偿 2%:抵消印章颗粒边缘显虚", lockup(body, w, h, h * 1.02, -h * 0.01)),
("H3", "放大到 115%:图形主导", lockup(body, w, h, h * 1.15, -h * 0.075)),
("H4", "书法落款式:印章只与中文同高", lockup(body, w, h, cn_h, 0)),
]
for vid, _, svg in variants:
(OUT / f"height-{vid}.svg").write_text(svg)
# 2. 字体对比(统一用 H2)
for p in PAIRS:
for color, dark, suffix in ((NAVY, False, ""), (WHITE, True, "-reverse")):
body, w, h = wordmark(p, color)
(OUT / f"font-{p[0]}{suffix}.svg").write_text(lockup(body, w, h, h * 1.02, -h * 0.01, on_dark=dark))
# 3. 对比页
rows_h = "".join(f'<figure class="tile"><img src="height-{vid}.svg" alt="{t}"><figcaption><b>{vid}</b> {t}</figcaption></figure>'
for vid, t, _ in variants)
rows_f = "".join(
f'<section class="font"><h3>{p[0]} · {p[6]}</h3><p>{p[8]}<span class="lic{" warn" if p[7] != "OFL" else ""}">{p[7]}</span></p>'
f'<div class="pair"><div class="tile"><img src="font-{p[0]}.svg" alt="{p[6]} 浅色底"></div>'
f'<div class="tile dark"><img src="font-{p[0]}-reverse.svg" alt="{p[6]} 深色底"></div></div>'
f'<div class="small"><img src="font-{p[0]}.svg" width="200" alt=""><img src="font-{p[0]}.svg" width="120" alt=""></div></section>'
for p in PAIRS)
html = f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>字标与比例对比</title><style>
:root{{--ink:#1D2733;--sub:#5C6670;--line:#D9DDD8}}
*{{box-sizing:border-box}}body{{margin:0;background:#F5F6F3;color:var(--ink);font:15px/1.7 "PingFang SC","Microsoft YaHei",system-ui,sans-serif}}
main{{max-width:1120px;margin:0 auto;padding:40px 24px 80px}}h1{{font-size:26px;margin:0 0 4px}}h2{{font-size:19px;margin:44px 0 6px}}h3{{font-size:16px;margin:0}}
p.note{{color:var(--sub);margin:0 0 18px;max-width:48em}}
.grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:14px}}
.tile{{background:#fff;border:1px solid var(--line);padding:28px;margin:0}}.tile.dark{{background:#1D2733;border-color:#1D2733}}
.tile img{{display:block;width:100%;height:auto}}figcaption{{font-size:13px;color:var(--sub);margin-top:12px}}
.font{{background:#fff;border:1px solid var(--line);padding:20px;margin:0 0 16px}}.font p{{margin:2px 0 12px;color:var(--sub);font-size:14px}}
.pair{{display:grid;grid-template-columns:1fr 1fr;gap:12px}}.pair .tile{{padding:20px}}
.small{{display:flex;gap:24px;align-items:center;margin-top:12px}}
.lic{{display:inline-block;margin-left:8px;font-size:12px;padding:0 6px;border:1px solid #2E6B4F;color:#2E6B4F;border-radius:2px}}.lic.warn{{border-color:#A66300;color:#A66300}}
@media (max-width:700px){{.pair{{grid-template-columns:1fr}}}}
</style></head><body><main>
<h1>字标与比例对比</h1><p class="note">两个问题:一、印章和右边文字的高度关系;二、字标字体。所有版本都用同一枚印章,文字已转成路径。</p>
<h2>一、印章高度</h2><p class="note">字体统一用 F1。H0 是现在的版本,H2 是建议的版本。</p>
<div class="grid">{rows_h}</div>
<h2>二、字标字体</h2><p class="note">比例统一用 H2。每组下面一行是 200px 和 120px 宽度的实际效果。</p>
{rows_f}
</main></body></html>"""
(OUT / "index.html").write_text(html)
print("ok", len(PAIRS), "fonts,", len(variants), "height variants")
# 4. 英文字体对比:中文固定为 F2(思源宋体 SemiBold),比例 H2
# (编号, 英文字体, 字重, 名称, 对应的经典字体 / 类型, 说明)
EN_OPTIONS = [
("E0", "CormorantGaramond[wght].ttf", 600, "Cormorant Garamond", "Garamond 类 · 古典衬线", "现选(F2)。纤细优雅,笔画对比高"),
("E1", "Cinzel[wght].ttf", 500, "Cinzel", "Trajan 类 · 罗马碑刻大写", "庄重、权威,金融和教育机构常用的气质"),
("E2", "MarcellusSC-Regular.ttf", None, "Marcellus SC", "Optima / Albertus 类 · 半衬线", "笔画末端微微张开,介于衬线和无衬线之间,和宋体很合拍"),
("E3", "TenorSans-Regular.ttf", None, "Tenor Sans", "Optima 类 · 人文无衬线", "无衬线但有书法韵味,现代而不冷"),
("E4", "EBGaramond[wght].ttf", 600, "EB Garamond", "Garamond · 古典衬线", "比 E0 更结实、更正统的 Garamond"),
("E5", "LibreCaslonText[wght].ttf", 600, "Libre Caslon", "Caslon · 老派衬线", "英国老字号和出版社的经典选择,沉稳"),
("E6", "SourceSerif4[opsz,wght].ttf", 600, "Source Serif 4", "与思源宋体同属 Adobe Source 家族", "和中文出自同一设计体系,最协调、最「工整」"),
("E7", "BodoniModa[opsz,wght].ttf", 600, "Bodoni Moda", "Didot / Bodoni 类 · 现代高对比", "时尚、精致,奢侈品和杂志常用"),
("E8", "Jost[wght].ttf", 500, "Jost", "Futura 类 · 几何无衬线", "现代、干净,和传统印章形成反差"),
]
cn = PAIRS[1] # F2
for eid, ef, ew, *_ in EN_OPTIONS:
pair = (eid, cn[1], cn[2], cn[3], ef, ew)
for color, dark, suffix in ((NAVY, False, ""), (WHITE, True, "-reverse")):
body, w, h = wordmark(pair, color)
(OUT / f"en-{eid}{suffix}.svg").write_text(lockup(body, w, h, h * 1.02, -h * 0.01, on_dark=dark))
rows_e = "".join(
f'<section class="font"><h3>{e[0]} · {e[3]}</h3><p>{e[4]}。{e[5]}<span class="lic">OFL</span></p>'
f'<div class="pair"><div class="tile"><img src="en-{e[0]}.svg" alt="{e[3]} 浅色底"></div>'
f'<div class="tile dark"><img src="en-{e[0]}-reverse.svg" alt="{e[3]} 深色底"></div></div>'
f'<div class="small"><img src="en-{e[0]}.svg" width="200" alt=""><img src="en-{e[0]}.svg" width="120" alt=""></div></section>'
for e in EN_OPTIONS)
(OUT / "english.html").write_text(html.split("<h1>")[0] + f"""<h1>英文字体对比(H2 + F2)</h1>
<p class="note">中文固定为思源宋体 SemiBold,比例固定为 H2,只换下方英文。全部是 Google Fonts 上的 SIL OFL 1.1 字体,可以免费商用、可以用在 logo 里。</p>
{rows_e}
</main></body></html>""")
print("ok", len(EN_OPTIONS), "english options")
# 5. 英文特写:只显示拼音这一行,放大比较字形
py_top, py_h = CN_H * (1 + GAP), CN_H * PY_CAP
for eid, ef, ew, *_ in EN_OPTIONS:
body, w, h = wordmark((eid, cn[1], cn[2], cn[3], ef, ew), NAVY)
(OUT / f"en-{eid}-detail.svg").write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 {py_top - 3:.1f} {w + 4:.1f} {py_h + 6:.1f}">{body}</svg>')
detail = "".join(f'<figure class="tile"><img src="en-{e[0]}-detail.svg" alt="{e[3]} 特写"><figcaption><b>{e[0]}</b> {e[3]}</figcaption></figure>'
for e in EN_OPTIONS)
page = (OUT / "english.html").read_text()
page = page.replace("</main>", f'<h2>英文特写</h2><p class="note">只看拼音这一行,放大比较字形细节。</p><div class="grid">{detail}</div></main>')
(OUT / "english.html").write_text(page)
print("ok detail")
# 6. 英文视觉对齐补偿(H2 + F2 + E5)
# 实测:中文两端只有细横画尖,英文两端是 B、I 的粗竖画;按墨迹浓度算英文左超 6.5、右超 7.7(中文字高 = 100)
E5 = next(e for e in EN_OPTIONS if e[0] == "E5")
ALIGN = [
("C0", (0.0, 0.0), "不补偿:几何两端对齐(现状)"),
("C1", (3.5, 4.0), "补偿一半:左 3.5、右 4.0"),
("C2", (5.0, 5.8), "补偿约 3/4:左 5.0、右 5.8"),
("C3", (6.5, 7.7), "完全补偿:按墨迹浓度边缘对齐,左 6.5、右 7.7"),
]
CN_VISUAL = (8.6, 315.4) # 中文「浓度 ≥ 15%」的视觉边缘(实测)
pair_e5 = ("E5", cn[1], cn[2], cn[3], E5[1], E5[2])
for cid, ins, _ in ALIGN:
for color, dark, suffix in ((NAVY, False, ""), (WHITE, True, "-reverse")):
body, w, h = wordmark(pair_e5, color, inset=ins)
(OUT / f"align-{cid}{suffix}.svg").write_text(lockup(body, w, h, h * 1.02, -h * 0.01, on_dark=dark))
# 带辅助线的字标特写:红线 = 中文视觉边缘,灰线 = 中文几何边界
body, w, h = wordmark(pair_e5, NAVY, inset=ins)
guides = (f'<line x1="0" y1="-4" x2="0" y2="{h + 4:.1f}" stroke="#999" stroke-width="0.6" stroke-dasharray="2 2"/>'
f'<line x1="{w:.1f}" y1="-4" x2="{w:.1f}" y2="{h + 4:.1f}" stroke="#999" stroke-width="0.6" stroke-dasharray="2 2"/>'
f'<line x1="{CN_VISUAL[0]}" y1="-4" x2="{CN_VISUAL[0]}" y2="{h + 4:.1f}" stroke="#D9291C" stroke-width="0.6"/>'
f'<line x1="{CN_VISUAL[1]}" y1="-4" x2="{CN_VISUAL[1]}" y2="{h + 4:.1f}" stroke="#D9291C" stroke-width="0.6"/>')
(OUT / f"align-{cid}-guides.svg").write_text(
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="-8 -8 {w + 16:.1f} {h + 16:.1f}"><rect x="-8" y="-8" width="{w + 16:.1f}" height="{h + 16:.1f}" fill="#fff"/>{body}{guides}</svg>')
rows_a = "".join(
f'<section class="font"><h3>{c[0]} · {c[2]}</h3>'
f'<div class="pair"><div class="tile"><img src="align-{c[0]}.svg" alt="{c[0]} 浅色底"></div>'
f'<div class="tile"><img src="align-{c[0]}-guides.svg" alt="{c[0]} 辅助线"></div></div>'
f'<div class="small"><img src="align-{c[0]}.svg" width="240" alt=""><img src="align-{c[0]}.svg" width="160" alt=""></div></section>'
for c in ALIGN)
(OUT / "alignment.html").write_text(html.split("<h1>")[0] + f"""<h1>英文视觉对齐补偿(H2 + F2 + E5)</h1>
<p class="note">右侧特写的辅助线:灰色虚线是中文的几何边界(最外的墨迹),红线是中文的视觉边缘(墨迹浓度达到行高 15% 的位置)。
补偿越多,英文越往红线靠。建议不看辅助线、只看左侧的整体效果做判断。</p>
{rows_a}
</main></body></html>""")
print("ok alignment")