diff --git a/course/.vitepress/epub/README.md b/course/.vitepress/epub/README.md index a92c5b22..b8a6ff7c 100644 --- a/course/.vitepress/epub/README.md +++ b/course/.vitepress/epub/README.md @@ -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 警告 | ## 目录结构 @@ -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 | diff --git a/course/.vitepress/epub/build.ts b/course/.vitepress/epub/build.ts index 309727e0..3ad9f41a 100644 --- a/course/.vitepress/epub/build.ts +++ b/course/.vitepress/epub/build.ts @@ -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. 封面 diff --git a/course/.vitepress/epub/config.ts b/course/.vitepress/epub/config.ts index e4921cb8..1d975682 100644 --- a/course/.vitepress/epub/config.ts +++ b/course/.vitepress/epub/config.ts @@ -17,7 +17,7 @@ export interface EpubConfig { courseDir: string; /** 输出 epub 文件的绝对路径 */ outFile: string; - /** 封面图相对 courseDir 的路径(位于 public 下) */ + /** 封面图相对 courseDir 的路径 */ coverImage: string; /** Shiki 主题 */ shikiTheme: string; @@ -27,8 +27,12 @@ export interface EpubConfig { fonts: { /** 中文正文字体 */ cjk: FontSpec; - /** 正文英文/数字字体(无衬线) */ + /** 中文粗体(wght:700,用于标题/加粗,避免依赖阅读器伪粗体) */ + cjkBold: FontSpec; + /** 正文英文/数字字体(无衡线) */ sans: FontSpec; + /** 英文粗体(wght:700) */ + sansBold: FontSpec; /** 代码等宽字体 */ mono: FontSpec; }; @@ -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", @@ -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", diff --git a/course/.vitepress/epub/cover.png b/course/.vitepress/epub/cover.png new file mode 100644 index 00000000..31c2fee8 Binary files /dev/null and b/course/.vitepress/epub/cover.png differ diff --git a/course/.vitepress/epub/fonts.ts b/course/.vitepress/epub/fonts.ts index 4c88d1d6..00148ba3 100644 --- a/course/.vitepress/epub/fonts.ts +++ b/course/.vitepress/epub/fonts.ts @@ -16,8 +16,12 @@ async function download(url: string, cachePath: string): Promise { export interface SubsetFonts { /** 子集化后的中文正文字体(woff2) */ cjk: Uint8Array; + /** 子集化后的中文粗体(woff2) */ + cjkBold: Uint8Array; /** 子集化后的英文正文字体(woff2) */ sans: Uint8Array; + /** 子集化后的英文粗体(woff2) */ + sansBold: Uint8Array; /** 子集化后的代码等宽字体(woff2) */ mono: Uint8Array; } @@ -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 }; } diff --git a/course/.vitepress/epub/images.ts b/course/.vitepress/epub/images.ts index 848d0173..80aa0777 100644 --- a/course/.vitepress/epub/images.ts +++ b/course/.vitepress/epub/images.ts @@ -74,5 +74,7 @@ export async function toPng(raw: Buffer): Promise { 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(), + ); } diff --git a/course/.vitepress/epub/package.ts b/course/.vitepress/epub/package.ts index 70a992d7..8611ba98 100644 --- a/course/.vitepress/epub/package.ts +++ b/course/.vitepress/epub/package.ts @@ -18,7 +18,13 @@ export interface PackageInput { /** epubPath(去掉 ../) -> bytes,统一 PNG */ images: Map; /** 子集化后的字体 */ - fonts: { cjk: Uint8Array; sans: Uint8Array; mono: Uint8Array }; + fonts: { + cjk: Uint8Array; + cjkBold: Uint8Array; + sans: Uint8Array; + sansBold: Uint8Array; + mono: Uint8Array; + }; /** 样式表内容 */ css: string; /** 封面 PNG 字节(可选) */ @@ -82,7 +88,9 @@ export async function packageEpub(input: PackageInput): Promise { 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); // 封面页 + 目录页 @@ -111,7 +119,9 @@ export async function packageEpub(input: PackageInput): Promise { const manifestItems: string[] = [ ``, ``, + ``, ``, + ``, ``, ``, ]; diff --git a/course/.vitepress/epub/preprocess.ts b/course/.vitepress/epub/preprocess.ts index 10426549..5dac6d5d 100644 --- a/course/.vitepress/epub/preprocess.ts +++ b/course/.vitepress/epub/preprocess.ts @@ -179,6 +179,55 @@ export function stripImageAttrs(md: string): string { return md.replace(/(!?\[[^\]]*\]\([^)]*\))\{[^}\n]*\}/g, "$1"); } +/** + * 修复 CJK 相邻的 **加粗** 解析失败。 + * CommonMark 的强调定界符(flanking)规则在 ** 被 CJK 标点/文字包夹时会判定其无效, + * 导致 markdown-it 原样输出字面 **(例如“。**文本。**”)。 + * 这里在非代码区把 **文本** 改写为 文本;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; + } + 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]|\*(?!\*))+?)(?$1", + ); + 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); @@ -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; } diff --git a/course/.vitepress/epub/style.css b/course/.vitepress/epub/style.css index 46b7f1ac..b39929a7 100644 --- a/course/.vitepress/epub/style.css +++ b/course/.vitepress/epub/style.css @@ -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"); @@ -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; diff --git a/scripts/pdf/AGENTS.md b/scripts/pdf/AGENTS.md index 273baf5d..27203b2c 100644 --- a/scripts/pdf/AGENTS.md +++ b/scripts/pdf/AGENTS.md @@ -37,16 +37,16 @@ node_modules/.bin/tsc --noEmit -p scripts/pdf/tsconfig.json ## 2. 目录与模块职责 -| 文件 | 职责 | -| ----------------- | ------------------------------------------------------------------------------------------ | -| `main.ts` | 入口:`import sidebar`,扁平化为有序节点,逐页渲染,创建 PDF 书签(outline),写出文件 | -| `parse.ts` | Markdown **预处理 + 分词**:展开代码引用、转换 GitHub alert、解析 VitePress 容器,产出 token | -| `renderer.ts` | **核心布局引擎**:标题/段落/列表/表格/代码块/图片/提示框/引用块绘制,链接坐标收集与最终绑定 | -| `highlight.ts` | 用 Shiki 把代码着色为 `{content, color}` 片段(只取 token 颜色,不生成 HTML) | -| `utils.ts` | sidebar 扁平化、站内链接归一化、`slugify`、图片路径解析、代码引用 `<<<@/...` 解析 + dedent | -| `tsconfig.json` | 仅供本目录 `tsc --noEmit` 类型检查与编辑器使用,**不参与** VitePress 构建 | -| `README.md` | 面向使用者的简明说明 | -| `AGENTS.md` | 本文件,面向维护者/Agent 的深入说明 | +| 文件 | 职责 | +| --------------- | -------------------------------------------------------------------------------------------- | +| `main.ts` | 入口:`import sidebar`,扁平化为有序节点,逐页渲染,创建 PDF 书签(outline),写出文件 | +| `parse.ts` | Markdown **预处理 + 分词**:展开代码引用、转换 GitHub alert、解析 VitePress 容器,产出 token | +| `renderer.ts` | **核心布局引擎**:标题/段落/列表/表格/代码块/图片/提示框/引用块绘制,链接坐标收集与最终绑定 | +| `highlight.ts` | 用 Shiki 把代码着色为 `{content, color}` 片段(只取 token 颜色,不生成 HTML) | +| `utils.ts` | sidebar 扁平化、站内链接归一化、`slugify`、图片路径解析、代码引用 `<<<@/...` 解析 + dedent | +| `tsconfig.json` | 仅供本目录 `tsc --noEmit` 类型检查与编辑器使用,**不参与** VitePress 构建 | +| `README.md` | 面向使用者的简明说明 | +| `AGENTS.md` | 本文件,面向维护者/Agent 的深入说明 | > 模块依赖方向(无环):`main → {parse, renderer, highlight, utils}`; > `parse → utils`;`renderer → {utils, highlight, parse(类型)}`。 @@ -164,19 +164,19 @@ routeStart / pendingLink.page"的代码,一律用 `curPage()`,不要用 `thi ### 5.6 token 分发表(`renderTokens`) -| token.type | 处理方法 | 说明 | -| ------------ | ------------------ | ----------------------------------------------- | -| `heading` | `drawHeading` | 登记 anchor;支持标题内含行内链接/代码 | -| `paragraph` | `drawParagraph` | 行内混排;返回其中的图片交由 `drawImage` | -| `code` | `drawCode` | Shiki 着色 + 自动换行 + 续行缩进 + 灰底圆角框 | -| `list` | `drawList` | 有序/无序/嵌套,矢量圆点或数字编号 | -| `blockquote` | `drawBlockquote` | 两遍渲染,淡灰底 + 左竖条 | -| `admonition` | `drawAdmonition` | 两遍渲染,按类型配色(tip/info/warning/...) | -| `table` | `drawTable` | 单元格内容扁平化后按列宽换行渲染 | -| `hr` | `hr` | 分隔线 | -| `space` | — | 纵向间距 | -| `image` | `drawImage` | sharp 栅格化为 PNG(SVG/远程图均支持) | -| `text` | `drawInline` | 兜底行内 | +| token.type | 处理方法 | 说明 | +| ------------ | ---------------- | --------------------------------------------- | +| `heading` | `drawHeading` | 登记 anchor;支持标题内含行内链接/代码 | +| `paragraph` | `drawParagraph` | 行内混排;返回其中的图片交由 `drawImage` | +| `code` | `drawCode` | Shiki 着色 + 自动换行 + 续行缩进 + 灰底圆角框 | +| `list` | `drawList` | 有序/无序/嵌套,矢量圆点或数字编号 | +| `blockquote` | `drawBlockquote` | 两遍渲染,淡灰底 + 左竖条 | +| `admonition` | `drawAdmonition` | 两遍渲染,按类型配色(tip/info/warning/...) | +| `table` | `drawTable` | 单元格内容扁平化后按列宽换行渲染 | +| `hr` | `hr` | 分隔线 | +| `space` | — | 纵向间距 | +| `image` | `drawImage` | sharp 栅格化为 PNG(SVG/远程图均支持) | +| `text` | `drawInline` | 兜底行内 | --- @@ -279,13 +279,13 @@ bun pdf ## 10. 已知陷阱速查 -| 现象 | 根因 / 修复 | -| -------------------------------------- | ----------------------------------------------------------------- | -| 站内跳转整体错 1 页 | 用 `this.page` 而非 `curPage()` 登记锚点;改用 `curPage()` | -| 代码块整体右移 | 代码片段缺 `dedent`;在导入返回前 `dedent(trimBlankEdges(...))` | -| 代码块缺少闭合 `}` | 把 `} // #endregion x` 整行当跳过;需保留标记前代码 | -| 提示框只剩半截竖条 / 无底框 | 未用两遍渲染测高,或 dry 期间误绘制 | -| 列表圆点偏上/偏下 | pt 当 mm 直接相加;`dotCy` 需 `size * k * 0.3528` 换算 | -| 提示框标题出现缺字形方块 | 标题含 emoji;`cleanAdmonitionTitle` 未生效或字体不含该字形 | -| 中文/英文整页空白、字体不显示 | 字体是 CFF/OTF,jsPDF 静默拒绝;换 glyf 型来源(见 §5.2) | -| 新加字符渲染为缺字形方块 | 子集未含该字形;改完课程文本后重跑 `bun pdf:fonts` | +| 现象 | 根因 / 修复 | +| ----------------------------- | --------------------------------------------------------------- | +| 站内跳转整体错 1 页 | 用 `this.page` 而非 `curPage()` 登记锚点;改用 `curPage()` | +| 代码块整体右移 | 代码片段缺 `dedent`;在导入返回前 `dedent(trimBlankEdges(...))` | +| 代码块缺少闭合 `}` | 把 `} // #endregion x` 整行当跳过;需保留标记前代码 | +| 提示框只剩半截竖条 / 无底框 | 未用两遍渲染测高,或 dry 期间误绘制 | +| 列表圆点偏上/偏下 | pt 当 mm 直接相加;`dotCy` 需 `size * k * 0.3528` 换算 | +| 提示框标题出现缺字形方块 | 标题含 emoji;`cleanAdmonitionTitle` 未生效或字体不含该字形 | +| 中文/英文整页空白、字体不显示 | 字体是 CFF/OTF,jsPDF 静默拒绝;换 glyf 型来源(见 §5.2) | +| 新加字符渲染为缺字形方块 | 子集未含该字形;改完课程文本后重跑 `bun pdf:fonts` | diff --git a/scripts/pdf/README.md b/scripts/pdf/README.md index 98382bc6..1f106ec7 100644 --- a/scripts/pdf/README.md +++ b/scripts/pdf/README.md @@ -17,22 +17,21 @@ bun pdf:sample # 仅渲染几篇代表页 -> books/zig_course_sample.pdf(快 ## 设计概览 -| 文件 | 职责 | -| --------------- | -------------------------------------------------------------------------- | +| 文件 | 职责 | +| --------------- | ---------------------------------------------------------------------------- | | `main.ts` | 入口:`import sidebar`,按目录顺序逐页渲染,写 PDF 书签(outline),输出文件 | -| `parse.ts` | Markdown 预处理与分词:展开代码引用、GitHub alert、VitePress 容器,转 token | +| `parse.ts` | Markdown 预处理与分词:展开代码引用、GitHub alert、VitePress 容器,转 token | | `renderer.ts` | 核心布局引擎:标题/段落/列表/表格/代码块/图片/提示框绘制,链接坐标收集与绑定 | -| `highlight.ts` | 用 Shiki(VitePress 同款引擎)将代码着色为 `{content, color}` 片段 | -| `utils.ts` | sidebar 扁平化、站内链接归一化、图片路径解析、代码引用 `<<<@/...` 解析 | -| `tsconfig.json` | 仅用于本目录的类型检查(`tsc --noEmit`),不参与 VitePress 构建 | +| `highlight.ts` | 用 Shiki(VitePress 同款引擎)将代码着色为 `{content, color}` 片段 | +| `utils.ts` | sidebar 扁平化、站内链接归一化、图片路径解析、代码引用 `<<<@/...` 解析 | +| `tsconfig.json` | 仅用于本目录的类型检查(`tsc --noEmit`),不参与 VitePress 构建 | ### 与项目的集成点 1. **同源 sidebar**:`main.ts` 直接 `import sidebar from "../../course/.vitepress/sidebar.js"`, PDF 目录与网页侧边栏始终一致,无需维护第二份顺序表。 2. **排除路由**:`main.ts` 的 `EXCLUDE` 当前**仅排除 `/code/**`**(纯代码片段目录, - 由正文以 `<<<@` 引用导入,本身非正文页面)。`appendix / update / about / epilogue` - 等章节均收入 PDF。调整收录范围改 `EXCLUDE` 即可。 +由正文以 `<<<@` 引用导入,本身非正文页面)。`appendix / update / about / epilogue`等章节均收入 PDF。调整收录范围改`EXCLUDE` 即可。 3. **字体**:三套——`zigcourse-cjk.ttf`(Noto Serif SC,即思源宋体同源设计,中文)、 `zigcourse-sans.ttf`(Inter,正文英文/数字,无衬线)、`zigcourse-mono.ttf` (JetBrains Mono,代码/行内代码)。CJK / 正文拉丁 / 代码三路分流绘制。三个 TTF 均为子集 diff --git a/scripts/pdf/build-fonts.ts b/scripts/pdf/build-fonts.ts index 1dbebb6d..3df9c629 100644 --- a/scripts/pdf/build-fonts.ts +++ b/scripts/pdf/build-fonts.ts @@ -11,7 +11,7 @@ // sans = Inter → 正文英文/数字(无衬线) // mono = JetBrains Mono → 代码/行内代码(等宽) // -// 运行:bun run scripts/pdf/build-fonts.ts (或 bun pdf:fonts) +// 运行:bun run scripts/pdf/build-fonts.ts(或 bun pdf:fonts) import subsetFont from "subset-font"; import { readFileSync, @@ -108,6 +108,8 @@ for (const f of FONTS) { }); const out = path.join(OUT, `zigcourse-${f.name}.ttf`); writeFileSync(out, sub); - console.log(`✓ ${f.name} -> ${out} ${(sub.byteLength / 1024).toFixed(0)} KB`); + console.log( + `✓ ${f.name} -> ${out} ${(sub.byteLength / 1024).toFixed(0)} KB`, + ); } console.log("完成。"); diff --git a/scripts/pdf/highlight.ts b/scripts/pdf/highlight.ts index 68ac75dc..583466c5 100644 --- a/scripts/pdf/highlight.ts +++ b/scripts/pdf/highlight.ts @@ -57,8 +57,14 @@ export async function initHighlighter(): Promise { export function highlightToLines(code: string, lang: string): HlLine[] { const hl = _hl; const normLang = LANG_ALIAS[lang] ?? lang ?? "text"; - if (!hl || normLang === "text" || !hl.getLoadedLanguages().includes(normLang)) { - return code.split("\n").map((line) => [{ content: line, color: "#24292E" }]); + if ( + !hl || + normLang === "text" || + !hl.getLoadedLanguages().includes(normLang) + ) { + return code + .split("\n") + .map((line) => [{ content: line, color: "#24292E" }]); } try { const { tokens } = hl.codeToTokens(code, { @@ -69,7 +75,9 @@ export function highlightToLines(code: string, lang: string): HlLine[] { line.map((t) => ({ content: t.content, color: t.color || "#24292E" })), ); } catch { - return code.split("\n").map((line) => [{ content: line, color: "#24292E" }]); + return code + .split("\n") + .map((line) => [{ content: line, color: "#24292E" }]); } } diff --git a/scripts/pdf/main.ts b/scripts/pdf/main.ts index 9cd630c3..96ed4533 100644 --- a/scripts/pdf/main.ts +++ b/scripts/pdf/main.ts @@ -53,9 +53,7 @@ async function main(): Promise { await readFile(path.join(ROOT, "assets/fonts/zigcourse-mono.ttf")) ).toString("base64"); - let nodes: FlatNode[] = flattenSidebar( - sidebar as DefaultTheme.SidebarItem[], - ); + let nodes: FlatNode[] = flattenSidebar(sidebar as DefaultTheme.SidebarItem[]); // 仅对页面节点应用排除;分组节点保留(其下无页面会被自动跳过)。 nodes = nodes.filter( (n) => n.isGroup || !EXCLUDE.some((re) => re.test(n.route!)), @@ -66,14 +64,23 @@ async function main(): Promise { const pageCount = nodes.filter((n) => !n.isGroup).length; console.log(`将渲染 ${pageCount} 个页面${SAMPLE ? "(样例模式)" : ""}`); - const renderer = new PdfRenderer({ fontCjk, fontSans, fontMono, courseDir: COURSE }); + const renderer = new PdfRenderer({ + fontCjk, + fontSans, + fontMono, + courseDir: COURSE, + }); const outline = (renderer.doc as any).outline; const bookmarkStack: Bookmark[] = []; // 分组节点的书签延迟创建:等其下第一个页面渲染完才知道起始页。 const pendingGroups: FlatNode[] = []; - const addBookmark = (level: number, title: string, pageNumber: number): unknown => { + const addBookmark = ( + level: number, + title: string, + pageNumber: number, + ): unknown => { while ( bookmarkStack.length && bookmarkStack[bookmarkStack.length - 1].level >= level @@ -102,7 +109,7 @@ async function main(): Promise { ? altPath : null; if (!file) { - console.warn(`✗ 找不到: ${route}`); + console.warn(`✗ 找不到:${route}`); continue; } const content = await readFile(file, "utf-8"); @@ -128,7 +135,7 @@ async function main(): Promise { const buf = renderer.output(); await writeFile(outFile, buf); console.log( - `\n已生成: ${outFile} 共 ${renderer.page} 页, ${(buf.length / 1024 / 1024).toFixed(1)} MB`, + `\n已生成:${outFile} 共 ${renderer.page} 页,${(buf.length / 1024 / 1024).toFixed(1)} MB`, ); } diff --git a/scripts/pdf/parse.ts b/scripts/pdf/parse.ts index 55228bf9..9ad742ab 100644 --- a/scripts/pdf/parse.ts +++ b/scripts/pdf/parse.ts @@ -154,6 +154,51 @@ function transformContainers(lines: string[]): string[] { return out; } +// step E: 仅在非代码围栏区,把成对的 **文本** 改写为 文本, +// 绕过 marked 对 CJK 相邻 ** 的 flanking 解析限制。 +// 为避免误伤代码块(如其他语言里的 **),逐行扫描并跳过 ``` / ~~~ 围栏内部。 +export function fixCjkStrong(lines: string[]): string[] { + let inFence = false; + let fenceChar = ""; + let fenceLen = 0; + return lines.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; + } + if (inFence) return ln; + // 跳过行内代码 `...`:先用占位符保护,替换后再还原,避免动到代码里的 ** + const codeSpans: string[] = []; + let protectedLine = ln.replace(/`[^`]*`/g, (m) => { + codeSpans.push(m); + return `\u0000${codeSpans.length - 1}\u0000`; + }); + // 成对、非贪婪的加粗 -> ;允许内部出现单个 *(如 Zig 指针 *T / *p) + protectedLine = protectedLine.replace( + /\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?$1", + ); + // 还原行内代码 + protectedLine = protectedLine.replace( + /\u0000(\d+)\u0000/g, + (_m, i) => codeSpans[Number(i)], + ); + return protectedLine; + }); +} + export async function preprocess( content: string, courseDir: string, @@ -175,6 +220,11 @@ export async function preprocess( lines = lines.map((ln) => ln.replace(/(!?\[[^\]]*\]\([^)]*\))\{[^}]*\}/g, "$1"), ); + // step E: 修复 CJK 相邻的 **加粗** 解析失败 + // CommonMark 的强调定界符(flanking)规则在 ** 被 CJK 标点/文字包夹时会判定其无效, + // 导致 marked 原样输出字面 **。这里在非代码区把 **文本** 改写为 文本, + // marked 会将其拆为 html 标签 token,再由渲染器消费为加粗(见 renderer 的 flatten)。 + lines = fixCjkStrong(lines); return lines.join("\n"); } diff --git a/scripts/pdf/renderer.ts b/scripts/pdf/renderer.ts index 845b8f06..7fafef24 100644 --- a/scripts/pdf/renderer.ts +++ b/scripts/pdf/renderer.ts @@ -56,7 +56,9 @@ interface RenderCtx { } /** 任意带 tokens 字段的 inline token 兼容形态。 */ -type InlineToken = Token | { type: string; text?: string; tokens?: Token[]; href?: string }; +type InlineToken = + | Token + | { type: string; text?: string; tokens?: Token[]; href?: string }; /** 构造参数。 */ export interface RendererOptions { @@ -189,7 +191,12 @@ export class PdfRenderer { } // 在指定 (x, y) 绘制一整行混排文本(不换行),CJK 走 CJK 字体、拉丁走指定字体。 - drawMixedLine(text: string, x: number, y: number, latinFont = "Sans"): number { + drawMixedLine( + text: string, + x: number, + y: number, + latinFont = "Sans", + ): number { let cx = x; let buf = ""; let bufCjk: boolean | null = null; @@ -227,24 +234,54 @@ export class PdfRenderer { const startX = MARGIN.left + indent; const maxW = CONTENT_W - indent; - // 把 inline tokens 拍平为 [{text, link?, code?}] 片段 + // 把 inline tokens 拍平为 [{text, link?, code?, bold?}] 片段 interface Seg { text: string; code?: boolean; link?: string; + bold?: boolean; } const segs: Seg[] = []; - const flatten = (toks: InlineToken[], link?: string): void => { + // boldDepth 用计数器跟踪嵌套的加粗/斜体(含 html / 标签), + // 兼容 marked 正常解析出的 strong/em,以及预处理转写出的 html 标签。 + const flatten = ( + toks: InlineToken[], + link?: string, + boldDepth = 0, + ): void => { for (const t of toks) { const tt = t as any; if (tt.type === "link") { - flatten(tt.tokens || [{ type: "text", text: tt.text }], tt.href); + flatten( + tt.tokens || [{ type: "text", text: tt.text }], + tt.href, + boldDepth, + ); + } else if (tt.type === "strong" || tt.type === "em") { + flatten( + tt.tokens || [{ type: "text", text: tt.text }], + link, + boldDepth + 1, + ); + } else if (tt.type === "html") { + // 处理预处理转写出的 / 标签(成对出现),标签本身不输出文本 + const raw = (tt.text ?? tt.raw ?? "").trim().toLowerCase(); + if (/^<(strong|b|em|i)>$/.test(raw)) { + boldDepth++; + } else if (/^<\/(strong|b|em|i)>$/.test(raw)) { + boldDepth = Math.max(0, boldDepth - 1); + } + // 其他 html 原样忽略(与原行为一致:不输出标签文本) } else if (tt.type === "codespan") { - segs.push({ text: tt.text, code: true, link }); + segs.push({ text: tt.text, code: true, link, bold: boldDepth > 0 }); } else if (tt.tokens) { - flatten(tt.tokens, link); + flatten(tt.tokens, link, boldDepth); } else { - segs.push({ text: tt.text ?? tt.raw ?? "", link }); + segs.push({ + text: tt.text ?? tt.raw ?? "", + link, + bold: boldDepth > 0, + }); } } }; @@ -255,7 +292,12 @@ export class PdfRenderer { let curY = this.y; const cjkFont = fontName; - const placeChar = (s: string, isCode: boolean | undefined, link?: string): void => { + const placeChar = ( + s: string, + isCode: boolean | undefined, + link?: string, + bold?: boolean, + ): void => { const units: string[] = []; let buf2 = ""; for (const ch of s) { @@ -307,25 +349,48 @@ export class PdfRenderer { } this.doc.setTextColor(20, 90, 200); } - if (!this._dry) this.doc.text(piece, x, curY); + if (!this._dry) { + if (bold) { + // 伪粗体:用填充 + 描边模式加粗笔画(无需额外 bold 字体) + const dc = link ? [20, 90, 200] : [30, 30, 30]; + this.doc.setDrawColor(dc[0], dc[1], dc[2]); + this.doc.setLineWidth(0.25); + this.doc.text(piece, x, curY, { renderingMode: "fillThenStroke" }); + } else { + this.doc.text(piece, x, curY); + } + } if (link && !this._dry) this.doc.setTextColor(30, 30, 30); x += w; } }; - for (const seg of segs) placeChar(seg.text, seg.code, seg.link); + for (const seg of segs) placeChar(seg.text, seg.code, seg.link, seg.bold); this.y = curY + lineH; return this.y; } - drawHeading(token: Tokens.Heading, currentRoute: string): { anchor: string; page: number } { - const sizes: Record = { 1: 20, 2: 16, 3: 13, 4: 12, 5: 11, 6: 11 }; + drawHeading( + token: Tokens.Heading, + currentRoute: string, + ): { anchor: string; page: number } { + const sizes: Record = { + 1: 20, + 2: 16, + 3: 13, + 4: 12, + 5: 11, + 6: 11, + }; const size = sizes[token.depth] || 12; this.ensureSpace(size * 0.6); this.y += token.depth <= 2 ? 4 : 2; const anchor = slugify(token.text); if (!this._dry) - this.anchors.set(`${currentRoute}#${anchor}`, { page: this.curPage(), y: this.y }); + this.anchors.set(`${currentRoute}#${anchor}`, { + page: this.curPage(), + y: this.y, + }); this.setSize(size); this.doc.setTextColor(0, 0, 0); // 标题可能含行内链接/代码(如 ### [`@atomicLoad`](url)),用 renderCell 解析渲染 @@ -335,9 +400,25 @@ export class PdfRenderer { : [{ type: "text", text: token.text }]; const lineH = size * 0.55; this._cellDefaultColor = [0, 0, 0]; - const nLines = this.renderCell(headTokens, MARGIN.left, this.y, CONTENT_W, lineH, true, currentRoute); + const nLines = this.renderCell( + headTokens, + MARGIN.left, + this.y, + CONTENT_W, + lineH, + true, + currentRoute, + ); this.ensureSpace(nLines * lineH); - this.renderCell(headTokens, MARGIN.left, this.y, CONTENT_W, lineH, false, currentRoute); + this.renderCell( + headTokens, + MARGIN.left, + this.y, + CONTENT_W, + lineH, + false, + currentRoute, + ); this._cellDefaultColor = null; this.y += nLines * lineH + 2; return { anchor, page: this.curPage() }; @@ -396,6 +477,19 @@ export class PdfRenderer { } this.y += 2.5; + + // 孤行/寡行保护所需的常量: + // - MIN_TAIL_ROWS:避免一个代码块在下一页只留下极少的尾行(如仅 `}`)。 + // - 整块下移阈值:体量不大的代码块(不超过半个内容区高度)若当前页放不下,整体移到下一页,避免拦腰断开。 + const MIN_TAIL_ROWS = 3; + const contentH = A4.h - MARGIN.top - MARGIN.bottom; + const totalBlockH = drawRows.length * lineH + padY * 2; + const availSpace = A4.h - MARGIN.bottom - this.y; + if (totalBlockH <= contentH * 0.5 && totalBlockH > availSpace) { + // 小代码块整体下移到下一页,保持完整 + this.newPage(); + } + let idx = 0; while (idx < drawRows.length) { this.ensureSpace(lineH + padY * 2); @@ -407,10 +501,32 @@ export class PdfRenderer { yy += lineH; idx++; } + // 孤行保护:若本页放下后,剩余行数过少(如只剩闭合括号), + // 则从本页回收若干行留给下一页,使断点更自然(仅当本页放得下足够多行时才回收)。 + const remaining = drawRows.length - idx; + if ( + remaining > 0 && + remaining < MIN_TAIL_ROWS && + rowsThisPage.length > MIN_TAIL_ROWS + ) { + const giveBack = MIN_TAIL_ROWS - remaining; + for (let k = 0; k < giveBack; k++) { + rowsThisPage.pop(); + idx--; + } + } const blockH = rowsThisPage.length * lineH + padY * 2; if (!this._dry) { this.doc.setFillColor(246, 248, 250); - this.doc.roundedRect(blockX, blockStartY, blockW, blockH, 1.2, 1.2, "F"); + this.doc.roundedRect( + blockX, + blockStartY, + blockW, + blockH, + 1.2, + 1.2, + "F", + ); } for (const { row, yy: ry } of rowsThisPage) { let cx = row.x; @@ -468,6 +584,8 @@ export class PdfRenderer { drawW = drawH / ratio; } + // 剩余空间放不下整图就换页,避免顶部/底部被裁切 + // (drawH 已被限制不超过单页内容区高度)。 this.ensureSpace(drawH + 4); const x = MARGIN.left + (CONTENT_W - drawW) / 2; if (!this._dry) this.doc.addImage(png, "PNG", x, this.y, drawW, drawH); @@ -529,12 +647,20 @@ export class PdfRenderer { this.doc.setTextColor(45, 45, 45); } if (inlineToks.length) { - this.drawInline(inlineToks, { indent: textIndent, currentRoute, lineH, size }); + this.drawInline(inlineToks, { + indent: textIndent, + currentRoute, + lineH, + size, + }); } else { this.y += lineH; } for (const sub of subLists) { - await this.drawList(sub, currentRoute, { level: level + 1, baseIndent }); + await this.drawList(sub, currentRoute, { + level: level + 1, + baseIndent, + }); } this.y += itemGap; n++; @@ -551,7 +677,7 @@ export class PdfRenderer { const padX = 4; // 框内左右内边距(含竖条侧) const padY = 2.8; // 框内上下内边距 const barW = 1; // 左竖条宽度 - const contentIndent = padX + 1; // 内容相对 boxX 的左缩进(让出竖条+留白) + const contentIndent = padX + 1; // 内容相对 boxX 的左缩进(让出竖条 + 留白) const barColor: [number, number, number] = [200, 160, 40]; const bgColor: [number, number, number] = [250, 247, 235]; @@ -567,7 +693,12 @@ export class PdfRenderer { for (const t of token.tokens) { const tt = t as any; if (tt.type === "paragraph") - this.drawInline(tt.tokens, { indent: contentIndent, currentRoute, size, lineH }); + this.drawInline(tt.tokens, { + indent: contentIndent, + currentRoute, + size, + lineH, + }); else if (tt.type === "text") this.drawInline(tt.tokens || [{ type: "text", text: tt.text }], { indent: contentIndent, @@ -703,7 +834,8 @@ export class PdfRenderer { for (const ch of u.text) { const tw = this.doc.getTextWidth(part + ch); if (x0 - x + tw > maxW && part) { - if (!measureOnly) this.drawCellPiece(part, x0, y, u, measureOnly, currentRoute); + if (!measureOnly) + this.drawCellPiece(part, x0, y, u, measureOnly, currentRoute); x0 = x; y += lineH; lines++; @@ -717,7 +849,8 @@ export class PdfRenderer { y += lineH; lines++; } - if (!measureOnly) this.drawCellPiece(part, x0, y, u, measureOnly, currentRoute); + if (!measureOnly) + this.drawCellPiece(part, x0, y, u, measureOnly, currentRoute); x0 += pw; } continue; @@ -727,7 +860,8 @@ export class PdfRenderer { y += lineH; lines++; } - if (!measureOnly) this.drawCellPiece(u.text, x0, y, u, measureOnly, currentRoute); + if (!measureOnly) + this.drawCellPiece(u.text, x0, y, u, measureOnly, currentRoute); x0 += w; } return lines; @@ -748,7 +882,14 @@ export class PdfRenderer { if (u.link) { const norm = normalizeInternalLink(u.link, currentRoute); if (norm) - this.pendingLinks.push({ page: this.curPage(), x, y: y - 3, w, h: 4.5, target: norm }); + this.pendingLinks.push({ + page: this.curPage(), + x, + y: y - 3, + w, + h: 4.5, + target: norm, + }); else this.doc.link(x, y - 3, w, 4.5, { url: u.link }); this.doc.setTextColor(20, 90, 200); } else { @@ -816,16 +957,35 @@ export class PdfRenderer { } // 提示框(VitePress 容器):淡色圆角背景 + 左竖条 + 标题,两遍渲染。 - async drawAdmonition(token: AdmonitionToken, currentRoute: string): Promise { + async drawAdmonition( + token: AdmonitionToken, + currentRoute: string, + ): Promise { const styles: Record< string, - { bar: [number, number, number]; bg: [number, number, number]; title: [number, number, number] } + { + bar: [number, number, number]; + bg: [number, number, number]; + title: [number, number, number]; + } > = { tip: { bar: [66, 184, 131], bg: [240, 249, 244], title: [33, 131, 88] }, - info: { bar: [100, 150, 220], bg: [240, 244, 251], title: [60, 100, 180] }, - warning: { bar: [234, 179, 8], bg: [252, 248, 227], title: [157, 117, 10] }, + info: { + bar: [100, 150, 220], + bg: [240, 244, 251], + title: [60, 100, 180], + }, + warning: { + bar: [234, 179, 8], + bg: [252, 248, 227], + title: [157, 117, 10], + }, danger: { bar: [220, 80, 80], bg: [253, 241, 241], title: [180, 50, 50] }, - details: { bar: [150, 150, 150], bg: [245, 245, 245], title: [90, 90, 90] }, + details: { + bar: [150, 150, 150], + bg: [245, 245, 245], + title: [90, 90, 90], + }, }; const defaultTitles: Record = { tip: "提示", @@ -855,7 +1015,8 @@ export class PdfRenderer { const renderBody = async (): Promise => { this.y += padTop + titleSize * 0.55; - if (!this._dry) this.doc.setTextColor(st.title[0], st.title[1], st.title[2]); + if (!this._dry) + this.doc.setTextColor(st.title[0], st.title[1], st.title[2]); this.drawInline([{ type: "text", text: heading }], { indent: contentIndent, size: titleSize, @@ -872,8 +1033,8 @@ export class PdfRenderer { this.y += padTop * 0.5; }; - // 第一遍:干跑测高。snap 同时记录内部计数页(this.page,供跨页增量判断) - // 与 jsPDF 真实页号(realPage,供 setPage 精确回滚)。 + // 第一遍:干跑测高。snap 同时记录内部计数页 (this.page,供跨页增量判断) + // 与 jsPDF 真实页号 (realPage,供 setPage 精确回滚)。 const snap = { y: this.y, page: this.page, @@ -908,7 +1069,11 @@ export class PdfRenderer { } // 渲染一组 token(可复用于页面与容器内部) - async renderTokens(tokens: PdfToken[], route: string, ctx: RenderCtx = {}): Promise { + async renderTokens( + tokens: PdfToken[], + route: string, + ctx: RenderCtx = {}, + ): Promise { const indent = ctx.indent || 0; const codeOffset = ctx.codeOffset || 0; for (const token of tokens) { @@ -947,18 +1112,27 @@ export class PdfRenderer { await this.drawImage(t as Tokens.Image, route); break; case "text": - if (t.tokens) this.drawInline(t.tokens, { currentRoute: route, indent }); + if (t.tokens) + this.drawInline(t.tokens, { currentRoute: route, indent }); else if (t.text) - this.drawInline([{ type: "text", text: t.text }], { currentRoute: route, indent }); + this.drawInline([{ type: "text", text: t.text }], { + currentRoute: route, + indent, + }); break; default: - if (t.tokens) this.drawInline(t.tokens, { currentRoute: route, indent }); + if (t.tokens) + this.drawInline(t.tokens, { currentRoute: route, indent }); } } } // ---------- 渲染一页(一篇 markdown)---------- - async renderPage(route: string, _title: string, tokens: PdfToken[]): Promise { + async renderPage( + route: string, + _title: string, + tokens: PdfToken[], + ): Promise { if (this.page !== 1 || this.y > MARGIN.top) { if (!(this.page === 1 && this.y === MARGIN.top)) this.newPage(); } diff --git a/scripts/pdf/utils.ts b/scripts/pdf/utils.ts index dbedfb6e..b86e3d57 100644 --- a/scripts/pdf/utils.ts +++ b/scripts/pdf/utils.ts @@ -38,7 +38,9 @@ export interface ResolvedImage { // ---------- sidebar -> 有序页面列表 ---------- // 返回有序节点列表。isGroup=true 表示纯分组标题(无对应 md 页面,仅用于书签层级)。 // 与 VitePress 侧边栏目录完全一致,同时保留带 link 的页面与不带 link 的分组。 -export function flattenSidebar(sidebar: DefaultTheme.SidebarItem[]): FlatNode[] { +export function flattenSidebar( + sidebar: DefaultTheme.SidebarItem[], +): FlatNode[] { const nodes: FlatNode[] = []; function walk(items: DefaultTheme.SidebarItem[], level: number): void { for (const item of items) { @@ -131,7 +133,7 @@ export function resolveImagePath( // 处理 // [!code focus] / [!code highlight] / [!code ++] / [!code --] / // [!code warning] / [!code error] / [!code focus:n] / [!code word:xxx] 等。 // 这些指令可能独占一行注释,也可能紧跟在代码或另一段注释之后(如 -// `// 获取writer句柄// [!code focus]`),需统一移除指令本身并清理多余空白。 +// `// 获取 writer 句柄// [!code focus]`),需统一移除指令本身并清理多余空白。 function stripCodeDirectives(line: string): string { let out = line.replace(/\s*\/\/\s*\[!code[^\]]*\]/g, ""); out = out.replace(/[ \t]+$/g, ""); @@ -185,7 +187,7 @@ export async function resolveCodeImport( const absFile = path.join(courseDir, rel); const ext = path.extname(absFile).slice(1) || "text"; const lang = ext === "zig" ? "zig" : ext; - if (!existsSync(absFile)) return { lang, code: `// 文件不存在: ${rel}` }; + if (!existsSync(absFile)) return { lang, code: `// 文件不存在:${rel}` }; const src = await readFile(absFile, "utf-8"); const lines = src.split("\n");