# Course Ebook Publishing

> Use this skill ONLY AFTER a teaching site is feature-complete and stable, when the user wants to produce a printable / archivable / shareable version — typically a PDF ebook with cover, page numbers, table of contents, or a DOCX for editorial review. Triggers on phrases like "做電子書", "產 PDF", "ebook", "PDF ebook", "印給學員", "課程講義 PDF", "course handbook", "DOCX deliverable", "book-style layout", "build:ebook". Do NOT invoke during early site development — this is a downstream consumer that breaks if the site changes underneath. The skill produces PDF via Playwright `page.pdf()` and DOCX via pandoc, both from a single composed markdown source.

- Skill: `kevintsai1202/course-ebook-publishing` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kevintsai1202/course-ebook-publishing`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kevintsai1202/course-ebook-publishing/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: kevintsai1202 (https://skillmd.com/u/kevintsai1202)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/kevintsai1202/course-ebook-publishing

---


# Course Ebook Publishing

> **Schema authority**: this skill reads the live `window.COURSE` object whose shape is defined in [`_shared/domain-primitives.md`](../_shared/domain-primitives.md). Quiz / pre-test / post-test items are filtered OUT of the ebook (per §10 quiz item rules + ebook content policy).
>
> **Filename convention (English-first)**: outputs land in `dist/{name}.pdf` / `dist/{name}.docx`. Source markdown is composed under `dist/master.md`.

This skill turns a finished teaching website into a book — PDF (primary) and optionally DOCX (for editorial review or further authoring). It is a **post-site** step: it consumes `window.COURSE` from the live site, never re-authoring content.

## When to Invoke

- Site is feature-complete (Stages 1–5 done) and content is stable.
- Stakeholders ask for a printable hand-out, an archivable record, or a deliverable for non-web channels.
- Sometimes invoked after `course-corporate-edition` to produce a client deliverable bundle.

**Do NOT invoke** when site is mid-development. The ebook pipeline reads `window.COURSE`; if the site changes daily, the ebook drifts daily.

## Architecture: Single Source → Two Outputs

```
window.COURSE  ─┐
                ├─→ compose-ebook.mjs ─→ master.md ─→ pandoc ─→ ebook.docx
asset folders ─┤                                  └─→ Playwright page.pdf() ─→ ebook.pdf
materials/   ─┘
```

**Why a single composed markdown intermediate**: keeps PDF and DOCX content identical. Pandoc handles markdown → DOCX cleanly; Playwright renders the same markdown via an HTML wrapper → PDF. Two outputs, one source of truth.

## File Layout (Standard)

```
scripts/
├── build-ebook.mjs              ← CLI entry point (--md-only, --no-docx, --no-pdf, --output, --keep-html)
├── render-pdf.mjs               ← markdown → HTML → PDF via Playwright
└── lib/
    ├── compose-ebook.mjs        ← loadSources + composeXxx functions (cover, TOC, chapters, appendix)
    └── reference.docx           ← pandoc style template (generated by gen-reference-docx.mjs)

style-ebook.css                   ← @page rules, cover, page-number footer, print-only styles
dist/                             ← output: ebook.md, ebook.pdf, ebook.docx
```

## Loading Source Data: `window.COURSE` from index.html

When the site uses external `course-data.js`, just import it. When it uses **inlined** COURSE (corporate edition), extract via regex + vm sandbox:

```js
import vm from 'node:vm';
async function loadCourseFromIndexHtml(htmlPath) {
  const html = await fs.readFile(htmlPath, 'utf8');
  const match = html.match(/<script>\s*window\.COURSE\s*=\s*\{[\s\S]*?\};\s*<\/script>/);
  if (!match) throw new Error('window.COURSE 區塊找不到');
  const code = match[0].replace(/<\/?script>/g, '');
  const sandbox = { window: {}, console };
  vm.createContext(sandbox);
  vm.runInContext(code, sandbox);
  return sandbox.window.COURSE;
}
```

**Why vm sandbox** not `eval`: vm isolates the script's globals from the build process; an accidental side-effect can't leak.

## PDF Rendering: Playwright `page.pdf()` (not Chrome CLI)

Original implementation: `pandoc → HTML → msedge --headless --print-to-pdf`. **Switch to Playwright** — Chrome CLI doesn't support `footerTemplate` (no page numbers).

```js
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(`file://${path.resolve(htmlPath)}`, { waitUntil: 'networkidle' });
await page.pdf({
  path: outputPath,
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  headerTemplate: '<div></div>',
  footerTemplate: `
    <div style="font-size:9pt; width:100%; text-align:center; color:#666;">
      <span class="pageNumber"></span> / <span class="totalPages"></span>
    </div>`,
  margin: { top: '20mm', right: '20mm', bottom: '20mm', left: '20mm' },
});
await browser.close();
```

### Cover page without page number

The cover should be number-less. CSS:

```css
@page { margin: 20mm; }
@page :first { margin: 0; }    /* full bleed cover */
```

Playwright respects `@page :first` — the footer doesn't print on it.

## Book-Style Layout (style-ebook.css)

The example workshop's "book-grade typography" commit was driven by aesthetics, not just formality. Key rules worth porting:

```css
/* 章首跨頁分頁 */
h1.chapter { page-break-before: always; }

/* 圖文並排（Grid 排版） */
.figure-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 12mm;
}
.figure-grid .three-up { grid-template-columns: repeat(3, 1fr); }

/* 提示詞區塊：橘色 PROMPT 徽章 + 暗底 code */
.prompt-block {
  background: #1a1a1a; color: #e0e0e0;
  border-left: 4px solid #ff8c00;
  padding: 12pt; margin: 8pt 0;
}
.prompt-block::before {
  content: 'PROMPT'; display: inline-block;
  background: #ff8c00; color: white;
  padding: 2pt 8pt; border-radius: 3pt;
  font-size: 8pt; margin-bottom: 6pt;
}

/* 任務勾選方框（純印刷） */
.task::before {
  content: '☐'; margin-right: 8pt;
  font-size: 14pt; color: #888;
}

/* 學習目標綠色徽章 */
.goal::before {
  content: '✓'; margin-right: 6pt;
  color: #2e7d32; font-weight: bold;
}
```

## DOCX via pandoc + reference template

```js
const args = [
  mdPath,
  '-f', 'gfm+attributes+raw_html',
  '-t', 'docx',
  '--standalone',
  '--toc', '--toc-depth=2',
  '--resource-path', path.dirname(mdPath),
  '--reference-doc', 'scripts/lib/reference.docx',
  '-o', outPath,
];
spawn('pandoc', args);
```

**`reference.docx`** is a one-time-generated template with your chosen fonts (e.g. Microsoft JhengHei UI for Chinese), heading colors, paragraph spacing. Generate via `scripts/gen-reference-docx.mjs` — pandoc copies the styles.

**Caveat: raw HTML support in DOCX is partial.** `<figure-grid>` blocks won't render as grids in Word — they degrade to vertical stack. Use markdown `![alt](path)` syntax for images so they survive both formats.

## Compose Module Anatomy

`compose-ebook.mjs` exports one function per chapter type:

```js
export async function loadSources() { /* read COURSE + materials + assets */ }
export async function composeCover(meta) { /* big title, day list, meta card */ }
export async function composeOverview(meta) { /* TOC + course overview */ }
export async function composeChapter(day, assetIndex) { /* hero image + units */ }
export async function composeAppendixMaterials(materials) { /* full-text material appendix */ }
export async function composeAppendixSkills(skills) { /* QR-coded skill links */ }
```

Each returns a markdown string. `build-ebook.mjs` concatenates them with `\n\n---\n\n`.

## Asset Path Strategy

Inside the composed markdown, images use **paths relative to the markdown file** (`./assets/illustrations/foo.png`). Pandoc's `--resource-path` resolves them; Playwright's `file://` HTML loader does too. No absolute paths.

For corporate editions with asset fallback, resolve the actual file location at compose time and embed the resolved path:

```js
async function pickAsset(name, roots) {
  for (const root of roots) {
    const full = path.join(root, name);
    try { await fs.access(full); return path.relative(mdDir, full); }
    catch { continue; }
  }
  return null;  // caller decides whether to drop the image or fall back to placeholder
}
```

## What to Filter Out

Some content lives on the website but **should not be in the ebook**:

- **Quiz items**: leaking exam questions defeats the assessment. The example workshop filters out the entire quiz chapter.
- **Pre-test / post-test sections**: same reason.
- **Interactive-only features**: "點擊複製" buttons make no sense in print. Render the raw prompt text, not the button.
- **Dynamic illustrations**: anything generated client-side via canvas/JS won't render.

Add filter conditions to the compose functions; don't try to render-then-strip.

## Verification

After generating, verify:

```js
// scripts/verify-ebook.mjs
// - PDF file exists and is > 100KB
// - First page (cover) has no page number
// - TOC pages have entries
// - Every image reference resolved (no broken-image placeholders)
```

For DOCX, open in Word once and visually check: TOC linked, fonts applied, no missing-image squares.

## CLI Conventions

The example workshop's `build:ebook` script supports:

| Flag | Behavior |
|---|---|
| (no flag) | Build both PDF + DOCX |
| `--md-only` | Stop after composing markdown (debug content) |
| `--no-docx` | Skip DOCX (faster iteration on PDF) |
| `--no-pdf` | Skip PDF (faster iteration on DOCX) |
| `--keep-html` | Don't delete intermediate HTML (debug CSS) |
| `--output PATH` | Custom PDF output path |

Provide these from day one — you'll iterate the layout many times.

## Anti-Patterns

- **Re-authoring content in the ebook builder** — the ebook is a derivative. If you find yourself adding new prose in the compose functions, that prose belongs in `course-content-authoring` instead.
- **Using Chrome CLI for PDF** — switch to Playwright early; you'll want footers eventually.
- **Hardcoding image paths** — use `pickAsset()` with a list of roots, especially when supporting corporate edition fallbacks.
- **Forgetting `@page :first { margin: 0 }`** — cover ends up with a stray page number in the footer.
- **Re-running the ebook build every time the site changes** — only build at stable checkpoints; the intermediate `dist/` folder is large.

## Hand-off

When this skill finishes:
- `dist/{course-name}.pdf` and optionally `.docx` are produced.
- Verify scripts pass.
- If for a client, the file goes into the deliverable bundle.

Tell the user: "ebook ready. The site and ebook are now in sync; from now on, treat the site as canonical and rebuild the ebook only at release milestones — don't edit the ebook directly, it'll get overwritten."

