YSU
YSU 内核

CSS 解析

把记号流组织成规则与声明。1212 行,27 条单元测试。

把记号流组织成规则与声明。1212 行,27 条单元测试。

产物

pub struct StyleSheet {
    pub rules: Vec<Rule>,
    pub font_faces: Vec<FontFace>,
}

pub struct Rule {
    pub selectors: Vec<ComplexSelector>,
    pub declarations: Vec<Declaration>,
}

pub struct Declaration {
    pub property: String,
    pub value: PropertyValue,
    pub important: bool,
}

Rule 是「选择器列表 + 声明表」。一条规则带多个选择器时(h1, h2 { })它们在解析阶段就被放进同一个 Rule——它们的声明表完全一样,拆开只会让层叠那边多遍历几遍。

!important 在这里就记下来了,不在层叠阶段再解析一遍。

值的分类

pub enum PropertyValue {
    Keyword(String),        // 已转小写
    Length(Length),
    Number(f64),
    Angle(f64),             // 单位是度
    Color(Color),
    String(String),
    Url(String),
    LengthList(Vec<Length>),   // margin: 1px 2px
    NumberList(Vec<f64>),
    KeywordList(Vec<String>),  // font-family: A, B
    MixedList(Vec<PropertyValue>),  // border: 1px solid red
}

「能归类的都归了类,归不了的保留原始记号」。这句话是这一层的设计意图:颜色、长度、角度这些常见的值在解析阶段就变成结构化的类型,后面的层叠与布局拿到的是 Length 而不是一个字符串。

取值用一组 as_* 方法:

impl PropertyValue {
    pub fn as_keyword(&self) -> Option<&str>;
    pub fn as_length(&self) -> Option<Length>;
    pub fn as_number(&self) -> Option<f64>;
    pub fn as_color(&self) -> Option<Color>;
    pub fn as_length_list(&self) -> Option<Vec<Length>>;
    pub fn as_keyword_list(&self) -> Option<Vec<String>>;
}

有两个宽松处理值得注意:

as_length 把纯数字零当成零长度。 margin: 0 里的 0 在词法层是 Number,不是 Dimension(没有单位)。规范允许 0 不带单位,所以这里兜了一下。

as_length_list 把单个长度包装成长度为一的列表。 margin: 1pxmargin: 1px 1px 1px 1px 等价的那些简写规则因此不用在调用方各写一遍。

as_number 会把无单位长度里的数值也取出来。 这一条有个副作用:line-height: 20px20px 会被当数字 20 取出来。见下面「已知问题」。

长度单位

pub enum LengthUnit {
    None, Px, Em, Rem, Vw, Vh, Vmin, Vmax, Percent,
    Pt, Pc, In, Cm, Mm, Q, Ch, Ex,
    Auto,
}

绝对单位有换算表:

单位像素
px1
pt4/3
pc16
in96
cm96/2.54
mm96/25.4
q96/101.6

相对单位(emremvwvhvminvmax%)在解析阶段保留原样,换算留给布局——它们要知道上下文才能算。

Auto 放在 LengthUnit 里是有意的:

/// `auto`。
///
/// 它不是长度,但在「这一边交给布局自己决定」这个意义上和长度同处一个
/// 位置,所以放在这里当一种单位。解析成像素时是零,需要区分的地方用
/// [`Length::is_auto`] 判断——外边距的居中就是靠它区分的。

这一条解决的是 margin: 0 automargin: 0 分不开的问题。前者要居中,后者要贴左,两者在「解析成像素」之后都是零。

at 规则

解析器只分派两种:

"media" => { /* 就地按当前视口求值,满足条件的规则提升到外层 */ }
"font-face" => { /* 收进字体表 */ }

@media 在解析阶段就求值。 MediaContext 带当前视口宽高,规则满足条件时它的内容直接提升到外层,不满足就整块丢掉。这样后面的层叠不用再管媒体查询。

pub struct MediaContext {
    pub width: f64,
    pub height: f64,
}

pub fn parse_stylesheet(css: &str) -> StyleSheet;
pub fn parse_stylesheet_with_media(css: &str, media: MediaContext) -> StyleSheet;

@font-face 收进 FontFace(family、weight、style、source 四项),但 font_faces 目前没有消费方——字体文件既没下载也没注册。见已知限制

其余的 at 规则整块跳过

at 规则现在
@import不处理,后面的内容被跳过
@supports不处理
@namespace不处理
@keyframes不处理
@page不处理

跳过是安全的——不影响静态排版的东西丢掉之后,页面只是少了那些效果,不会排错。

@media only screen 有一个具体的坑。 only 关键字没有分支,only screen 既不等于 screen 也不等于 all,整条查询恒不匹配。而这是现实页面里最常见的写法之一。见已知限制

声明级的错误恢复

一条声明解析失败时,解析器跳到下一个分号继续,而不是放弃整条规则。所以:

p { color: red; bogus: ; background: blue }

bogus 那条被丢掉,colorbackground 正常生效。

这条恢复逻辑是有意做出来的,不是碰巧——写错一条声明让整条规则失效,会让调试变得很难。

已知问题

line-height: 20px 会被静默忽略。 as_number 对带单位的长度返回 None,而 line-height 的解析只吃无单位数字与 normal。结果是这样写不生效,也不报错,line-height 保持原值。

1e3 的归属不一致。 词法层把它读成数字 1000,1e 又退回单位 e。见 CSS 词法

未闭合字符串照样生效。 规范要求产出 BadString 并丢弃整条声明,现在照样返回可用的值。

接口

impl StyleSheet {
    pub fn parse(css: &str) -> Self;
    pub fn extend(&mut self, other: StyleSheet);
}

extend 把另一份样式表并进来。外部样式表取回之后就是靠它逐份并入的。

测试

27 条,覆盖规则与声明的解析、各简写形式的展开、媒体查询的匹配、错误恢复的几个分支、!important 的识别、以及值的分类。

On this page