diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml index 091252b..3d44664 100644 --- a/.github/workflows/auto-release.yml +++ b/.github/workflows/auto-release.yml @@ -57,8 +57,12 @@ jobs: echo "TAG_NAME=${tag_name}" >> "$GITHUB_OUTPUT" echo "SAFE_TAG_NAME=${safe_tag_name}" >> "$GITHUB_OUTPUT" + - name: Prepare PDF sources + run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" + - name: Build PDF - run: mdpress build --format pdf --output docker_practice-${{ steps.tag.outputs.SAFE_TAG_NAME || 'latest' }}.pdf + working-directory: ${{ runner.temp }}/docker_practice-pdf-src + run: mdpress build --format pdf --output "${GITHUB_WORKSPACE}/docker_practice-${{ steps.tag.outputs.SAFE_TAG_NAME || 'latest' }}.pdf" - name: Create Release with PDF if: startsWith(github.ref, 'refs/tags/') diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d7666f6..d7b4916 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -40,8 +40,11 @@ jobs: tar xzf "$archive" sudo mv mdpress /usr/local/bin/ mdpress --version + - name: Prepare PDF sources + run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" - name: Build PDF - run: mdpress build --format pdf --output docker_practice.pdf + working-directory: ${{ runner.temp }}/docker_practice-pdf-src + run: mdpress build --format pdf --output "$GITHUB_WORKSPACE/docker_practice.pdf" - name: Build site run: npm run build - name: Upload PDF as artifact diff --git a/.github/workflows/preview-pdf.yml b/.github/workflows/preview-pdf.yml index 25c7e46..151ec1d 100644 --- a/.github/workflows/preview-pdf.yml +++ b/.github/workflows/preview-pdf.yml @@ -50,10 +50,14 @@ jobs: sudo mv /tmp/mdpress /usr/local/bin/ mdpress --version + - name: Prepare PDF sources + run: python3 tools/prepare_pdf_sources.py --book-dir . --out "$RUNNER_TEMP/docker_practice-pdf-src" + - name: Build latest preview PDF + working-directory: ${{ runner.temp }}/docker_practice-pdf-src run: | - mkdir -p dist - mdpress build --format pdf --output dist/docker_practice.pdf + mkdir -p "$GITHUB_WORKSPACE/dist" + mdpress build --format pdf --output "$GITHUB_WORKSPACE/dist/docker_practice.pdf" - name: Write release notes run: | diff --git a/package.json b/package.json index 97bcc89..a1a0bcd 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ "vuepress-theme-hope": "^1.0.0" }, "scripts": { - "test": "node scripts/check_metadata.js", + "test": "node scripts/check_metadata.js && python3 -m unittest tools.test_prepare_pdf_sources", "mdpress:help": "mdpress --help", "build": "rm -rf _site _site_site && mdpress build --format site --output _site && rm -rf _site && mv _site_site _site", "serve": "mdpress serve", "start": "mdpress serve", - "pdf": "mdpress build --format pdf", + "pdf": "rm -rf /tmp/docker_practice-pdf-src && python3 tools/prepare_pdf_sources.py --book-dir . --out /tmp/docker_practice-pdf-src && cd /tmp/docker_practice-pdf-src && mdpress build --format pdf --output \"${INIT_CWD:-$PWD}/docker_practice.pdf\"", "vuepress:build": "npx vuepress build", "vuepress": "npx vuepress" }, diff --git a/tools/prepare_pdf_sources.py b/tools/prepare_pdf_sources.py new file mode 100644 index 0000000..9c31ac7 --- /dev/null +++ b/tools/prepare_pdf_sources.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Prepare a temporary source tree for mdPress PDF builds. + +mdPress assembles a single HTML document before printing it to PDF. Image paths +that are correct relative to their Markdown file, such as ../_images/foo.png, +can become incorrect in that assembled document. This script rewrites Markdown +and HTML image paths in a temporary copy so PDF builds can embed local assets +without changing source Markdown used by GitHub or HTML readers. +""" + +import argparse +import base64 +import mimetypes +import posixpath +import re +import shutil +from dataclasses import dataclass +from pathlib import Path + + +MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)([^)]*)\)") +HTML_IMAGE_RE = re.compile(r'(]*\bsrc=)(["\'])([^"\']+)(\2)([^>]*>)', re.IGNORECASE) +SKIP_PREFIXES = ("http://", "https://", "/", "data:") + + +@dataclass(frozen=True) +class PrepareResult: + markdown_files: int + rewritten_paths: int + + +def _should_rewrite(url: str) -> bool: + return bool(url) and not url.startswith(SKIP_PREFIXES) + + +def _normalize_url(reldir: str, url: str, book_dir: Path | None = None) -> str: + normalized = posixpath.normpath(posixpath.join(reldir, url)) + if book_dir is None: + return normalized + target = (book_dir / normalized).resolve() + if not target.is_file(): + return target.as_uri() + media_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + data = base64.b64encode(target.read_bytes()).decode("ascii") + return f"data:{media_type};base64,{data}" + + +def normalize_markdown_asset_paths(text: str, reldir: str, book_dir: Path | None = None) -> tuple[str, int]: + rewrites = 0 + + def markdown_image(match: re.Match[str]) -> str: + nonlocal rewrites + alt, url, suffix = match.group(1), match.group(2).strip(), match.group(3) + if not _should_rewrite(url): + return match.group(0) + rewrites += 1 + return f"![{alt}]({_normalize_url(reldir, url, book_dir)}{suffix})" + + def html_image(match: re.Match[str]) -> str: + nonlocal rewrites + prefix, quote, src, closing_quote, suffix = match.groups() + if not _should_rewrite(src): + return match.group(0) + rewrites += 1 + return f"{prefix}{quote}{_normalize_url(reldir, src, book_dir)}{closing_quote}{suffix}" + + text = MARKDOWN_IMAGE_RE.sub(markdown_image, text) + text = HTML_IMAGE_RE.sub(html_image, text) + return text, rewrites + + +def _ignore_generated(_directory: str, names: list[str]) -> set[str]: + ignored = {".git", "node_modules", "_book", "_site", "_site_site", "output", ".DS_Store"} + return {name for name in names if name in ignored} + + +def prepare_pdf_sources(book_dir: Path, out_dir: Path) -> PrepareResult: + book_dir = book_dir.resolve() + out_dir = out_dir.resolve() + if out_dir.exists(): + raise FileExistsError(f"output directory already exists: {out_dir}") + + shutil.copytree(book_dir, out_dir, ignore=_ignore_generated) + + markdown_files = 0 + rewritten_paths = 0 + for path in out_dir.rglob("*.md"): + rel = path.relative_to(out_dir) + if rel.parts and rel.parts[0] in {"_book", "_site", "_site_site"}: + continue + if rel.parent == Path("."): + markdown_files += 1 + continue + markdown_files += 1 + original = path.read_text(encoding="utf-8") + normalized, count = normalize_markdown_asset_paths(original, rel.parent.as_posix(), out_dir) + if count: + path.write_text(normalized, encoding="utf-8") + rewritten_paths += count + + return PrepareResult(markdown_files=markdown_files, rewritten_paths=rewritten_paths) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--book-dir", type=Path, default=Path(".")) + parser.add_argument("--out", type=Path, required=True) + args = parser.parse_args() + + result = prepare_pdf_sources(args.book_dir, args.out) + print( + f"Prepared PDF sources at {args.out}: " + f"{result.markdown_files} Markdown files, {result.rewritten_paths} image paths rewritten." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/test_prepare_pdf_sources.py b/tools/test_prepare_pdf_sources.py new file mode 100644 index 0000000..e5bcbbf --- /dev/null +++ b/tools/test_prepare_pdf_sources.py @@ -0,0 +1,56 @@ +import tempfile +import unittest +from base64 import b64encode +from pathlib import Path + +from tools.prepare_pdf_sources import normalize_markdown_asset_paths, prepare_pdf_sources + + +class PreparePdfSourcesTest(unittest.TestCase): + def test_normalizes_relative_markdown_and_html_image_paths(self): + with tempfile.TemporaryDirectory() as tmp: + book_dir = Path(tmp) + (book_dir / "_images").mkdir() + (book_dir / "_images" / "virtualization.png").write_bytes(b"virtualization") + (book_dir / "_images" / "docker.png").write_bytes(b"docker") + text = "\n".join( + [ + "![传统虚拟化](../_images/virtualization.png)", + "![Remote](https://example.com/image.png)", + 'Docker', + ] + ) + + normalized, count = normalize_markdown_asset_paths(text, "01_introduction", book_dir) + virtualization_uri = "data:image/png;base64," + b64encode(b"virtualization").decode("ascii") + docker_uri = "data:image/png;base64," + b64encode(b"docker").decode("ascii") + + self.assertIn(f"![传统虚拟化]({virtualization_uri})", normalized) + self.assertIn("![Remote](https://example.com/image.png)", normalized) + self.assertIn(f'src="{docker_uri}"', normalized) + self.assertEqual(count, 2) + + def test_prepares_temp_tree_without_mutating_source_markdown(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + target = Path(tmp) / "target" + (source / "01_introduction").mkdir(parents=True) + (source / "_images").mkdir() + source_md = source / "01_introduction" / "1.2_what.md" + source_md.write_text("![Docker](../_images/docker.png)\n", encoding="utf-8") + (source / "_images" / "docker.png").write_bytes(b"png") + + result = prepare_pdf_sources(source, target) + + self.assertEqual(result.markdown_files, 1) + self.assertEqual(result.rewritten_paths, 1) + self.assertEqual(source_md.read_text(encoding="utf-8"), "![Docker](../_images/docker.png)\n") + self.assertEqual( + (target / "01_introduction" / "1.2_what.md").read_text(encoding="utf-8"), + "![Docker](data:image/png;base64,cG5n)\n", + ) + self.assertEqual((target / "_images" / "docker.png").read_bytes(), b"png") + + +if __name__ == "__main__": + unittest.main()