diff --git a/.github/workflows/watermark.yml b/.github/workflows/watermark.yml
new file mode 100644
index 00000000..b2f31e4b
--- /dev/null
+++ b/.github/workflows/watermark.yml
@@ -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
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..d7e77d5f
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -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
+
+```
+
+配图需要加 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
+```
diff --git a/README.md b/README.md
index d5764058..4919c72f 100644
--- a/README.md
+++ b/README.md
@@ -90,6 +90,8 @@
在线排版工具:[Text-Typesetting](https://github.com/CyC2018/Text-Typesetting)。
+提交 Issue、PR 以及添加配图的约定见 [贡献指南](CONTRIBUTING.md)。
+
### License
本仓库的内容不是将网上的资料随意拼凑而来,除了少部分引用书上和技术文档的原文(这部分内容都在末尾的参考链接中加了出处),其余都是我的原创。在您引用本仓库内容或者对内容进行修改演绎时,请署名并以相同方式共享,谢谢。
diff --git a/tools/diagram/build.py b/tools/diagram/build.py
new file mode 100644
index 00000000..8ccc0a49
--- /dev/null
+++ b/tools/diagram/build.py
@@ -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()
diff --git a/tools/hooks/pre-commit b/tools/hooks/pre-commit
new file mode 100755
index 00000000..17e85393
--- /dev/null
+++ b/tools/hooks/pre-commit
@@ -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
diff --git a/tools/watermark.py b/tools/watermark.py
new file mode 100644
index 00000000..6f34a360
--- /dev/null
+++ b/tools/watermark.py
@@ -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()