前端技术
#CSS#font-palette#COLR#彩色字体#主题切换

CSS font-palette 如何为彩色字体选择调色板:从 COLR 表到主题切换

彩色字体把字形轮廓与颜色分开存储,调色板则决定每个颜色槽取什么值。文章以品牌图标字体随主题切换配色为例,说明 COLR/CPAL 表如何携带多套调色板、font-palette 如何按索引或名称选择、@font-palette-values 如何用 override-colors 覆盖颜色,并对比 SVG 图标方案在缓存与渲染上的差异与回退边界。

品牌站常把一组图标做成图标字体,用 <i> 元素加类名渲染。设计稿要求同一套图标在浅色和深色主题下换配色:浅色主题用深蓝主色配浅灰描边,深色主题用亮青主色配深灰底。最直接的做法是准备两套字体文件,用媒体查询切换 font-family。这个基线方案能跑通,但它把“形状”复制了两份,两套字体的字形轮廓完全相同,只有颜色不同。彩色字体规范提供了另一条路径:字形只存一份,颜色槽由调色板提供,切换主题只改调色板引用。

要理解这条路径,需要先区分两个概念。彩色字体(color font)指的是字形本身携带颜色信息,而不是由 color 属性统一着色。调色板(palette)是一组有序的颜色值,每个颜色值有一个索引,字形通过索引引用颜色,而不是把颜色写死在轮廓里。本文围绕一个具体场景展开:一个品牌图标字体包含 3 套调色板,页面需要在浅色、深色和高对比度三种主题下切换图标配色,同时保留在彩色字体不可用或调色板缺失时回退到单色字体的能力。

字形与颜色为什么要分开存储

单色字体里,字形的轮廓只描述形状,填充色由渲染时的 color 决定。同一个字形可以随文字颜色变化,因为颜色不在字体文件里。彩色字体的目标是让字形自带多色效果,比如 emoji 或彩色图标,但规范并不希望把颜色焊死在轮廓上,否则换配色就得改字体。

OpenType 用两张表分工。COLR 表描述字形的分层结构:一个基字形由若干层组成,每层引用一个字形轮廓,并指定该层使用哪个调色板索引。CPAL 表存放调色板本身:一组颜色条目,按索引排列。COLR 层里的颜色引用指向 CPAL 的索引,渲染时用当前选中调色板在该索引处的颜色填充这一层。这样,形状在 COLR 里,颜色在 CPAL 里,二者通过索引解耦。

这个解耦带来一个直接结果:同一个字体文件可以携带多套 CPAL 调色板。字体制作者可以准备一套浅色背景用的调色板、一套深色背景用的调色板,甚至更多。选择哪一套由 CSS 决定,字体文件不需要为每个主题各存一份轮廓。

需要说明的是,COLR 有多个版本。COLR v0 的分层模型相对简单,每层是一个字形加一个颜色索引;后续版本扩展了渐变、变换和合成等能力。本文讨论的调色板选择机制对 v0 和更高版本都适用,但具体字形能表达的效果取决于字体使用的版本,这一点应以字体本身的构建结果为准。

font-palette 如何按索引或名称选中调色板

font-palette 属性决定用户代理从字体的多套调色板中选用哪一套。它的取值分几类。

normal 使用字体制作者设定的默认调色板,规范明确它渲染字体中索引 0 的调色板。lightdark 不是索引,而是语义标签:它们要求用户代理选取字体中第一个被标记为适用于浅色或深色背景的调色板。这里有一个容易踩的边界:如果字体没有携带这类元数据,lightdark 的行为等同于 normal。也就是说,写 font-palette: dark 并不保证一定切到深色配色,它依赖字体是否声明了相应元数据。

第三类取值是 <palette-identifier>,形式是一个 <dashed-ident>,例如 --brand-dark。它指向一条 @font-palette-values 规则,由作者自定义调色板。这类标识符看起来像 CSS 自定义属性,但用法不同:它不包在 var() 里,而是直接作为 font-palette 的值。

第四类是 palette-mix(),把两个 font-palette 值按百分比和颜色插值方法混合,生成一个新的调色板。它适合做过渡或中间态,但本文场景只需要离散切换,暂不展开。

font-palette 是继承属性,初始值为 normal,适用于所有元素和文本,也适用于 ::first-letter::first-line。继承这一点在工程上很重要:在根元素或容器上设置一次,内部文本会继承,不需要给每个图标元素单独声明。

还有一个优先级细节值得记住:调色板在着色字体时优先于 color 属性。即使给 color 加了 !important,也不会覆盖字体调色板。这意味着彩色字体的图标颜色不能靠改 color 来调整,必须走调色板路径。

@font-palette-values 与 override-colors 的覆盖规则

@font-palette-values 规则让作者在不修改字体文件的前提下定制调色板。它有一个 <dashed-ident> 名称,内部包含若干描述符。

font-family 描述符指定这条规则作用于哪个字体族。规范要求必须提供 font-family,否则规则无效。这一点在多个字体混用的页面里尤其关键:调色板标识符本身不携带字体信息,绑定关系由 font-family 描述符建立。

base-palette 描述符指定以哪一套字体内置调色板为基底,可以写名称或索引。override-colors 描述符指定要覆盖的颜色,格式是“索引 颜色”的列表,例如 override-colors: 0 #00ffbb, 1 #007744;

覆盖规则里有两个边界需要分清。第一,如果覆盖的是 normal 或索引 0 的基底调色板,不需要声明 base-palette;只有在覆盖其他基底调色板时才需要显式指定。第二,如果覆盖了全部颜色,也不需要指定 base-palette,因为基底已经无关紧要。

回到品牌图标场景。假设字体内置 3 套调色板:索引 0 是默认,索引 1 面向深色背景,索引 2 面向高对比度。浅色主题可以直接用 normal,深色主题如果字体声明了 dark 元数据可以用 dark,但更可控的做法是用 @font-palette-values 明确指定基底并覆盖关键颜色。这样即使字体元数据缺失,配色也不会意外退回默认。

覆盖时只需要改真正需要变的颜色槽。如果主色在索引 0、描边在索引 1,那么只覆盖这两个索引即可,其余颜色沿用基底。覆盖越少,字体升级后颜色槽含义变化的概率越低。

主题切换的完整流程

把上述机制串起来,页面加载到图标呈现的流程如下。

flowchart TD
    A[加载彩色字体文件] --> B[解析 COLR 分层与 CPAL 调色板]
    B --> C[CSS 匹配 font-palette 取值]
    C --> D{取值类型}
    D -->|normal| E[使用索引 0 调色板]
    D -->|light 或 dark| F[查找带对应元数据的调色板]
    D -->|dashed-ident| G[应用 @font-palette-values 规则]
    F --> H{字体是否声明元数据}
    H -->|否| E
    H -->|是| I[使用匹配到的调色板]
    G --> J[以 base-palette 为基底]
    J --> K[按 override-colors 替换指定索引]
    E --> L[按索引填充 COLR 各层]
    I --> L
    K --> L
    L --> M[合成并绘制图标]

流程里有两个分支点值得注意。light/dark 分支依赖字体元数据,缺失时回落到索引 0;dashed-ident 分支则完全由作者控制,不依赖字体是否声明语义标签。工程上如果要求主题切换结果确定,用 @font-palette-values 比依赖 light/dark 更稳。

主题切换本身由媒体查询或类名触发。用 prefers-color-scheme 时,可以在媒体查询内改 font-palette;用类名切换时,在对应选择器里改。由于 font-palette 继承,只需在容器或根元素上改一次。

与 SVG 图标方案的缓存和渲染对比

图标字体不是唯一选择,内联或外链 SVG 同样能实现多色图标和主题切换。两者在缓存和渲染上的差异决定了适用场景。

维度彩色图标字体SVG 图标
颜色存储颜色在 CPAL 表,字形按索引引用颜色写在 SVG 的 fill/stroke 或 CSS 中
主题切换方式切换 font-palette 或覆盖颜色槽切换 CSS 变量或类名改 fill/stroke
缓存粒度一个字体文件覆盖全部图标,改配色通常不需重新下载每个图标是独立资源,或需打包成 sprite
单图标增量新增图标需重新生成字体子集可单独增删某个图标文件
渲染路径走字体渲染,字形可被文本相关属性影响走图像/矢量绘制,不受字体属性影响
可访问性图标作为文本字符,需处理语义与读屏可加 title/aria 属性,语义更直接
颜色覆盖优先级调色板优先于 color,不能靠 color 改色fill/stroke 由 CSS 正常控制
回退复杂度需准备单色字体或文本回退可直接换成单色 SVG 或隐藏

缓存方面,字体方案的优势在于图标集合整体缓存。品牌图标通常成套使用,一个字体文件命中缓存后,所有图标都不再产生额外请求。代价是任何图标增删都要重新生成字体子集,缓存键随之变化,用户需要重新下载整个子集。SVG 方案按图标或 sprite 缓存,增删单个图标影响面小,但图标数量多时请求数或 sprite 体积会上升。

渲染方面,字体图标走字体渲染路径,字形受字体相关属性影响,比如字号、字重、字体特性。这在纯图标场景里通常是负担而非优势。SVG 走矢量绘制,颜色由 fill/stroke 控制,与 CSS 颜色体系一致,主题切换可以直接复用已有的 CSS 变量。

可访问性差异也值得纳入决策。图标字体的字符对读屏软件是文本,需要额外处理语义,否则可能读出无意义的字符。SVG 可以携带 title 或 ARIA 属性,语义表达更直接。如果图标承担交互功能,这一点往往比缓存收益更重要。

回退边界与常见失败模式

彩色字体方案在几种情况下会退化,需要提前设计回退。

字体不支持彩色或调色板缺失时,font-palette 的取值可能落到 normal 或索引 0。如果索引 0 是单色轮廓,图标会以单色呈现;如果字体完全没有彩色能力,font-palette 不产生可见效果。回退策略可以是准备一份单色字体,在 @font-facesrc 列表里作为后备,或者直接用文本或 SVG 替代。

light/dark 依赖字体元数据,这是最容易被忽略的失败点。设计稿要求深色主题换配色,但字体没有声明 dark 元数据,font-palette: dark 静默等同于 normal,页面看起来“没生效”。排查时先确认字体是否携带相应元数据,再决定是否改用 @font-palette-values 显式指定。

override-colors 的索引与字体颜色槽含义绑定。字体升级后如果颜色槽顺序变化,覆盖会作用到错误的层,出现颜色错乱。缓解方式是尽量少覆盖、只覆盖语义明确的关键槽,并在字体升级时回归验证。

@font-palette-values 缺少 font-family 描述符时规则无效,font-palette 会回落到其他取值。多字体页面里,如果调色板标识符和字体族的绑定写错,覆盖不会生效。

color 属性无法覆盖调色板,这是与单色字体最大的行为差异。习惯用 color 调图标颜色的代码迁移到彩色字体后会发现改不动,必须改走调色板路径。

可观测信号方面,可以在开发阶段检查计算后的 font-palette 值,确认它落在预期的标识符上;对比不同主题下的实际渲染结果,确认颜色槽覆盖正确;在字体加载失败或回退字体生效时,确认图标仍可辨识。

适用边界与决策建议

彩色字体加 font-palette 的适用条件是:图标成套使用、形状稳定、主题数量有限且配色可由颜色槽表达。满足这些条件时,它把配色从字形里抽出来,主题切换只改调色板引用,缓存粒度是整套图标。

不适用的情况也清楚。图标需要频繁增删、每个图标有独立配色逻辑、或图标承担重要可访问性语义时,SVG 方案更直接。图标需要复杂渐变、滤镜或动画时,字体能表达的效果受 COLR 版本限制,SVG 的表达空间更大。

如果选择字体方案,建议优先用 @font-palette-values 显式定义主题调色板,而不是依赖 light/dark 元数据,因为前者不依赖字体是否声明语义标签。覆盖颜色时只改关键槽,保留基底。同时准备单色回退,并确认图标在回退状态下仍可辨识。

浏览器支持方面,font-palette@font-palette-values 自 2022 年 11 月起已在主流浏览器中得到支持,但规范中部分能力(如 palette-mix() 的完整实现)的支持程度可能不同。使用前应针对目标浏览器确认具体取值的可用性,尤其是动画和混合相关能力,不要把规范草案里的全部语法当作已普遍实现的行为。

资料来源

  1. MDN: font-palette
  2. MDN: @font-palette-values
  3. CSS Fonts Module Level 4: font-palette
  4. font-palette - CSS:层叠样式表 | MDN