mirror of
https://github.com/tradecatlabs/vibe-coding-cn.git
synced 2026-08-24 00:08:06 +00:00
chore: migrate repository to standard knowledge base layout
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: markdown-to-epub
|
||||
description: "Markdown to EPUB build skill: normalize local image references, copy assets, call Calibre ebook-convert, inspect EPUB package structure, and report missing images. Use when turning Markdown manuscripts into reproducible EPUB files."
|
||||
---
|
||||
|
||||
# markdown-to-epub Skill
|
||||
|
||||
Use this skill to build a reproducible EPUB from Markdown manuscripts with local image assets, without mutating the source manuscript.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Trigger when any of these applies:
|
||||
- Converting one or more Markdown files into an EPUB deliverable.
|
||||
- Normalizing broken or inconsistent image references before conversion.
|
||||
- Recovering local assets with unreliable extensions such as `.bin` or `.idunno`.
|
||||
- Running Calibre `ebook-convert` non-interactively from a repeatable build directory.
|
||||
- Checking the resulting EPUB archive for OPF, NCX/NAV, and image inclusion.
|
||||
|
||||
## Not For / Boundaries
|
||||
|
||||
- Not for authoring, rewriting, proofreading, or typesetting the manuscript body.
|
||||
- Not for downloading remote `http(s)` or `data:` images; remote references are preserved unless the user supplies local replacements.
|
||||
- Not a substitute for full EPUB QA in dedicated readers; it performs structural and asset checks only.
|
||||
- Required inputs: source Markdown path, desired EPUB path/title/authors/language, source root, and any fallback asset map.
|
||||
- If Calibre is unavailable, fail clearly and provide the install/`--ebook-convert-bin` verification path instead of producing a fake EPUB.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Patterns
|
||||
|
||||
**Build a basic EPUB**
|
||||
```bash
|
||||
python3 skills/markdown-to-epub/scripts/build_epub.py \
|
||||
--input-md "./book.md" \
|
||||
--output-epub "./book.epub" \
|
||||
--title "Book Title" \
|
||||
--authors "Author Name" \
|
||||
--language "zh-CN"
|
||||
```
|
||||
|
||||
**Use a custom source root and build directory**
|
||||
```bash
|
||||
python3 skills/markdown-to-epub/scripts/build_epub.py \
|
||||
--input-md "./manuscript/book.md" \
|
||||
--source-root "./manuscript" \
|
||||
--build-dir "./build/book-epub" \
|
||||
--output-epub "./dist/book.epub"
|
||||
```
|
||||
|
||||
**Recover missing assets with a fallback map**
|
||||
```bash
|
||||
python3 skills/markdown-to-epub/scripts/build_epub.py \
|
||||
--input-md "./book.md" \
|
||||
--output-epub "./book.epub" \
|
||||
--fallback-map "./fallback-map.json"
|
||||
```
|
||||
|
||||
**Allow unresolved local images but report them**
|
||||
```bash
|
||||
python3 skills/markdown-to-epub/scripts/build_epub.py \
|
||||
--input-md "./book.md" \
|
||||
--output-epub "./book.epub" \
|
||||
--no-strict-missing
|
||||
```
|
||||
|
||||
**Point to a non-standard Calibre binary**
|
||||
```bash
|
||||
python3 skills/markdown-to-epub/scripts/build_epub.py \
|
||||
--input-md "./book.md" \
|
||||
--ebook-convert-bin "/opt/calibre/ebook-convert"
|
||||
```
|
||||
|
||||
**Inspect the generated package**
|
||||
```bash
|
||||
unzip -l ./book.epub | rg 'content.opf|toc.ncx|nav.xhtml|\\.(png|jpg|jpeg|webp|gif)$'
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Clean Manuscript Build
|
||||
|
||||
- Input: `book.md` with valid local images and metadata title/author/language.
|
||||
- Steps:
|
||||
1. Run the basic build command.
|
||||
2. Inspect `build_epub/report.json`.
|
||||
3. Check the EPUB zip listing for OPF and navigation files.
|
||||
- Expected output / acceptance: `book.epub` exists, `missing_images` is empty, and package structure contains OPF plus NCX or NAV.
|
||||
|
||||
### Example 2: Extension Recovery
|
||||
|
||||
- Input: Markdown references `images/cover.idunno`, but the file signature is a PNG.
|
||||
- Steps:
|
||||
1. Run the build script in strict mode.
|
||||
2. Confirm copied assets in `build_epub/assets/` use normalized extensions.
|
||||
3. Rebuild after fixing any missing file mapping.
|
||||
- Expected output / acceptance: EPUB includes the normalized image and the report records no unresolved local image.
|
||||
|
||||
### Example 3: Missing Asset Triage
|
||||
|
||||
- Input: manuscript references old file names that no longer exist.
|
||||
- Steps:
|
||||
1. Create a JSON fallback map from missing basenames to replacement basenames.
|
||||
2. Re-run with `--fallback-map`.
|
||||
3. Keep strict mode enabled so unmapped missing assets fail the build.
|
||||
- Expected output / acceptance: every missing local image is either resolved by the map or listed in `report.json` for explicit follow-up.
|
||||
|
||||
## References
|
||||
|
||||
- `references/index.md`: navigation, script contract, and validation notes.
|
||||
- `scripts/build_epub.py`: executable builder used by this skill.
|
||||
- `agents/openai.yaml`: agent metadata for this skill package.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Sources: local script implementation and EPUB/Calibre behavior observed by the build report.
|
||||
- Last updated: 2026-04-28
|
||||
- Known limits: structural checks do not guarantee visual fidelity in every EPUB reader; run reader-specific QA for final publication.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Markdown → EPUB 构建器"
|
||||
short_description: "把 Markdown 手稿 + 本地图片资产转换为可校验的 EPUB。"
|
||||
default_prompt: "使用 $markdown-to-epub 把我的 Markdown 手稿和本地图片资产转换成可校验的 EPUB 文件。"
|
||||
@@ -0,0 +1,27 @@
|
||||
# markdown-to-epub Reference Index
|
||||
|
||||
This directory keeps long-form notes for the Markdown to EPUB skill. The entrypoint stays in `../SKILL.md`; operational behavior is implemented by `../scripts/build_epub.py`.
|
||||
|
||||
## Navigation
|
||||
|
||||
- `../SKILL.md`: triggers, boundaries, quick commands, and examples.
|
||||
- `../scripts/build_epub.py`: source of truth for CLI arguments, asset normalization, Calibre invocation, and EPUB inspection.
|
||||
- `../agents/openai.yaml`: optional agent metadata.
|
||||
|
||||
## Script Contract
|
||||
|
||||
- Source Markdown is read-only.
|
||||
- Build artifacts are written to `--build-dir`.
|
||||
- Local images are copied into the build workspace and normalized when the file signature proves a better extension.
|
||||
- Remote `http(s)` and `data:` image references are not downloaded.
|
||||
- Strict mode fails when local image references cannot be resolved.
|
||||
|
||||
## Verification
|
||||
|
||||
Run the builder, then inspect:
|
||||
|
||||
```bash
|
||||
unzip -l ./book.epub | rg 'content.opf|toc.ncx|nav.xhtml|\\.(png|jpg|jpeg|webp|gif)$'
|
||||
```
|
||||
|
||||
Also inspect `report.json` in the build directory for `missing_images`, copied assets, and conversion status.
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build a robust EPUB from Markdown with local image assets.
|
||||
|
||||
Features:
|
||||
- Normalize Markdown image references into build_dir/assets
|
||||
- Detect real image extensions from file signatures (.png/.jpg/.gif/.webp/.svg)
|
||||
- Optionally resolve missing files via fallback JSON map
|
||||
- Convert using Calibre ebook-convert
|
||||
- Emit conversion report JSON for verification
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha1
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
IMAGE_PATTERN = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
|
||||
REMOTE_PREFIXES = ("http://", "https://", "data:")
|
||||
VALID_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"}
|
||||
|
||||
|
||||
@dataclass
|
||||
class RewriteResult:
|
||||
normalized_markdown: Path
|
||||
assets_dir: Path
|
||||
total_refs: int
|
||||
rewritten_refs: int
|
||||
copied_assets: int
|
||||
missing_images: List[str]
|
||||
|
||||
|
||||
def detect_extension(file_path: Path, data: bytes) -> str:
|
||||
lower_name = file_path.name.lower()
|
||||
if lower_name.endswith(".svg"):
|
||||
return ".svg"
|
||||
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return ".png"
|
||||
if data.startswith(b"\xff\xd8\xff"):
|
||||
return ".jpg"
|
||||
if data.startswith(b"GIF87a") or data.startswith(b"GIF89a"):
|
||||
return ".gif"
|
||||
if data.startswith(b"RIFF") and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||
return ".webp"
|
||||
if data.startswith(b"BM"):
|
||||
return ".bmp"
|
||||
current_ext = file_path.suffix.lower()
|
||||
if current_ext in VALID_IMAGE_EXTS:
|
||||
return current_ext
|
||||
return ".bin"
|
||||
|
||||
|
||||
def decode_reference(reference: str) -> str:
|
||||
return urllib.parse.unquote(reference.strip())
|
||||
|
||||
|
||||
def resolve_source_file(
|
||||
source_root: Path,
|
||||
decoded_ref: str,
|
||||
fallback_map: Dict[str, str],
|
||||
) -> Tuple[Optional[Path], str]:
|
||||
decoded_ref = decoded_ref.replace("\\", "/")
|
||||
basename = Path(decoded_ref).name
|
||||
candidates = []
|
||||
|
||||
# Keep relative path when possible.
|
||||
rel_path = Path(decoded_ref)
|
||||
if not rel_path.is_absolute():
|
||||
candidates.append((source_root / rel_path).resolve())
|
||||
|
||||
# Common exported markdown style: "<folder>/<asset>"
|
||||
if "/" in decoded_ref:
|
||||
candidates.append((source_root / basename).resolve())
|
||||
|
||||
# Direct basename fallback.
|
||||
candidates.append((source_root / basename).resolve())
|
||||
|
||||
checked = set()
|
||||
for candidate in candidates:
|
||||
key = str(candidate).lower()
|
||||
if key in checked:
|
||||
continue
|
||||
checked.add(key)
|
||||
if candidate.exists() and candidate.is_file():
|
||||
return candidate, basename
|
||||
|
||||
fallback_name = fallback_map.get(basename)
|
||||
if fallback_name:
|
||||
fallback_candidate = (source_root / fallback_name).resolve()
|
||||
if fallback_candidate.exists() and fallback_candidate.is_file():
|
||||
return fallback_candidate, basename
|
||||
|
||||
return None, basename
|
||||
|
||||
|
||||
def rewrite_markdown_and_copy_assets(
|
||||
input_md: Path,
|
||||
source_root: Path,
|
||||
build_dir: Path,
|
||||
input_encoding: str,
|
||||
fallback_map: Dict[str, str],
|
||||
strict_missing: bool,
|
||||
) -> RewriteResult:
|
||||
assets_dir = build_dir / "assets"
|
||||
assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
text = input_md.read_text(encoding=input_encoding)
|
||||
copied_name_by_source: Dict[str, str] = {}
|
||||
missing_images: List[str] = []
|
||||
total_refs = 0
|
||||
rewritten_refs = 0
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
nonlocal total_refs, rewritten_refs
|
||||
total_refs += 1
|
||||
alt_text = match.group(1)
|
||||
original_ref = match.group(2).strip()
|
||||
|
||||
if original_ref.lower().startswith(REMOTE_PREFIXES):
|
||||
return match.group(0)
|
||||
|
||||
decoded = decode_reference(original_ref)
|
||||
source_file, missing_name = resolve_source_file(source_root, decoded, fallback_map)
|
||||
if source_file is None:
|
||||
missing_images.append(missing_name)
|
||||
return match.group(0)
|
||||
|
||||
source_key = str(source_file.resolve()).lower()
|
||||
if source_key in copied_name_by_source:
|
||||
target_name = copied_name_by_source[source_key]
|
||||
else:
|
||||
data = source_file.read_bytes()
|
||||
ext = detect_extension(source_file, data)
|
||||
target_name = f"{source_file.stem}{ext}"
|
||||
target_path = assets_dir / target_name
|
||||
if target_path.exists():
|
||||
existing_data = target_path.read_bytes()
|
||||
if existing_data != data:
|
||||
digest = sha1(data).hexdigest()[:8]
|
||||
target_name = f"{source_file.stem}-{digest}{ext}"
|
||||
target_path = assets_dir / target_name
|
||||
target_path.write_bytes(data)
|
||||
copied_name_by_source[source_key] = target_name
|
||||
|
||||
rewritten_refs += 1
|
||||
return f""
|
||||
|
||||
rewritten = IMAGE_PATTERN.sub(replace, text)
|
||||
normalized_md = build_dir / "book.normalized.md"
|
||||
normalized_md.write_text(rewritten, encoding="utf-8")
|
||||
|
||||
unique_missing = sorted(set(missing_images))
|
||||
if strict_missing and unique_missing:
|
||||
msg = (
|
||||
"Missing local image files detected. "
|
||||
f"Count={len(unique_missing)}; examples={unique_missing[:10]}"
|
||||
)
|
||||
raise FileNotFoundError(msg)
|
||||
|
||||
return RewriteResult(
|
||||
normalized_markdown=normalized_md,
|
||||
assets_dir=assets_dir,
|
||||
total_refs=total_refs,
|
||||
rewritten_refs=rewritten_refs,
|
||||
copied_assets=len(copied_name_by_source),
|
||||
missing_images=unique_missing,
|
||||
)
|
||||
|
||||
|
||||
def run_ebook_convert(
|
||||
ebook_convert_bin: str,
|
||||
normalized_md: Path,
|
||||
output_epub: Path,
|
||||
title: Optional[str],
|
||||
authors: Optional[str],
|
||||
language: Optional[str],
|
||||
input_encoding: str,
|
||||
conversion_log: Path,
|
||||
) -> None:
|
||||
cmd = [
|
||||
ebook_convert_bin,
|
||||
str(normalized_md),
|
||||
str(output_epub),
|
||||
"--input-encoding",
|
||||
input_encoding,
|
||||
"--level1-toc",
|
||||
"//h:h1",
|
||||
"--level2-toc",
|
||||
"//h:h2",
|
||||
"--level3-toc",
|
||||
"//h:h3",
|
||||
]
|
||||
|
||||
if title:
|
||||
cmd.extend(["--title", title])
|
||||
if authors:
|
||||
cmd.extend(["--authors", authors])
|
||||
if language:
|
||||
cmd.extend(["--language", language])
|
||||
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||
conversion_log.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
f"COMMAND: {' '.join(cmd)}",
|
||||
"",
|
||||
"STDOUT:",
|
||||
proc.stdout,
|
||||
"",
|
||||
"STDERR:",
|
||||
proc.stderr,
|
||||
"",
|
||||
f"EXIT_CODE: {proc.returncode}",
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"ebook-convert failed with exit code {proc.returncode}")
|
||||
|
||||
|
||||
def inspect_epub(epub_file: Path) -> Dict[str, object]:
|
||||
if not epub_file.exists():
|
||||
raise FileNotFoundError(f"EPUB not found: {epub_file}")
|
||||
|
||||
with zipfile.ZipFile(epub_file) as zf:
|
||||
names = zf.namelist()
|
||||
image_files = [
|
||||
n for n in names if re.search(r"\.(png|jpg|jpeg|gif|svg|webp|bmp)$", n, flags=re.IGNORECASE)
|
||||
]
|
||||
has_opf = any(n.lower().endswith(".opf") for n in names)
|
||||
has_ncx_or_nav = any(n.lower().endswith(".ncx") or "nav" in n.lower() for n in names)
|
||||
nav_points = 0
|
||||
for name in names:
|
||||
if name.lower().endswith(".ncx"):
|
||||
content = zf.read(name).decode("utf-8", errors="ignore")
|
||||
nav_points = len(re.findall(r"<navPoint\b", content))
|
||||
break
|
||||
|
||||
return {
|
||||
"file_size": epub_file.stat().st_size,
|
||||
"total_files": len(names),
|
||||
"image_files": len(image_files),
|
||||
"has_opf": has_opf,
|
||||
"has_ncx_or_nav": has_ncx_or_nav,
|
||||
"ncx_nav_points": nav_points,
|
||||
}
|
||||
|
||||
|
||||
def load_fallback_map(path: Optional[Path]) -> Dict[str, str]:
|
||||
if path is None:
|
||||
return {}
|
||||
content = path.read_text(encoding="utf-8-sig")
|
||||
raw = json.loads(content)
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("--fallback-map must be a JSON object")
|
||||
output: Dict[str, str] = {}
|
||||
for key, value in raw.items():
|
||||
if isinstance(key, str) and isinstance(value, str):
|
||||
output[key] = value
|
||||
return output
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="从 Markdown 与本地图片资产构建 EPUB。")
|
||||
parser.add_argument("--input-md", required=True, type=Path, help="源 Markdown 路径。")
|
||||
parser.add_argument(
|
||||
"--output-epub",
|
||||
type=Path,
|
||||
help="输出 EPUB 路径。默认:当前目录下的 <input-stem>.epub。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--source-root",
|
||||
type=Path,
|
||||
help="解析图片引用的根目录。默认:Markdown 所在目录。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-dir",
|
||||
type=Path,
|
||||
default=Path.cwd() / "build_epub",
|
||||
help="构建工作区目录(规范化 Markdown / assets / 日志 / 报告)。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fallback-map",
|
||||
type=Path,
|
||||
help="JSON 映射:缺失图片 basename → 替换 basename。",
|
||||
)
|
||||
parser.add_argument("--title", help="EPUB 标题元数据。")
|
||||
parser.add_argument("--authors", help="EPUB 作者元数据。")
|
||||
parser.add_argument("--language", default="zh-CN", help="EPUB 语言元数据。")
|
||||
parser.add_argument("--input-encoding", default="utf-8", help="输入 Markdown 编码。")
|
||||
parser.add_argument("--ebook-convert-bin", default="ebook-convert", help="ebook-convert 可执行文件名/路径。")
|
||||
parser.add_argument(
|
||||
"--strict-missing",
|
||||
action="store_true",
|
||||
default=True,
|
||||
help="严格模式:任何本地图片无法解析则失败(默认开启)。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-strict-missing",
|
||||
action="store_false",
|
||||
dest="strict_missing",
|
||||
help="关闭严格模式:即使存在未解析的本地图片引用也继续转换。",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--clean-build-dir",
|
||||
action="store_true",
|
||||
help="转换前清空 build-dir。",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
input_md = args.input_md.resolve()
|
||||
if not input_md.exists():
|
||||
raise FileNotFoundError(f"Markdown not found: {input_md}")
|
||||
|
||||
output_epub = (
|
||||
args.output_epub.resolve()
|
||||
if args.output_epub
|
||||
else (Path.cwd() / f"{input_md.stem}.epub").resolve()
|
||||
)
|
||||
source_root = args.source_root.resolve() if args.source_root else input_md.parent.resolve()
|
||||
build_dir = args.build_dir.resolve()
|
||||
|
||||
if args.clean_build_dir and build_dir.exists():
|
||||
shutil.rmtree(build_dir)
|
||||
build_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fallback_map = load_fallback_map(args.fallback_map.resolve() if args.fallback_map else None)
|
||||
|
||||
rewrite_result = rewrite_markdown_and_copy_assets(
|
||||
input_md=input_md,
|
||||
source_root=source_root,
|
||||
build_dir=build_dir,
|
||||
input_encoding=args.input_encoding,
|
||||
fallback_map=fallback_map,
|
||||
strict_missing=args.strict_missing,
|
||||
)
|
||||
|
||||
conversion_log = build_dir / "conversion.log"
|
||||
run_ebook_convert(
|
||||
ebook_convert_bin=args.ebook_convert_bin,
|
||||
normalized_md=rewrite_result.normalized_markdown,
|
||||
output_epub=output_epub,
|
||||
title=args.title,
|
||||
authors=args.authors,
|
||||
language=args.language,
|
||||
input_encoding="utf-8",
|
||||
conversion_log=conversion_log,
|
||||
)
|
||||
|
||||
epub_info = inspect_epub(output_epub)
|
||||
report = {
|
||||
"input_markdown": str(input_md),
|
||||
"output_epub": str(output_epub),
|
||||
"build_dir": str(build_dir),
|
||||
"total_image_refs": rewrite_result.total_refs,
|
||||
"rewritten_image_refs": rewrite_result.rewritten_refs,
|
||||
"copied_assets": rewrite_result.copied_assets,
|
||||
"missing_images": rewrite_result.missing_images,
|
||||
"epub": epub_info,
|
||||
}
|
||||
report_path = build_dir / "report.json"
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Exception as exc: # pragma: no cover
|
||||
print(f"错误:{exc}", file=sys.stderr)
|
||||
raise
|
||||
Reference in New Issue
Block a user