Merge pull request #1455 from CyC2018/diagram-tools

添加配图水印工具和贡献指南
This commit is contained in:
CyC2018
2026-10-25 19:24:16 +08:00
committed by GitHub
6 changed files with 326 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
# 检查 PR 中新增或修改的配图是否已加 CyC2018 水印,缺少时提示用 tools/watermark.py 补上。
name: 配图水印检查
on:
pull_request:
paths:
- 'notes/pics/**'
permissions:
contents: read
jobs:
watermark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pillow
- name: 检查配图
env:
BASE_REF: ${{ github.base_ref }}
run: |
git diff --name-only --diff-filter=AM "origin/$BASE_REF...HEAD" -- 'notes/pics/*.png' 'notes/pics/*.jpg' 'notes/pics/*.jpeg' > files.txt
if [ ! -s files.txt ]; then echo "没有新增或修改的 PNG、JPEG 配图"; exit 0; fi
if ! xargs -d '\n' python3 tools/watermark.py --check < files.txt; then
echo "::error::以上配图缺少水印,请执行 python3 tools/watermark.py <图片> 后重新提交,或启用 git config core.hooksPath tools/hooks"
exit 1
fi
+47
View File
@@ -0,0 +1,47 @@
# 贡献指南
本仓库是面向复习和面试的技术笔记,内容力求简洁准确。欢迎通过 Issue 或 PR 指出错误、补充内容。
## Issue
指出错误时请写明:
- 文件名和原文;
- 问题所在以及修改建议;
- 依据,优先引用官方文档、规范、论文或源码。
## PR
- 一个 PR 只解决一类问题,例如修正错误和补充内容分开提交;
- 不改动已有的章节结构、标题编号和图片,修改标题时同步更新文件开头的目录;
- 排版遵循 [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines/blob/master/README.zh-Hans.md),中英文之间加空格,使用全角标点;
- 修改代码时在 PR 中说明验证方式,例如测试用例或与暴力解法的对照结果。
## 配图
新图片放在 `notes/pics` 目录,在笔记中用相对路径引用:
```html
<div align="center"> <img src="pics/xxx.png" width="500px"/> </div><br>
```
配图需要加 CyC2018 浅水印,包括整张图斜向平铺的淡色文字和右下角的标识。启用仓库自带的 Git 钩子后,提交时会自动给 `notes/pics` 下新增或修改的 PNG、JPEG 图片加水印,已加过的图片会跳过:
```bash
git config core.hooksPath tools/hooks
```
PR 中新增或修改的配图如果没有水印,GitHub Actions 的“配图水印检查”会失败并给出提示。
也可以手动执行,依赖 Pillow:
```bash
python3 tools/watermark.py notes/pics/xxx.png
python3 tools/watermark.py --check notes/pics/xxx.png # 只检查
```
示意图建议保留可编辑的源文件。`tools/diagram/src` 中的 SVG 可以用以下命令渲染成 `notes/pics` 下同名的 PNG,需要本机安装 Chrome、Edge 或 Chromium:
```bash
python3 tools/diagram/build.py xxx
```
+2
View File
@@ -90,6 +90,8 @@
在线排版工具:[Text-Typesetting](https://github.com/CyC2018/Text-Typesetting)。
提交 Issue、PR 以及添加配图的约定见 [贡献指南](CONTRIBUTING.md)。
### License
本仓库的内容不是将网上的资料随意拼凑而来,除了少部分引用书上和技术文档的原文(这部分内容都在末尾的参考链接中加了出处),其余都是我的原创。在您引用本仓库内容或者对内容进行修改演绎时,请署名并以相同方式共享,谢谢。
+70
View File
@@ -0,0 +1,70 @@
"""把 src/*.svg 渲染成 notes/pics/*.png。
用法:
python3 tools/diagram/build.py # 渲染全部
python3 tools/diagram/build.py paxos-example cache-aside
依赖:Pillow,以及 Chrome、Edge 或 Chromium 之一(无头模式截图)。
浏览器路径可用环境变量 CHROME 指定。
图片按 2 倍分辨率输出,在笔记里用 width 指定为 SVG 的宽度即可。
渲染结果不带水印,水印由 tools/watermark.py 添加,启用 tools/hooks 后提交时会自动执行。
"""
import os
import shutil
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from PIL import Image
HERE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(HERE, "src")
OUT = os.path.join(HERE, "..", "..", "notes", "pics")
SCALE = 2 # 按 2 倍分辨率输出,在高分屏上也清晰
BROWSERS = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"google-chrome", "chromium", "chromium-browser", "microsoft-edge",
]
def browser():
if os.environ.get("CHROME"):
return os.environ["CHROME"]
for b in BROWSERS:
path = b if os.path.isabs(b) else shutil.which(b)
if path and os.path.exists(path):
return path
sys.exit("找不到 Chrome/Edge/Chromium,请用环境变量 CHROME 指定浏览器路径")
def render(name, exe, profile):
svg = os.path.join(SRC, name + ".svg")
root = ET.parse(svg).getroot()
w, h = int(float(root.get("width"))), int(float(root.get("height")))
png = os.path.abspath(os.path.join(OUT, name + ".png"))
subprocess.run([
exe, "--headless=new", "--disable-gpu", "--hide-scrollbars", "--no-first-run",
"--user-data-dir=" + profile, f"--force-device-scale-factor={SCALE}",
f"--window-size={w},{h}", "--default-background-color=ffffffff",
"--screenshot=" + png, "file://" + os.path.abspath(svg),
], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
img = Image.open(png).convert("RGB")
img.save(png, optimize=True)
print(f"{name}.svg -> notes/pics/{name}.png {img.size[0]}x{img.size[1]}")
def main():
names = sys.argv[1:] or sorted(f[:-4] for f in os.listdir(SRC) if f.endswith(".svg"))
exe = browser()
with tempfile.TemporaryDirectory() as profile:
for name in names:
render(name, exe, profile)
if __name__ == "__main__":
main()
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# 提交时给 notes/pics 下新增或修改的 PNG、JPEG 配图自动加 CyC2018 浅水印。
# 启用:git config core.hooksPath tools/hooks
root=$(git rev-parse --show-toplevel) || exit 1
files=$(git diff --cached --name-only --diff-filter=AM -- 'notes/pics/*.png' 'notes/pics/*.jpg' 'notes/pics/*.jpeg')
[ -z "$files" ] && exit 0
if ! python3 -c "import PIL" 2>/dev/null; then
echo "pre-commit: 需要 Pillow 才能给配图加水印,请先执行 pip3 install pillow" >&2
exit 1
fi
echo "$files" | while IFS= read -r f; do
python3 "$root/tools/watermark.py" "$root/$f" || exit 1
git add -- "$root/$f"
done
+161
View File
@@ -0,0 +1,161 @@
"""给配图加 CyC2018 浅水印。
用法:
python3 tools/watermark.py notes/pics/a.png notes/pics/b.jpg
python3 tools/watermark.py --check notes/pics/*.png # 只检查,不修改
水印分两层:
- 整张图斜向平铺一层很淡的 “CyC2018” 文字,不影响阅读;
- 右下角加上与已有配图一致的浅灰色猫头图标和 “CyC2018”,右下角有内容时在底部补白边。
角标大小按图片宽度自动缩放,也可以用 --scale 指定。
加过水印的图片会在元数据中写入标记,再次执行时直接跳过,不会重复叠加。
GIF 动图不处理。依赖 Pillow。
"""
import argparse
import os
import sys
from PIL import Image, ImageDraw, ImageFont, PngImagePlugin
TEXT = "CyC2018"
MARK = "Watermark: CyC2018"
GRAY = (128, 128, 128)
ALPHA = 0.57 # 角标:白底上约为 #B7B7B7,与已有配图的水印深浅一致
TILE_ALPHA = 0.07 # 平铺:白底上约为 #F6F6F6,只在留意时可见
TILE_ANGLE = 30 # 平铺文字的倾斜角度
SS = 4 # 超采样倍数,用于抗锯齿
FONTS = [ # 等宽字体,与已有水印一致
"/System/Library/Fonts/Menlo.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
"C:/Windows/Fonts/consola.ttf",
]
def _font(size):
for path in FONTS:
if os.path.exists(path):
return ImageFont.truetype(path, size)
return ImageFont.load_default(size)
def _mark(scale):
"""生成右下角水印的 alpha 蒙版。"""
icon = round(24 * scale) * SS
font = _font(round(19 * scale) * SS)
left, top, right, bottom = font.getbbox(TEXT)
gap = round(8 * scale) * SS
w = icon + gap + right
h = max(icon, bottom - top)
mask = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(mask)
# 猫头图标:按 100×100 网格绘制后缩放
def p(x, y):
return (x * icon / 100, (h - icon) / 2 + y * icon / 100)
d.polygon([p(10, 50), p(14, 4), p(46, 26)], fill=255)
d.polygon([p(90, 50), p(86, 4), p(54, 26)], fill=255)
d.rounded_rectangle([p(6, 22), p(94, 96)], radius=34 * icon / 100, fill=255)
d.rounded_rectangle([p(18, 54), p(82, 88)], radius=16 * icon / 100, fill=0)
d.ellipse([p(31, 61), p(41, 79)], fill=255)
d.ellipse([p(59, 61), p(69, 79)], fill=255)
d.text((icon + gap, (h - (bottom - top)) / 2 - top), TEXT, font=font, fill=255)
return mask.resize((w // SS, h // SS), Image.LANCZOS)
def _tiles(size, scale):
"""生成整张图斜向平铺的文字蒙版。"""
W, H = size
font = _font(round(15 * scale))
right = font.getbbox(TEXT)[2]
dx, dy = round(right * 2.6), round(85 * scale)
side = int((W ** 2 + H ** 2) ** 0.5) + 2 * dx
big = Image.new("L", (side, side), 0)
d = ImageDraw.Draw(big)
for row, y in enumerate(range(0, side, dy)):
for x in range(-dx + (dx // 2) * (row % 2), side, dx):
d.text((x, y), TEXT, font=font, fill=255)
big = big.rotate(TILE_ANGLE, resample=Image.BICUBIC)
l, t = (side - W) // 2, (side - H) // 2
return big.crop((l, t, l + W, t + H))
def _blank(img, box):
"""判断区域内是否基本为空白。"""
region = img.crop(box).convert("L")
dark = sum(1 for v in region.getdata() if v < 235)
return dark <= region.width * region.height * 0.002
def add(img, scale=None):
"""返回加了水印的新图片。scale 默认按宽度计算,1000px 宽的图为 1。"""
if scale is None:
scale = min(max(img.width / 1000, 0.6), 2.0)
img = img.convert("RGBA") if img.mode in ("P", "LA", "RGBA") else img.convert("RGB")
mask = _mark(scale)
mx, my = round(12 * scale), round(6 * scale)
W, H = img.size
box = (W - mask.width - 2 * mx, H - mask.height - 2 * my, W, H)
if box[0] < 0 or not _blank(img, box):
bg = (255, 255, 255, 255) if img.mode == "RGBA" else (255, 255, 255)
padded = Image.new(img.mode, (W, H + mask.height + 2 * my), bg)
padded.paste(img, (0, 0))
img, H = padded, padded.height
layer = Image.new(img.mode, img.size, GRAY + ((255,) if img.mode == "RGBA" else ()))
img.paste(layer, (0, 0), _tiles(img.size, scale).point(lambda v: round(v * TILE_ALPHA)))
corner = Image.new("L", img.size, 0)
corner.paste(mask.point(lambda v: round(v * ALPHA)), (W - mask.width - mx, H - mask.height - my))
img.paste(layer, (0, 0), corner)
return img
def marked(img):
"""图片是否已经加过水印。"""
value = img.info.get("Watermark") or img.info.get("comment") or b""
if isinstance(value, bytes):
value = value.decode("utf-8", "ignore")
return "CyC2018" in value
def save(img, path):
"""保存图片并写入水印标记。"""
if path.lower().endswith(".png"):
info = PngImagePlugin.PngInfo()
info.add_text("Watermark", "CyC2018")
img.save(path, pnginfo=info, optimize=True)
else:
img.convert("RGB").save(path, quality=92, comment=MARK.encode())
def main():
ap = argparse.ArgumentParser(description="给配图加 CyC2018 浅水印")
ap.add_argument("images", nargs="+")
ap.add_argument("--scale", type=float, help="角标缩放,默认按图片宽度计算")
ap.add_argument("--check", action="store_true", help="只检查是否已加水印")
ap.add_argument("--mark-only", action="store_true", help="图片已有水印,只写入标记")
args = ap.parse_args()
missing = 0
for path in args.images:
if not path.lower().endswith((".png", ".jpg", ".jpeg")):
print("skip (not png/jpg)", path)
continue
img = Image.open(path)
if marked(img):
print("ok", path)
elif args.check:
print("missing watermark", path)
missing += 1
elif args.mark_only:
save(img, path)
print("marked", path)
else:
save(add(img, args.scale), path)
print("watermarked", path)
sys.exit(1 if missing else 0)
if __name__ == "__main__":
main()