Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: Test installation
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
concurrency:
group: installation-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
smoke-test:
name: Installation smoke test
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.12'
cache: pip
- run: python -m pip install -r requirements.txt
- run: python -m unittest discover -s tests -v
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.venv/
__pycache__/
*.py[cod]
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,19 @@ git clone git@github.com:rayalexvector/skill-mard.git ~/.hermes/skills/creative/

安装后,重启 Hermes,或在已有会话里使用 `/reset` 让 skill 生效。

如果缺少依赖,可以安装:
使用 Python 3.10 或更新版本,在 skill 目录创建独立环境并安装固定版本依赖:

```bash
pip install pillow pymupdf
cd ~/.hermes/skills/creative/mard-pindou-pattern
python3 -m venv .venv
. .venv/bin/activate
python -m pip install -r requirements.txt
# Linux 环境如果 PDF 中文标签字体缺失,可安装:
sudo apt install fonts-noto-cjk
```

运行 skill 时使用该环境的 Python;其他 agent 或自定义安装目录同样适用。脚本和色板均在仓库内,不需要复制到 `~/.hermes/scripts` 或 `~/.hermes/data`。

## 使用方式

在 Hermes 里发送触发语,例如:
Expand Down Expand Up @@ -68,19 +73,17 @@ Hermes 会按固定回复确认已准备好接收图片:

## 手动脚本用法

如果不通过 Hermes skill,也可以直接运行脚本:
如果不通过 Hermes skill,也可以直接运行脚本。以下命令在安装目录执行,并先激活上面的虚拟环境。脚本默认加载随包色板,从任意工作目录使用脚本绝对路径也能运行:

```bash
python scripts/mard221_printable_pattern.py input.jpg \
--palette references/mard221_palette.json \
--output-prefix /tmp/mard_pattern
```

指定精确尺寸:

```bash
python scripts/mard221_printable_pattern.py input.jpg \
--palette references/mard221_palette.json \
--size 80x80 \
--output-prefix /tmp/mard_pattern
```
Expand All @@ -89,7 +92,6 @@ python scripts/mard221_printable_pattern.py input.jpg \

```bash
python scripts/mard221_printable_pattern.py input.jpg \
--palette references/mard221_palette.json \
--long-side 80 \
--output-prefix /tmp/mard_pattern
```
Expand All @@ -98,7 +100,6 @@ python scripts/mard221_printable_pattern.py input.jpg \

```bash
python scripts/mard221_printable_pattern.py input.jpg \
--palette references/mard221_palette.json \
--size 80x80 \
--blank-white \
--output-prefix /tmp/mard_pattern_empty
Expand All @@ -113,20 +114,30 @@ python scripts/mard221_printable_pattern.py input.jpg \
/tmp/mard_pattern_counts.csv
```

自定义色板可显式传入 `--palette /absolute/path.json`,或设置 `MARD221_PALETTE` 环境变量;命令行参数优先。自定义路径错误会直接报错。

## 验证安装

```bash
python -m unittest discover -s tests -v
```

测试使用临时合成图片,在安装目录之外执行脚本,检查 PDF 分页、PNG、用量总数、白底模式及色板覆盖,不读取用户图片。

## PDF 转 PNG 分页

聊天平台不一定稳定接收 PDF;推荐把 PDF 每页转成 PNG 后分开发送:

```bash
python - <<'PY'
from pathlib import Path
import fitz
import pymupdf
pdf = Path('/tmp/mard_pattern.pdf')
outdir = Path('/tmp/mard_pattern_png_pages')
outdir.mkdir(exist_ok=True)
doc = fitz.open(str(pdf))
doc = pymupdf.open(str(pdf))
for i, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), alpha=False)
pix.save(str(outdir / f'mard_pattern_page_{i:02d}.png'))
PY
```
Expand All @@ -137,6 +148,8 @@ PY
SKILL.md
README.md
LICENSE
requirements.txt
tests/test_installation.py
scripts/mard221_printable_pattern.py
references/mard221_palette.json
```
Expand Down
65 changes: 18 additions & 47 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
name: mard-pindou-pattern
description: Use when the user says “准备拼豆图纸生成”, “准备做拼豆图纸”, “我要生成拼豆底稿”, or sends a bead-pattern generation request for MARD 221 colors. Generates printable perler/bead PDF patterns, previews, section color-code pages, and usage CSV from an image.
version: 1.0.0
author: Hermes Agent
license: MIT
metadata:
version: 1.0.0
author: Hermes Agent
hermes:
tags: [creative, mard, 拼豆, perler, beads, pdf, pillow]
related_skills: []
Expand All @@ -17,7 +17,7 @@ metadata:
This skill turns a user-supplied image into a printable MARD 221 拼豆图纸. It uses the local script:

```bash
~/.hermes/scripts/mard221_printable_pattern.py
"$SKILL_ROOT/scripts/mard221_printable_pattern.py"
```

The script uses Pillow to:
Expand All @@ -28,15 +28,9 @@ The script uses Pillow to:
4. Match every bead to the nearest MARD 221 standard RGB color.
5. Export a finished preview PNG, a printable PDF, section color-code pages, and a usage CSV.

The MARD 221 palette is saved at:
Resolve `SKILL_ROOT` to the absolute directory containing this `SKILL.md`, regardless of the host agent or current working directory. Run commands with the Python environment installed from `$SKILL_ROOT/requirements.txt` (see README). Do not assume scripts or data exist outside this skill directory.

- `~/.hermes/data/mard221_palette.json`
- `~/.hermes/data/mard221_palette.csv`

A portable copy is bundled in this skill folder:

- `references/mard221_palette.json`
- `scripts/mard221_printable_pattern.py`
The default palette is bundled at `references/mard221_palette.json`, resolved relative to the script, not the working directory. `--palette /absolute/path.json` or `MARD221_PALETTE` can explicitly override it; an invalid override fails instead of falling back to another user's data.

## Trigger / First Reply

Expand Down Expand Up @@ -69,11 +63,7 @@ The palette must contain exactly 221 unique color tags and fields at least:
- `hex`
- `rgb`

Primary runtime path:

```bash
~/.hermes/data/mard221_palette.json
```
Bundled runtime path: `$SKILL_ROOT/references/mard221_palette.json`.

### 2. Size Selection

Expand Down Expand Up @@ -143,13 +133,13 @@ PDF-to-PNG conversion recipe:
```bash
python - <<'PY'
from pathlib import Path
import fitz
import pymupdf
pdf = Path('/tmp/mard_x.pdf')
outdir = Path('/tmp/mard_x_png_pages')
outdir.mkdir(exist_ok=True)
doc = fitz.open(str(pdf))
doc = pymupdf.open(str(pdf))
for i, page in enumerate(doc, start=1):
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2), alpha=False)
pix = page.get_pixmap(matrix=pymupdf.Matrix(2, 2), alpha=False)
pix.save(str(outdir / f'mard_x_page_{i:02d}.png'))
PY
```
Expand All @@ -161,7 +151,7 @@ If the user says the result feels low-resolution or blurry, use the original ima
### Auto-size generation

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \
--output-prefix /tmp/mard_pattern
```

Expand All @@ -175,15 +165,15 @@ Outputs:
### Strict size

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \
--output-prefix /tmp/mard_pattern \
--size 80x80
```

### Strict width or height while preserving ratio

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \
--output-prefix /tmp/mard_pattern \
--width 60
```
Expand All @@ -193,7 +183,7 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
Only when explicitly requested:

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \
--output-prefix /tmp/mard_pattern \
--blank-white
```
Expand All @@ -203,7 +193,7 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
Default is 40×40. Use only when needed:

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" /path/to/input.png \
--output-prefix /tmp/mard_pattern \
--section-size 40
```
Expand All @@ -213,31 +203,12 @@ python ~/.hermes/scripts/mard221_printable_pattern.py /path/to/input.png \
1. Trigger phrase received → reply with the exact prepared message above. Do not change the user's trigger wording or the prepared reply wording.
2. Wait for the uploaded image. When the image arrives after this trigger, generate the pattern automatically immediately; do not wait for the user to say “生成” again. If the user first asks to describe an image and then says “就是这张图 / 按之前要求生成 / use this image”, treat the most recently uploaded/seen image as the input for this skill.
3. Inspect the user's trigger message and image message for explicit size or blank-white instructions.
4. Locate the image path. In WeChat/Hermes sessions, uploaded images are often cached under `~/.hermes/image_cache/` or profile-specific cache directories such as `~/.hermes/profiles/*/cache/images/`. If no direct path is provided, find the most recent plausible image and verify its dimensions/timestamp before using it:

```bash
python - <<'PY'
from pathlib import Path
from PIL import Image
import time
roots = [Path.home()/'.hermes/image_cache', Path.home()/'.hermes/profiles']
paths = []
for r in roots:
if r.exists():
paths += list(r.rglob('*.jpg')) + list(r.rglob('*.jpeg')) + list(r.rglob('*.png')) + list(r.rglob('*.webp'))
for p in sorted(paths, key=lambda p: p.stat().st_mtime, reverse=True)[:20]:
try:
im = Image.open(p)
print(time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(p.stat().st_mtime)), im.size, p)
except Exception:
pass
PY
```
4. Use the image path supplied by the current conversation or its attachment tool. Verify that it is the intended image. Do not search unrelated agent profiles or another application's caches; if the current attachment has no accessible path, ask for it to be attached again.

5. Run the generator with a safe unique prefix, e.g.:

```bash
python ~/.hermes/scripts/mard221_printable_pattern.py "$IMAGE_PATH" \
python "$SKILL_ROOT/scripts/mard221_printable_pattern.py" "$IMAGE_PATH" \
--output-prefix "/tmp/mard_$(date +%Y%m%d_%H%M%S)"
```

Expand All @@ -263,9 +234,9 @@ MEDIA:/tmp/mard_x_png_pages/mard_x_page_02.png

## Verification Checklist

- [ ] Palette JSON/CSV exist under `~/.hermes/data/`.
- [ ] Bundled palette JSON exists at `$SKILL_ROOT/references/mard221_palette.json`.
- [ ] Palette contains exactly 221 unique MARD tags.
- [ ] Script exists and compiles: `python -m py_compile ~/.hermes/scripts/mard221_printable_pattern.py`.
- [ ] Script exists and compiles: `python -m py_compile "$SKILL_ROOT/scripts/mard221_printable_pattern.py"`.
- [ ] A test run produces PDF, page1 PNG, preview PNG, and counts CSV.
- [ ] Default behavior treats white as beads.
- [ ] `--blank-white` is used only when explicitly requested.
3 changes: 3 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Python >=3.10; generator and PDF-to-PNG delivery dependencies
Pillow==12.3.0
PyMuPDF==1.28.2
19 changes: 5 additions & 14 deletions scripts/mard221_printable_pattern.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,11 @@
GridCell = Optional[str]

def default_palette_path() -> Path:
"""Find the installed MARD 221 palette across Hermes profile/home layouts."""
candidates = []
env = os.environ.get("MARD221_PALETTE")
if env:
candidates.append(Path(env).expanduser())
candidates.extend([
Path.home() / ".hermes" / "data" / "mard221_palette.json",
Path(__file__).resolve().parents[1] / "data" / "mard221_palette.json",
Path("/home/ubuntu/.hermes/data/mard221_palette.json"),
])
for p in candidates:
if p.exists():
return p
return candidates[0] if candidates else Path("mard221_palette.json")
"""Use an explicit override or the palette bundled beside this script."""
override = os.environ.get("MARD221_PALETTE")
if override:
return Path(override).expanduser()
return Path(__file__).resolve().parents[1] / "references" / "mard221_palette.json"


DEFAULT_PALETTE = default_palette_path()
Expand Down
75 changes: 75 additions & 0 deletions tests/test_installation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Exercise an installed copy from an unrelated directory using synthetic media."""
import csv
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

import pymupdf
from PIL import Image


class InstallationTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory(prefix="mard install ")
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
source = Path(__file__).resolve().parents[1]
self.installed = self.root / "custom skill location"
for folder in ("scripts", "references"):
shutil.copytree(source / folder, self.installed / folder)
self.work = self.root / "unrelated working directory"
self.work.mkdir()
self.env = os.environ.copy()
self.env.pop("MARD221_PALETTE", None)
self.input = self.work / "white.png"
Image.new("RGB", (12, 8), "white").save(self.input)

def run_generator(self, *args, success=True):
result = subprocess.run(
[sys.executable, str(self.installed / "scripts/mard221_printable_pattern.py"),
str(self.input), "--size", "12x8", "--section-size", "10",
"--output-prefix", str(self.work / "output/pattern"), *args],
cwd=self.work, env=self.env, capture_output=True, text=True, timeout=90,
)
if not success:
self.assertNotEqual(result.returncode, 0)
return result
self.assertEqual(result.returncode, 0, result.stderr)
return json.loads(result.stdout)

def test_default_palette_and_deliverables(self):
result = self.run_generator()
self.assertEqual((result["width"], result["height"], result["beads"]), ("12", "8", "96"))
with Path(result["counts"]).open(encoding="utf-8-sig", newline="") as stream:
counts = list(csv.DictReader(stream))
self.assertEqual(sum(int(row["count"]) for row in counts), 96)
for key in ("page1", "preview"):
with Image.open(result[key]) as image:
image.verify()
with pymupdf.open(result["pdf"]) as document:
self.assertEqual(document.page_count, 3) # Cover plus two sections.
for page in document:
png = page.get_pixmap(matrix=pymupdf.Matrix(0.25, 0.25)).tobytes("png")
self.assertTrue(png.startswith(b"\x89PNG\r\n\x1a\n"))

def test_blank_white_is_opt_in(self):
result = self.run_generator("--blank-white")
self.assertEqual(result["beads"], "0")
with pymupdf.open(result["pdf"]) as document:
self.assertEqual(document.page_count, 3)

def test_missing_override_fails_and_cli_takes_precedence(self):
self.env["MARD221_PALETTE"] = str(self.root / "missing.json")
failed = self.run_generator(success=False)
self.assertIn("FileNotFoundError", failed.stderr)
result = self.run_generator("--palette", str(self.installed / "references/mard221_palette.json"))
self.assertEqual(result["beads"], "96")


if __name__ == "__main__":
unittest.main()
Loading