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"}{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(
+ [
+ "",
+ "",
+ '
',
+ ]
+ )
+
+ 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"", normalized)
+ self.assertIn("", 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("\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"), "\n")
+ self.assertEqual(
+ (target / "01_introduction" / "1.2_what.md").read_text(encoding="utf-8"),
+ "\n",
+ )
+ self.assertEqual((target / "_images" / "docker.png").read_bytes(), b"png")
+
+
+if __name__ == "__main__":
+ unittest.main()