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
36 changes: 18 additions & 18 deletions course/.vitepress/epub/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ bun run epub # 生成 books/zig-course.epub

## 特性

| 能力 | 说明 |
| --- | --- |
| 章节顺序 | 直接复用 `course/.vitepress/sidebar.ts`,与网站目录保持一致 |
| 代码高亮 | 使用 [Shiki](https://shiki.style/),与网站同款主题,支持 Zig |
| 代码片段导入 | 支持 `<<<@/code/xxx.zig#region` 语法(含 region 提取、行内 label) |
| 容器语法 | `::: info / tip / warning / danger / details` 与 GitHub 警告块 `> [!TIP]` 均转为带样式的提示框 |
| 自定义字体 | 与 PDF 同方案:**思源宋体**(中文)+ **Inter**(正文英文,无衬线)+ **JetBrains Mono**(代码);均取自 Google Fonts 的 glyf 可变字体,按全书字符即时子集 + 钉轴(woff2) |
| 图片嵌入 | 本地 / 远程图片全部内嵌;SVG、WebP 统一栅格化为 PNG(兼容所有阅读器) |
| 站内跳转 | md 间链接重写为电子书内部章节跳转,页内/跨章锚点自动校验,**不会跳到网页** |
| 规范校验 | 通过官方 EPUBCheck 5.1.0:0 错误 / 0 警告 |
| 能力 | 说明 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 章节顺序 | 直接复用 `course/.vitepress/sidebar.ts`,与网站目录保持一致 |
| 代码高亮 | 使用 [Shiki](https://shiki.style/),与网站同款主题,支持 Zig |
| 代码片段导入 | 支持 `<<<@/code/xxx.zig#region` 语法(含 region 提取、行内 label) |
| 容器语法 | `::: info / tip / warning / danger / details` 与 GitHub 警告块 `> [!TIP]` 均转为带样式的提示框 |
| 自定义字体 | 与 PDF 同方案:**思源宋体**(中文)+ **Inter**(正文英文,无衬线)+ **JetBrains Mono**(代码);均取自 Google Fonts 的 glyf 可变字体,按全书字符即时子集 + 钉轴(woff2) |
| 图片嵌入 | 本地 / 远程图片全部内嵌;SVG、WebP 统一栅格化为 PNG(兼容所有阅读器) |
| 站内跳转 | md 间链接重写为电子书内部章节跳转,页内/跨章锚点自动校验,**不会跳到网页** |
| 规范校验 | 通过官方 EPUBCheck 5.1.0:0 错误 / 0 警告 |

## 目录结构

Expand Down Expand Up @@ -54,11 +54,11 @@ course/.vitepress/epub/

## 依赖

| 包 | 用途 |
| --- | --- |
| `markdown-it` | Markdown 渲染 |
| `shiki` | 代码高亮 |
| `jszip` | EPUB 打包 |
| `subset-font` | 字体子集化 + 钉轴 |
| `@resvg/resvg-js` | SVG 栅格化为 PNG |
| `sharp` | 位图(WebP/JPG/GIF)转 PNG |
| 包 | 用途 |
| ----------------- | -------------------------- |
| `markdown-it` | Markdown 渲染 |
| `shiki` | 代码高亮 |
| `jszip` | EPUB 打包 |
| `subset-font` | 字体子集化 + 钉轴 |
| `@resvg/resvg-js` | SVG 栅格化为 PNG |
| `sharp` | 位图(WebP/JPG/GIF)转 PNG |
2 changes: 1 addition & 1 deletion course/.vitepress/epub/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ async function main() {
const usedText = collectUsedText(rendered);
const fonts = await prepareFonts(config, usedText);
console.log(
`[epub] 字体子集化完成:中文 ${(fonts.cjk.length / 1024) | 0}KB,英文 ${(fonts.sans.length / 1024) | 0}KB,等宽 ${(fonts.mono.length / 1024) | 0}KB`,
`[epub] 字体子集化完成:中文 ${(fonts.cjk.length / 1024) | 0}KB(粗体 ${(fonts.cjkBold.length / 1024) | 0}KB),英文 ${(fonts.sans.length / 1024) | 0}KB(粗体 ${(fonts.sansBold.length / 1024) | 0}KB),等宽 ${(fonts.mono.length / 1024) | 0}KB`,
);

// 7. 封面
Expand Down
22 changes: 19 additions & 3 deletions course/.vitepress/epub/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface EpubConfig {
courseDir: string;
/** 输出 epub 文件的绝对路径 */
outFile: string;
/** 封面图相对 courseDir 的路径(位于 public 下) */
/** 封面图相对 courseDir 的路径 */
coverImage: string;
/** Shiki 主题 */
shikiTheme: string;
Expand All @@ -27,8 +27,12 @@ export interface EpubConfig {
fonts: {
/** 中文正文字体 */
cjk: FontSpec;
/** 正文英文/数字字体(无衬线) */
/** 中文粗体(wght:700,用于标题/加粗,避免依赖阅读器伪粗体) */
cjkBold: FontSpec;
/** 正文英文/数字字体(无衡线) */
sans: FontSpec;
/** 英文粗体(wght:700) */
sansBold: FontSpec;
/** 代码等宽字体 */
mono: FontSpec;
};
Expand All @@ -54,7 +58,7 @@ export const config: EpubConfig = {
language: "zh-CN",
courseDir: COURSE_DIR,
outFile: path.resolve(COURSE_DIR, "..", "books", "zig-course.epub"),
coverImage: "public/cover_image.png",
coverImage: ".vitepress/epub/cover.png",
shikiTheme: "github-light",
shikiLangs: [
"zig",
Expand All @@ -81,12 +85,24 @@ export const config: EpubConfig = {
fileName: "NotoSerifSC.ttf",
axes: { wght: 400 },
},
cjkBold: {
family: "Noto Serif SC",
url: "https://github.com/google/fonts/raw/main/ofl/notoserifsc/NotoSerifSC%5Bwght%5D.ttf",
fileName: "NotoSerifSC.ttf",
axes: { wght: 700 },
},
sans: {
family: "Inter",
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
fileName: "Inter.ttf",
axes: { wght: 400, opsz: 14 },
},
sansBold: {
family: "Inter",
url: "https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf",
fileName: "Inter.ttf",
axes: { wght: 700, opsz: 14 },
},
mono: {
family: "JetBrains Mono",
url: "https://github.com/google/fonts/raw/main/ofl/jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf",
Expand Down
Binary file added course/.vitepress/epub/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 25 additions & 2 deletions course/.vitepress/epub/fonts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ async function download(url: string, cachePath: string): Promise<Buffer> {
export interface SubsetFonts {
/** 子集化后的中文正文字体(woff2) */
cjk: Uint8Array;
/** 子集化后的中文粗体(woff2) */
cjkBold: Uint8Array;
/** 子集化后的英文正文字体(woff2) */
sans: Uint8Array;
/** 子集化后的英文粗体(woff2) */
sansBold: Uint8Array;
/** 子集化后的代码等宽字体(woff2) */
mono: Uint8Array;
}
Expand Down Expand Up @@ -51,11 +55,30 @@ export async function prepareFonts(
" !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`" +
"abcdefghijklmnopqrstuvwxyz{|}~";

const [cjk, sans, mono] = await Promise.all([
// 先并行预热下载唯一的原始字体文件(按 fileName 去重),填充磁盘缓存;
// 否则随后并行子集化时,共享同一 fileName 的 normal/bold 会并发写缓存产生竞态。
const specs = [
config.fonts.cjk,
config.fonts.cjkBold,
config.fonts.sans,
config.fonts.sansBold,
config.fonts.mono,
];
const uniqueByFile = new Map(specs.map((s) => [s.fileName, s]));
await Promise.all(
[...uniqueByFile.values()].map((s) =>
download(s.url, path.join(config.cacheDir, s.fileName)),
),
);

// 缓存已就绪,buildOne 内的 download 均命中缓存(只读),可安全并行子集化
const [cjk, cjkBold, sans, sansBold, mono] = await Promise.all([
buildOne(config.fonts.cjk, usedText, config.cacheDir),
buildOne(config.fonts.cjkBold, usedText, config.cacheDir),
buildOne(config.fonts.sans, usedText, config.cacheDir),
buildOne(config.fonts.sansBold, usedText, config.cacheDir),
buildOne(config.fonts.mono, asciiText + usedText, config.cacheDir),
]);

return { cjk, sans, mono };
return { cjk, cjkBold, sans, sansBold, mono };
}
4 changes: 3 additions & 1 deletion course/.vitepress/epub/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,5 +74,7 @@ export async function toPng(raw: Buffer): Promise<Uint8Array> {
const resvg = new Resvg(raw, { fitTo: { mode: "width", value: 1000 } });
return resvg.render().asPng();
}
return new Uint8Array(await sharp(raw).png().toBuffer());
return new Uint8Array(
await sharp(raw).png({ compressionLevel: 9, effort: 10 }).toBuffer(),
);
}
12 changes: 11 additions & 1 deletion course/.vitepress/epub/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ export interface PackageInput {
/** epubPath(去掉 ../) -> bytes,统一 PNG */
images: Map<string, Uint8Array>;
/** 子集化后的字体 */
fonts: { cjk: Uint8Array; sans: Uint8Array; mono: Uint8Array };
fonts: {
cjk: Uint8Array;
cjkBold: Uint8Array;
sans: Uint8Array;
sansBold: Uint8Array;
mono: Uint8Array;
};
/** 样式表内容 */
css: string;
/** 封面 PNG 字节(可选) */
Expand Down Expand Up @@ -82,7 +88,9 @@ export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
const oebps = zip.folder("OEBPS")!;
oebps.file("styles/style.css", css);
oebps.file("fonts/cjk.woff2", fonts.cjk);
oebps.file("fonts/cjk-bold.woff2", fonts.cjkBold);
oebps.file("fonts/sans.woff2", fonts.sans);
oebps.file("fonts/sans-bold.woff2", fonts.sansBold);
oebps.file("fonts/mono.woff2", fonts.mono);

// 封面页 + 目录页
Expand Down Expand Up @@ -111,7 +119,9 @@ export async function packageEpub(input: PackageInput): Promise<Uint8Array> {
const manifestItems: string[] = [
`<item id="css" href="styles/style.css" media-type="text/css"/>`,
`<item id="font-cjk" href="fonts/cjk.woff2" media-type="font/woff2"/>`,
`<item id="font-cjk-bold" href="fonts/cjk-bold.woff2" media-type="font/woff2"/>`,
`<item id="font-sans" href="fonts/sans.woff2" media-type="font/woff2"/>`,
`<item id="font-sans-bold" href="fonts/sans-bold.woff2" media-type="font/woff2"/>`,
`<item id="font-mono" href="fonts/mono.woff2" media-type="font/woff2"/>`,
`<item id="nav" href="nav.xhtml" properties="nav" media-type="application/xhtml+xml"/>`,
];
Expand Down
50 changes: 50 additions & 0 deletions course/.vitepress/epub/preprocess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,55 @@ export function stripImageAttrs(md: string): string {
return md.replace(/(!?\[[^\]]*\]\([^)]*\))\{[^}\n]*\}/g, "$1");
}

/**
* 修复 CJK 相邻的 **加粗** 解析失败。
* CommonMark 的强调定界符(flanking)规则在 ** 被 CJK 标点/文字包夹时会判定其无效,
* 导致 markdown-it 原样输出字面 **(例如“。**文本。**”)。
* 这里在非代码区把 **文本** 改写为 <strong>文本</strong>;markdown-it 开启了 html:true,
* 会原样保留该标签,阅读器会正常加粗。
* 为避免误伤代码块(其他语言里的 **),逐行扫描并跳过 ``` / ~~~ 围栏内部;
* 同时跳过行内代码 `...` 中的内容。
*/
export function fixCjkStrong(md: string): string {
let inFence = false;
let fenceChar = "";
let fenceLen = 0;
return md
.split(/\r?\n/)
.map((ln) => {
const fence = ln.match(/^\s*(```+|~~~+)/);
if (fence) {
const char = fence[1][0];
const len = fence[1].length;
// CommonMark:围栏只能被同字符、且不短于开栏的围栏闭合
if (!inFence) {
inFence = true;
fenceChar = char;
fenceLen = len;
} else if (char === fenceChar && len >= fenceLen) {
inFence = false;
fenceChar = "";
fenceLen = 0;
}
return ln;
}
Comment on lines +191 to +213

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current fence parsing logic in fixCjkStrong is naive and can be broken by nested code blocks or different-length fences (e.g., a 3-backtick fence inside a 4-backtick fence). In CommonMark, a code fence can only be closed by a fence of the same character and of equal or greater length. We should track both the fence character and its length to correctly handle nested fences and prevent premature closing.

export function fixCjkStrong(md: string): string {
  let inFence = false;
  let fenceChar = "";
  let fenceLength = 0;
  return md
    .split(/\r?\n/)
    .map((ln) => {
      const fence = ln.match(/^\s*(```+|~~~+)/);
      if (fence) {
        const char = fence[1][0];
        const len = fence[1].length;
        if (!inFence) {
          inFence = true;
          fenceChar = char;
          fenceLength = len;
        } else if (char === fenceChar && len >= fenceLength) {
          inFence = false;
          fenceChar = "";
          fenceLength = 0;
        }
        return ln;
      }

if (inFence) return ln;
const codeSpans: string[] = [];
let s = ln.replace(/`[^`]*`/g, (m) => {
codeSpans.push(m);
return `\u0000${codeSpans.length - 1}\u0000`;
});
// 允许内部出现单个 *(如 Zig 指针 *T / *p),但不吞掉作定界的 **
s = s.replace(
/\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g,
"<strong>$1</strong>",
);
s = s.replace(/\u0000(\d+)\u0000/g, (_m, i) => codeSpans[Number(i)]);
return s;
})
.join("\n");
}

/** 完整预处理管线 */
export function preprocess(md: string, courseDir: string): string {
md = stripFrontmatter(md);
Expand All @@ -187,5 +236,6 @@ export function preprocess(md: string, courseDir: string): string {
md = transformCodeGroupTabs(md);
md = transformContainers(md);
md = stripImageAttrs(md);
md = fixCjkStrong(md);
return md;
}
25 changes: 25 additions & 0 deletions course/.vitepress/epub/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,24 @@
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Noto Serif SC";
src: url("../fonts/cjk-bold.woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("../fonts/sans.woff2");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("../fonts/sans-bold.woff2");
font-weight: 700;
font-style: normal;
}
@font-face {
font-family: "JetBrains Mono";
src: url("../fonts/mono.woff2");
Expand Down Expand Up @@ -61,6 +73,19 @@ p {
margin: 0.8em 0;
}

/* 加粗:显式使用 700 字重,配合上方 @font-face 的真粗体,避免阅读器伪粗体 */
strong,
b {
font-weight: 700;
}

/* 删除线 */
del,
s {
text-decoration: line-through;
color: #888;
}

a {
color: #c8730b;
text-decoration: none;
Expand Down
Loading
Loading