Skip to content

Add cover image and bold font support with CJK markdown fixes#333

Merged
jinzhongjia merged 4 commits into
mainfrom
placid-bobcat
Jun 23, 2026
Merged

Add cover image and bold font support with CJK markdown fixes#333
jinzhongjia merged 4 commits into
mainfrom
placid-bobcat

Conversation

@jinzhongjia

Copy link
Copy Markdown
Member

Summary

  • Cover image: Add EPUB cover image at .vitepress/epub/cover.png and update config to reference it
  • Bold fonts: Introduce CJK Bold (NotoSerifSC wght:700) and Sans Bold (Inter wght:700) for both EPUB and PDF
    • Update font configuration, CSS @font-face declarations, and EPUB packaging
    • Apply pseudo-bold rendering in PDF via fillThenStroke mode
  • CJK markdown bold fix: Implement fixCjkStrong() to convert **text**<strong>text</strong> where CommonMark flanking rules fail with CJK punctuation/text
    • Applied to both EPUB preprocessing and PDF preprocessing pipelines
    • Preserves code block safety by skipping fence boundaries and inline code
  • PNG optimization: Increase compression level to 9 with effort 10
  • PDF layout improvements:
    • Implement orphan/widow protection (MIN_TAIL_ROWS) for code blocks
    • Add smart page breaks for images to prevent physical cropping
    • Preserve code block integrity by moving small blocks to next page if needed

Why

Cover image improves presentation. Bold fonts enable proper typography (especially critical for CJK titles and emphasis) without reader pseudo-bold. CJK markdown fix resolves parsing failure for adjacent strong emphasis. Layout improvements prevent content loss in PDF rendering.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces bold font support for EPUB generation, implements a preprocessor to fix CJK bold parsing issues, and improves PDF rendering with pseudo-bolding, orphan protection for code blocks, and better image page-breaking. The review feedback suggests improving the markdown fence parsing and bold text regex in the preprocessor to support nested blocks and single asterisks (crucial for Zig pointers), parallelizing font downloads to optimize build performance, and removing a redundant layout check in the PDF renderer.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +191 to +207
export function fixCjkStrong(md: string): string {
let inFence = false;
let fenceMark = "";
return md
.split(/\r?\n/)
.map((ln) => {
const fence = ln.match(/^\s*(```+|~~~+)/);
if (fence) {
if (!inFence) {
inFence = true;
fenceMark = fence[1][0];
} else if (fence[1][0] === fenceMark) {
inFence = false;
fenceMark = "";
}
return ln;
}

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;
      }

Comment thread course/.vitepress/epub/preprocess.ts Outdated
codeSpans.push(m);
return `\u0000${codeSpans.length - 1}\u0000`;
});
s = s.replace(/\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g, "<strong>$1</strong>");

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 regex [^*\n]+? prevents matching any bold text that contains a single asterisk * (such as pointer types or dereferences like **指针 *p** or **类型 *T**). Since this is a Zig programming course, pointer notation is extremely common. We can improve the regex to allow single asterisks inside bold text by using (?:[^*\n]|\*(?!\*))+? instead.

Suggested change
s = s.replace(/\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g, "<strong>$1</strong>");
s = s.replace(/\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g, "<strong>$1</strong>");

Comment thread scripts/pdf/parse.ts
Comment on lines +160 to +174
export function fixCjkStrong(lines: string[]): string[] {
let inFence = false;
let fenceMark = "";
return lines.map((ln) => {
const fence = ln.match(/^\s*(```+|~~~+)/);
if (fence) {
if (!inFence) {
inFence = true;
fenceMark = fence[1][0];
} else if (fence[1][0] === fenceMark) {
inFence = false;
fenceMark = "";
}
return ln;
}

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(lines: string[]): string[] {
  let inFence = false;
  let fenceChar = "";
  let fenceLength = 0;
  return lines.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;
    }

Comment thread scripts/pdf/parse.ts
Comment on lines +183 to +186
protectedLine = protectedLine.replace(
/\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g,
"<strong>$1</strong>",
);

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 regex [^*\n]+? prevents matching any bold text that contains a single asterisk * (such as pointer types or dereferences like **指针 *p** or **类型 *T**). Since this is a Zig programming course, pointer notation is extremely common. We can improve the regex to allow single asterisks inside bold text by using (?:[^*\n]|\*(?!\*))+? instead.

Suggested change
protectedLine = protectedLine.replace(
/\*\*(?!\s)([^*\n]+?)(?<!\s)\*\*/g,
"<strong>$1</strong>",
);
protectedLine = protectedLine.replace(
/\*\*(?!\s)((?:[^*\n]|\*(?!\*))+?)(?<!\s)\*\*/g,
"<strong>$1</strong>",
);

Comment thread course/.vitepress/epub/fonts.ts Outdated
Comment on lines +58 to +72
// 先串行预热下载(填充磁盘缓存),避免 normal/bold 共享同一 fileName 时并发写缓存的竞态。
// 由于同一字体文件的下载被 download() 缓存,这里只会实际下载三个原始可变字体。
const cjk = await buildOne(config.fonts.cjk, usedText, config.cacheDir);
const cjkBold = await buildOne(config.fonts.cjkBold, usedText, config.cacheDir);
const sans = await buildOne(config.fonts.sans, usedText, config.cacheDir);
const sansBold = await buildOne(
config.fonts.sansBold,
usedText,
config.cacheDir,
);
const mono = await buildOne(
config.fonts.mono,
asciiText + usedText,
config.cacheDir,
);

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.

medium

Currently, the font files are prepared sequentially to avoid concurrent write race conditions on the same file name. However, this serial execution slows down the build process significantly because downloading and subsetting are done one by one. We can parallelize the preparation by first downloading all unique font files in parallel (which populates the cache safely), and then running the CPU-intensive subsetting (buildOne) in parallel using Promise.all.

Suggested change
// 先串行预热下载(填充磁盘缓存),避免 normal/bold 共享同一 fileName 时并发写缓存的竞态。
// 由于同一字体文件的下载被 download() 缓存,这里只会实际下载三个原始可变字体。
const cjk = await buildOne(config.fonts.cjk, usedText, config.cacheDir);
const cjkBold = await buildOne(config.fonts.cjkBold, usedText, config.cacheDir);
const sans = await buildOne(config.fonts.sans, usedText, config.cacheDir);
const sansBold = await buildOne(
config.fonts.sansBold,
usedText,
config.cacheDir,
);
const mono = await buildOne(
config.fonts.mono,
asciiText + usedText,
config.cacheDir,
);
// 先并行下载所有唯一的原始字体文件以填充缓存,避免并发写入竞态
await Promise.all([
download(config.fonts.cjk.url, path.join(config.cacheDir, config.fonts.cjk.fileName)),
download(config.fonts.sans.url, path.join(config.cacheDir, config.fonts.sans.fileName)),
download(config.fonts.mono.url, path.join(config.cacheDir, config.fonts.mono.fileName)),
]);
// 随后并行执行子集化,充分利用并发性能
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),
]);

Comment thread scripts/pdf/renderer.ts Outdated
Comment on lines +545 to +549
if (this.y + drawH + 4 > A4.h - MARGIN.bottom) {
this.newPage();
} else {
this.ensureSpace(drawH + 4);
}

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.

medium

The if-else block here is redundant. this.ensureSpace(drawH + 4) already checks if the remaining space is insufficient and calls this.newPage() internally. Therefore, we can simplify this block to a single call to this.ensureSpace(drawH + 4).

    this.ensureSpace(drawH + 4);

Comment thread scripts/pdf/renderer.ts Outdated
if (!this._dry) this.doc.text(piece, x, curY);
if (!this._dry) {
if (bold) {
// 伪粗体:用填充+描边模式加粗笔画(无需额外 bold 字体)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚫 [AutoCorrect Lint] <AutoCorrect> reported by reviewdog 🐶

Suggested change
// 伪粗体:用填充+描边模式加粗笔画(无需额外 bold 字体)
// 伪粗体:用填充 + 描边模式加粗笔画(无需额外 bold 字体)

修复 `bun check`(prettier)与 AutoCorrect CI。两者在 main 分支均已是
红的:prettier 从旧版升到 3.8.4 后产生格式漂移(7 个文件),autocorrect
有 8 处 CJK 间距/标点遗留问题;本 PR 另引入 1 处(renderer.ts 的
"填充+描边" 注释)。全部为换行/缩进与 CJK 间距调整,无逻辑变更。
- fixCjkStrong(epub/pdf):按 CommonMark 规则跟踪围栏字符与长度,
  正确处理变长/嵌套围栏(如 4 反引号内的 3 反引号),避免提前闭合。
- fixCjkStrong 加粗正则允许内部出现单个 *(如 Zig 指针 *T / *p),
  此前 [^*\n]+? 会漏配含指针记法的加粗。
- renderer 图片换页:if-else 与 ensureSpace 等价,简化为单次 ensureSpace。
- fonts:按 fileName 去重并行预热下载后再并行子集化,替代串行(消除写竞态同时提速)。

14 项 fixCjkStrong 边界用例通过;EPUB/PDF 重建无回归(图片不裁切、加粗正常)。
@jinzhongjia jinzhongjia merged commit 3b13f87 into main Jun 23, 2026
5 checks passed
@jinzhongjia jinzhongjia deleted the placid-bobcat branch June 23, 2026 16:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant