StreamPDFStreamPDF
返回博客

PDF.js 源码解读: css_utils.js

2026年10月27日 · StreamPDF Team

PDF.js 源码解读: css_utils.js

前言

本篇分析 src/shared/css_utils.js,这是一个位于 PDF 字体数据与浏览器 CSS 边界上的小型模块。

尽管文件名很宽泛,当前源码中没有 CSS 单位转换、缩放计算或变换矩阵辅助函数。它实际聚焦在一个更窄、更明确的问题上:对字体族名称进行序列化,使源自 PDF 的数据能够作为 CSS 字符串使用,而不改变周围 CSS 的语法结构。

这个边界很重要。字体名不一定是安全标识符:它可能包含引号、反斜杠、控制字符,甚至正好是 inherit 这样的关键字。若把它直接传给 FontFace 构造函数或插入 @font-face 规则,就会混淆数据与语法。本模块定义了这些边界共同使用的序列化契约。

模块总览

文件包含一个常量、一个私有谓词和一个公开序列化函数:

区块行号职责
CONTROL_CHAR_REGEXP16识别 Unicode Cc 类别中的控制字符
isCSSString25-50判断现有值能否作为 CSS 字符串安全地原样复用
serializeFontFamily58-74返回经过安全引用与转义的字体族值
导出76导出控制字符正则与序列化函数,谓词保持私有

公开契约刻意保持很小:输入 JavaScript 字符串,输出表示一个 CSS 字符串 token 的源码文本。该函数不负责加载字体、选择回退字体、规范化字体度量,也不计算任何视觉几何信息。

CONTROL_CHAR_REGEXP 与 isCSSString():判断何时可以原样复用

const CONTROL_CHAR_REGEXP = /\p{Cc}/u;

Unicode 属性转义针对 Cc 类别,即控制字符类别。正则没有全局标志,因此重复调用 .test() 不会携带 lastIndex 状态。它还会被导出,供 src/core/core_utils.js 中相关的字体名验证路径复用。

isCSSString 是私有快速路径的守卫:

function isCSSString(str) {
  const quote = str[0];
  if (str.length < 2 || (quote !== `"` && quote !== `'`) || str.at(-1) !== quote) {
    return false;
  }
  const end = str.length - 1;
  for (let i = 1; i < end; i++) {
    const char = str[i];
    if (char === quote || CONTROL_CHAR_REGEXP.test(char)) {
      return false;
    }
    if (char === '\\') {
      if (++i >= end || CONTROL_CHAR_REGEXP.test(str[i])) {
        return false;
      }
    }
  }
  return true;
}

这些检查构成一个聚焦的契约,而不是通用 CSS 解析器:

  • 值必须以相同的单引号或双引号开始和结束;
  • 字符串主体中未转义的同类引号会被拒绝;
  • 控制字符即使跟在反斜杠后面也会被拒绝;
  • 扫描时反斜杠会消费后一个字符,因此转义引号不会被误认为分隔符;
  • 紧邻收尾引号的反斜杠会被拒绝,因为它会转义这个收尾引号。

空的带引号字符串可以通过,另一种引号则可以在字符串内部不转义出现。对于这个聚焦的谓词,这些都是合理结果。如果值不是可复用的 CSS 字符串,序列化函数就会走更安全的处理路径,而不是尝试修补原有形式。

serializeFontFamily():生成 CSS 字符串 token

function serializeFontFamily(fontFamily) {
  if (isCSSString(fontFamily)) {
    return fontFamily;
  }
  const escaped = fontFamily.replaceAll(/["\\\p{Cc}]/gu, char =>
    char === `"` || char === '\\' ? `\\${char}` : `\\${char.codePointAt(0).toString(16)} `,
  );
  return `"${escaped}"`;
}

这里有两条路径。已经满足 isCSSString 的值会原样返回,保留单引号或双引号形式。其他所有值都会被视为字体族数据,经过转义后再包上双引号。因此结果一定是 CSS 字符串 token,但不一定是新构造的字符串。

为什么使用带引号的字符串,而不是自定义标识符?

CSS 也允许用标识符序列书写字体族,但这种表示有额外的语法和关键字规则。PDF 中一个字面名称为 serif 的字体不能悄悄变成通用字体族选择器,名为 inherit 的字体也不能变成 CSS 全局关键字。带引号的字符串能把名称保留为数据,避免这两种解释。

这里做的是序列化,不是 HTML 转义或 URL 编码。输出面向 CSS 消费者,不能直接插入任意 HTML 或 JavaScript 上下文。

转义规则与具体结果

回退路径只处理双引号输出中需要特殊处理的字符:

  • " 变为 \";
  • \ 变为 \\;
  • 每个 Cc 控制字符变为一个后随空格的十六进制 CSS 转义。

末尾空格用于终止十六进制转义。如果没有它,后续十六进制数字可能会被并入转义序列,进而改变字体名。十六进制转义也避免依赖“反斜杠 + 字面换行符”,后者在 CSS 中具有不同语义。

下面的例子展示 CSS 源码文本;A<newline>B 表示值中含有一个真实换行符:

字体族值                    输出的 CSS 文本
Acme                       "Acme"
A"B                        "A\"B"
A\B                        "A\\B"
A<newline>B                "A\a B"

对于 "Acme" 这种已经带引号的值,函数会原样返回,而不是再包一层引号。

调用契约:在每个 CSS 边界使用同一个字体族名

同一个源自 PDF 的字体族名会通过不同 API 进入 CSS,各处都使用这个序列化函数:

调用方返回值用途共享契约的意义
FontFaceLoader.createNativeFontFacenew FontFace(...) 的字体族参数以序列化后的字体族名注册原生字体
FontFaceLoader.createFontFaceRule@font-face 规则中的 font-family防止 PDF 数据突破声明边界并注入 CSS
XFA setFontFamilystyle.fontFamily,包括替代字体的族名与字体注册使用相同表示,确保名称匹配
validateFontName复用 CONTROL_CHAR_REGEXP,而非序列化函数在核心侧验证阶段拒绝不合适的带引号名称

最后一行体现了清晰的职责划分:验证决定核心路径是否接受这个名称,序列化则负责在 CSS 输出边界安全表示名称。两者不能互相替代。

总结

当前的 css_utils.js 是字体族序列化模块,不是通用 CSS 几何工具箱。它的设计建立在四个明确决策上:

  1. 只有 isCSSString 证明分隔符和转义安全时,才原样复用现有值。
  2. 其他情况一律输出带引号的 CSS 字符串,避免被解释为通用字体族或 CSS 全局关键字。
  3. 按 CSS 字符串语法转义引号、反斜杠和控制字符,并保留十六进制转义必需的终止空格。
  4. 在 FontFace、@font-face 与 XFA 样式间共享同一序列化,让字体注册与消费对字体族名达成一致。

理解这个契约,比把文件名想象成一组无关 CSS 工具更有价值:模块只保护一个特定的“数据到语法”边界,并且不承担渲染或布局职责。

StreamPDF 的设计原则: 能前置处理的绝对不放到解析的时候才做, 由服务端一次解决所有性能问题. 浏览器只获取&渲染必须数据.

相关文章