跳转至

样式系统 API

样式模块负责 CSS 属性的解析、注册和计算。

模块概览

子模块 职责
style.properties 属性注册表 (PROPERTY_REGISTRY)
style.parser 值解析器和特殊值类型
style.computed ComputedStyle 计算样式对象

ComputedStyle

ComputedStyle 是每个节点的样式容器,负责:

  • 存储显式设置的属性值
  • 提供属性继承(从父节点)
  • 计算最终使用值
from latticesvg import ComputedStyle

cs = ComputedStyle({"font-size": 24, "color": "red"})
print(cs.font_size)     # 24
print(cs.get("color"))  # "red"

自动生成的 API 文档

样式属性

properties

CSS property registry — default values, inheritance flags, and type hints.

PropertyDef dataclass
PropertyDef(default: Any, inheritable: bool = False, parser_hint: Optional[str] = None)

Definition of a single CSS property.

default instance-attribute
default: Any

Default (initial) value — raw CSS string or Python object.

inheritable class-attribute instance-attribute
inheritable: bool = False

Whether this property is inherited from the parent element.

parser_hint class-attribute instance-attribute
parser_hint: Optional[str] = None

Hint for the parser: 'length', 'color', 'keyword', 'track-list', etc.

get_default
get_default(prop: str) -> Any

Return the default value for a property, or None if unknown.

Source code in src/latticesvg/style/properties.py
def get_default(prop: str) -> Any:
    """Return the default value for a property, or ``None`` if unknown."""
    defn = PROPERTY_REGISTRY.get(prop)
    return defn.default if defn else None
is_inheritable
is_inheritable(prop: str) -> bool

Return whether a property is inherited from the parent.

Source code in src/latticesvg/style/properties.py
def is_inheritable(prop: str) -> bool:
    """Return whether a property is inherited from the parent."""
    defn = PROPERTY_REGISTRY.get(prop)
    return defn.inheritable if defn else False

值解析器

parser

CSS value parser — converts raw CSS strings to resolved Python values.

Handles units (px, %, em, fr), keywords, colors, and shorthand expansion.

FrValue dataclass
FrValue(value: float)

Represents an fr flexible-length value (CSS Grid).

AutoValue

Singleton representing the auto keyword.

LineHeightMultiplier dataclass
LineHeightMultiplier(value: float)

A unitless line-height multiplier (e.g. 1.5).

CSS distinguishes line-height: 1.5 (multiplier, relative to font-size) from line-height: 24px (absolute). This wrapper preserves that distinction so downstream code does not need to guess.

resolve
resolve(font_size: float) -> float

Return the absolute line-height in px.

Source code in src/latticesvg/style/parser.py
def resolve(self, font_size: float) -> float:
    """Return the absolute line-height in px."""
    return self.value * font_size
MinContent dataclass
MinContent()

Sentinel for min-content.

MaxContent dataclass
MaxContent()

Sentinel for max-content.

MinMaxValue dataclass
MinMaxValue(min_val: Any, max_val: Any)

Represents a minmax(min, max) track sizing function.

GradientStop dataclass
GradientStop(color: str, position: Optional[float] = None)

A single color stop in a gradient.

LinearGradientValue dataclass
LinearGradientValue(angle: float = 180.0, stops: Tuple[GradientStop, ...] = ())

Parsed linear-gradient(...) value.

RadialGradientValue dataclass
RadialGradientValue(shape: str = 'ellipse', cx: float = 0.5, cy: float = 0.5, stops: Tuple[GradientStop, ...] = ())

Parsed radial-gradient(...) value.

BoxShadow dataclass
BoxShadow(offset_x: float = 0.0, offset_y: float = 0.0, blur_radius: float = 0.0, spread_radius: float = 0.0, color: str = 'rgba(0,0,0,1)', inset: bool = False)

A single box-shadow layer.

TransformFunction dataclass
TransformFunction(name: str, args: Tuple[float, ...] = ())

A single CSS transform function (e.g. rotate(45)).

FilterFunction dataclass
FilterFunction(name: str, args: Tuple[float, ...] = ())

A single CSS filter function (e.g. blur(5)).

ClipCircle dataclass
ClipCircle(radius: Any, cx: Any, cy: Any)

Parsed circle(radius at cx cy).

ClipEllipse dataclass
ClipEllipse(rx: Any, ry: Any, cx: Any, cy: Any)

Parsed ellipse(rx ry at cx cy).

ClipPolygon dataclass
ClipPolygon(points: tuple)

Parsed polygon(x1 y1, x2 y2, …).

ClipInset dataclass
ClipInset(top: Any, right: Any, bottom: Any, left: Any, round_radii: Optional[tuple] = None)

Parsed inset(top right bottom left round r).

AreaMapping dataclass
AreaMapping(areas: Dict[str, Tuple[int, int, int, int]], num_rows: int, num_cols: int)

Parsed result of grid-template-areas.

ATTRIBUTE DESCRIPTION
areas

Indices are 0-based.

TYPE: dict mapping area name → (row_start, col_start, row_span, col_span)

num_rows

Number of rows implied by the template.

TYPE: int

num_cols

Number of columns implied by the template.

TYPE: int

parse_value
parse_value(raw: Any, *, reference_length: Optional[float] = None, font_size: Optional[float] = None, root_font_size: Optional[float] = None) -> Any

Parse a single CSS value string into a resolved Python value.

PARAMETER DESCRIPTION
raw

The raw value. Non-string types are returned as-is (numbers) or processed (lists).

TYPE: str | int | float | list | Any

reference_length

The reference length for resolving % values.

TYPE: float DEFAULT: None

font_size

Current computed font-size for resolving em units.

TYPE: float DEFAULT: None

root_font_size

Root element font-size for resolving rem units (default 16).

TYPE: float DEFAULT: None

RETURNS DESCRIPTION
Resolved value — float (px), FrValue, AutoValue, MinContent, MaxContent,
str (color / keyword), or list.
Source code in src/latticesvg/style/parser.py
def parse_value(
    raw: Any,
    *,
    reference_length: Optional[float] = None,
    font_size: Optional[float] = None,
    root_font_size: Optional[float] = None,
) -> Any:
    """Parse a single CSS value string into a resolved Python value.

    Parameters
    ----------
    raw : str | int | float | list | Any
        The raw value.  Non-string types are returned as-is (numbers) or
        processed (lists).
    reference_length : float, optional
        The reference length for resolving ``%`` values.
    font_size : float, optional
        Current computed font-size for resolving ``em`` units.
    root_font_size : float, optional
        Root element font-size for resolving ``rem`` units (default 16).

    Returns
    -------
    Resolved value — float (px), FrValue, AutoValue, MinContent, MaxContent,
    str (color / keyword), or list.
    """
    # Passthrough for already-resolved types
    if isinstance(raw, (int, float)):
        return float(raw)
    if isinstance(raw, (FrValue, AutoValue, MinContent, MaxContent)):
        return raw
    if isinstance(raw, list):
        return [
            parse_value(v, reference_length=reference_length,
                        font_size=font_size, root_font_size=root_font_size)
            for v in raw
        ]

    if not isinstance(raw, str):
        return raw

    s = raw.strip()

    # --- Keywords ---
    lower = s.lower()
    if lower == "auto":
        return AUTO
    if lower == "min-content":
        return MIN_CONTENT
    if lower == "max-content":
        return MAX_CONTENT
    if lower in ("none", "normal", "nowrap", "pre", "pre-wrap", "pre-line",
                  "hidden", "visible", "scroll",
                  "left", "right", "center", "justify",
                  "start", "end", "stretch", "baseline",
                  "bold", "italic", "oblique",
                  "row", "column", "dense", "row dense", "column dense",
                  "contain", "cover", "fill",
                  "ellipsis", "clip",
                  "inherit", "initial", "unset"):
        return lower

    # --- Colors ---
    color = _parse_color(s)
    if color is not None:
        return color

    # --- Number + unit ---
    m = _RE_NUMBER_UNIT.match(s)
    if m:
        num = float(m.group(1))
        unit = (m.group(2) or "").lower()
        if unit == "" or unit == "px":
            return num
        if unit == "%":
            if reference_length is not None:
                return num / 100.0 * reference_length
            # Return a deferred percentage object
            return _Percentage(num)
        if unit in ("em", "rem"):
            if unit == "rem":
                rfs = root_font_size if root_font_size is not None else 16.0
                return num * rfs
            else:
                fs = font_size if font_size is not None else 16.0
                return num * fs
        if unit == "fr":
            return FrValue(num)
        if unit == "pt":
            return num * (4.0 / 3.0)  # 1pt = 4/3 px at 96dpi

    # --- Grid line spec  e.g. "1 / span 2" ---
    gm = _RE_GRID_LINE.match(s)
    if gm:
        start = int(gm.group(1))
        span = int(gm.group(2)) if gm.group(2) else 1
        return (start, span)

    # Font-family list (comma separated unquoted names)
    if "," in s:
        return [part.strip().strip("'\"") for part in s.split(",")]

    # Return as-is (e.g. font family single name)
    return s
expand_shorthand
expand_shorthand(prop: str, value: Any) -> Dict[str, Any]

Expand CSS shorthand properties into their longhands.

Returns a dict of {longhand_name: raw_value} pairs. If prop is not a shorthand, returns {prop: value}.

Source code in src/latticesvg/style/parser.py
def expand_shorthand(prop: str, value: Any) -> Dict[str, Any]:
    """Expand CSS shorthand properties into their longhands.

    Returns a dict of ``{longhand_name: raw_value}`` pairs.
    If *prop* is not a shorthand, returns ``{prop: value}``.
    """
    # --- Four-side shorthands ---
    if prop in _FOUR_SIDE_PROPS:
        top, right, bottom, left = _FOUR_SIDE_PROPS[prop]
        parts = _split_shorthand_parts(value)
        t, r, b, l = _expand_four(parts)
        return {top: t, right: r, bottom: b, left: l}

    # --- gap → row-gap + column-gap ---
    if prop == "gap":
        parts = _split_shorthand_parts(value)
        if len(parts) == 1:
            return {"row-gap": parts[0], "column-gap": parts[0]}
        return {"row-gap": parts[0], "column-gap": parts[1]}

    # --- border (simplified: width style color) ---
    if prop == "border":
        parts = _split_shorthand_parts(value)
        result: Dict[str, Any] = {}
        for p in parts:
            pv = parse_value(p)
            if isinstance(pv, (int, float)):
                for side in ("top", "right", "bottom", "left"):
                    result[f"border-{side}-width"] = p
            elif isinstance(pv, str) and (pv.startswith("#") or pv.startswith("rgb") or pv in NAMED_COLORS):
                for side in ("top", "right", "bottom", "left"):
                    result[f"border-{side}-color"] = p
            else:
                for side in ("top", "right", "bottom", "left"):
                    result[f"border-{side}-style"] = p
        return result if result else {prop: value}

    # --- Single-side border shorthands (P2-6) ---
    _SINGLE_SIDE_BORDERS = {
        "border-top": "top",
        "border-right": "right",
        "border-bottom": "bottom",
        "border-left": "left",
    }
    if prop in _SINGLE_SIDE_BORDERS:
        side = _SINGLE_SIDE_BORDERS[prop]
        parts = _split_shorthand_parts(value)
        result3: Dict[str, Any] = {}
        for p in parts:
            pv = parse_value(p)
            if isinstance(pv, (int, float)):
                result3[f"border-{side}-width"] = p
            elif isinstance(pv, str) and (pv.startswith("#") or pv.startswith("rgb") or pv in NAMED_COLORS):
                result3[f"border-{side}-color"] = p
            else:
                result3[f"border-{side}-style"] = p
        return result3 if result3 else {prop: value}

    # --- outline (simplified: width style color) ---
    if prop == "outline":
        parts = _split_shorthand_parts(value)
        result2: Dict[str, Any] = {}
        for p in parts:
            pv = parse_value(p)
            if isinstance(pv, (int, float)):
                result2["outline-width"] = p
            elif isinstance(pv, str) and (pv.startswith("#") or pv.startswith("rgb") or pv in NAMED_COLORS):
                result2["outline-color"] = p
            else:
                result2["outline-style"] = p
        return result2 if result2 else {prop: value}

    # --- border-radius → four corner longhands ---
    if prop == "border-radius":
        parts = _split_shorthand_parts(value)
        tl, tr, br, bl = _expand_four(parts)
        return {
            "border-top-left-radius": tl,
            "border-top-right-radius": tr,
            "border-bottom-right-radius": br,
            "border-bottom-left-radius": bl,
        }

    # --- background shorthand (P2-4) ---
    if prop == "background":
        if isinstance(value, str) and "gradient(" in value:
            return {"background-image": value}
        return {"background-color": value}

    # --- Not a shorthand ---
    return {prop: value}
parse_clip_path
parse_clip_path(raw: Any) -> Any

Parse a CSS clip-path value.

Supports circle(), ellipse(), polygon(), inset() function syntax. Returns the corresponding dataclass instance, or the string "none" for no clipping.

Source code in src/latticesvg/style/parser.py
def parse_clip_path(raw: Any) -> Any:
    """Parse a CSS ``clip-path`` value.

    Supports ``circle()``, ``ellipse()``, ``polygon()``, ``inset()``
    function syntax.  Returns the corresponding dataclass instance,
    or the string ``"none"`` for no clipping.
    """
    if raw is None or raw == "none":
        return "none"
    if not isinstance(raw, str):
        return "none"

    s = raw.strip()
    if s.lower() == "none":
        return "none"

    m = _RE_CLIP_FN.match(s)
    if not m:
        return "none"

    fn = m.group(1).lower()
    args = m.group(2).strip()

    if fn == "circle":
        return _parse_clip_circle(args)
    if fn == "ellipse":
        return _parse_clip_ellipse(args)
    if fn == "polygon":
        return _parse_clip_polygon(args)
    if fn == "inset":
        return _parse_clip_inset(args)

    return "none"
parse_track_template
parse_track_template(raw: Any, reference_length: Optional[float] = None) -> list

Parse a grid-template-columns / grid-template-rows value.

Accepts a list of strings (["200px", "1fr"]) or a single space-separated string ("200px 1fr").

Supports repeat(count, track_expr) and minmax(min, max) function syntax.

Source code in src/latticesvg/style/parser.py
def parse_track_template(raw: Any, reference_length: Optional[float] = None) -> list:
    """Parse a ``grid-template-columns`` / ``grid-template-rows`` value.

    Accepts a list of strings (``["200px", "1fr"]``) or a single
    space-separated string (``"200px 1fr"``).

    Supports ``repeat(count, track_expr)`` and ``minmax(min, max)``
    function syntax.
    """
    if isinstance(raw, str):
        tokens = _tokenize_track_list(raw)
    elif isinstance(raw, (list, tuple)):
        tokens = [str(v) if not isinstance(v, str) else v for v in raw]
    else:
        return [_parse_single_track_token(str(raw), reference_length)]

    result: List[Any] = []
    for tok in tokens:
        tok = tok.strip()
        m = _RE_REPEAT.match(tok)
        if m:
            count = int(m.group(1))
            inner = m.group(2).strip()
            inner_tokens = parse_track_template(inner, reference_length=reference_length)
            result.extend(inner_tokens * count)
        else:
            result.append(_parse_single_track_token(tok, reference_length))
    return result
parse_gradient
parse_gradient(raw: Any) -> Any

Parse a CSS gradient value.

Supports linear-gradient(...) and radial-gradient(...) with: - Direction angle (45deg) or keyword (to right) - Multiple color stops with optional percentage positions - rgb() / rgba() color functions inside stops

Returns a :class:LinearGradientValue or :class:RadialGradientValue, or "none" if the value cannot be parsed.

Source code in src/latticesvg/style/parser.py
def parse_gradient(raw: Any) -> Any:
    """Parse a CSS gradient value.

    Supports ``linear-gradient(...)`` and ``radial-gradient(...)`` with:
    - Direction angle (``45deg``) or keyword (``to right``)
    - Multiple color stops with optional percentage positions
    - ``rgb()`` / ``rgba()`` color functions inside stops

    Returns a :class:`LinearGradientValue` or :class:`RadialGradientValue`,
    or ``"none"`` if the value cannot be parsed.
    """
    if raw is None or raw == "none":
        return "none"
    if not isinstance(raw, str):
        return "none"

    s = raw.strip()

    # --- linear-gradient ---
    m = _RE_LINEAR_GRADIENT.match(s)
    if m:
        args = _split_gradient_args(m.group(1))
        if not args:
            return "none"

        angle = 180.0  # default: to bottom
        start_idx = 0

        first = args[0].strip().lower()
        # Check for direction keyword
        for kw, deg in _CSS_DIRECTION_MAP.items():
            if first == kw:
                angle = deg
                start_idx = 1
                break
        else:
            # Check for angle
            am = _RE_ANGLE.match(args[0])
            if am:
                angle = float(am.group(1))
                start_idx = 1

        stops = [_parse_color_stop(a) for a in args[start_idx:]]
        stops_final = _distribute_stop_positions(stops)
        return LinearGradientValue(angle=angle, stops=stops_final)

    # --- radial-gradient ---
    m = _RE_RADIAL_GRADIENT.match(s)
    if m:
        args = _split_gradient_args(m.group(1))
        if not args:
            return "none"

        shape = "ellipse"
        cx, cy = 0.5, 0.5
        start_idx = 0

        first = args[0].strip().lower()
        # Check for shape / position spec
        if "at" in first or first in ("circle", "ellipse"):
            start_idx = 1
            if "at" in first:
                parts = first.split("at", 1)
                shape_part = parts[0].strip()
                pos_part = parts[1].strip()
                if shape_part in ("circle", "ellipse"):
                    shape = shape_part
                elif shape_part == "":
                    shape = "ellipse"
                pos_tokens = pos_part.split()
                if len(pos_tokens) >= 1:
                    cx = _parse_position_pct(pos_tokens[0])
                if len(pos_tokens) >= 2:
                    cy = _parse_position_pct(pos_tokens[1])
            else:
                shape = first

        stops = [_parse_color_stop(a) for a in args[start_idx:]]
        stops_final = _distribute_stop_positions(stops)
        return RadialGradientValue(shape=shape, cx=cx, cy=cy, stops=stops_final)

    return "none"
parse_grid_template_areas
parse_grid_template_areas(raw: Any) -> Optional[AreaMapping]

Parse a grid-template-areas value.

The CSS syntax is a series of quoted row strings, e.g.::

"header header header"
"sidebar main main"
"footer footer footer"

A single dot . represents an empty cell (unnamed).

The value may be supplied as: * a single string with quoted substrings (CSS syntax) * a list of strings, each representing one row

Returns None if the value is None or "none". Raises ValueError on malformed input (non-rectangular areas, etc.).

Source code in src/latticesvg/style/parser.py
def parse_grid_template_areas(raw: Any) -> Optional[AreaMapping]:
    """Parse a ``grid-template-areas`` value.

    The CSS syntax is a series of quoted row strings, e.g.::

        "header header header"
        "sidebar main main"
        "footer footer footer"

    A single dot ``.`` represents an empty cell (unnamed).

    The value may be supplied as:
    * a single string with quoted substrings (CSS syntax)
    * a list of strings, each representing one row

    Returns ``None`` if the value is ``None`` or ``"none"``.
    Raises ``ValueError`` on malformed input (non-rectangular areas, etc.).
    """
    if raw is None or raw == "none":
        return None

    # Accept a list of row strings directly
    if isinstance(raw, (list, tuple)):
        row_strings = [str(r).strip() for r in raw]
    elif isinstance(raw, str):
        # Try to extract quoted strings first  ("header header" "main main")
        quoted = re.findall(r'"([^"]*)"', raw)
        if quoted:
            row_strings = [s.strip() for s in quoted]
        else:
            # Maybe it's a single-row template without quotes
            row_strings = [raw.strip()]
    else:
        return None

    if not row_strings:
        return None

    # Build the 2-D grid of cell names
    grid: List[List[str]] = []
    num_cols: Optional[int] = None
    for r_idx, row_str in enumerate(row_strings):
        tokens = row_str.split()
        if not tokens:
            continue
        if num_cols is None:
            num_cols = len(tokens)
        elif len(tokens) != num_cols:
            raise ValueError(
                f"grid-template-areas row {r_idx} has {len(tokens)} columns, "
                f"expected {num_cols}"
            )
        grid.append(tokens)

    if not grid or num_cols is None:
        return None

    num_rows = len(grid)

    # Collect each area name → list of (row, col) cells
    name_cells: Dict[str, List[Tuple[int, int]]] = {}
    for r in range(num_rows):
        for c in range(num_cols):
            name = grid[r][c]
            if name == ".":
                continue
            name_cells.setdefault(name, []).append((r, c))

    # Convert to rectangular area mappings
    areas: Dict[str, Tuple[int, int, int, int]] = {}
    for name, cells in name_cells.items():
        rows = [rc[0] for rc in cells]
        cols = [rc[1] for rc in cells]
        r_min, r_max = min(rows), max(rows)
        c_min, c_max = min(cols), max(cols)
        row_span = r_max - r_min + 1
        col_span = c_max - c_min + 1

        # Verify that the area forms a proper rectangle
        if len(cells) != row_span * col_span:
            raise ValueError(
                f"grid-template-areas: area '{name}' is not rectangular"
            )
        # Verify all cells in the bounding rect belong to this area
        for rr in range(r_min, r_max + 1):
            for cc in range(c_min, c_max + 1):
                if grid[rr][cc] != name:
                    raise ValueError(
                        f"grid-template-areas: area '{name}' is not rectangular "
                        f"(cell [{rr}][{cc}] is '{grid[rr][cc]}')"
                    )

        areas[name] = (r_min, c_min, row_span, col_span)

    return AreaMapping(areas=areas, num_rows=num_rows, num_cols=num_cols)
parse_box_shadow
parse_box_shadow(raw: Any) -> Any

Parse a CSS box-shadow value.

Returns a tuple of :class:BoxShadow instances, or the string "none" for no shadow.

Source code in src/latticesvg/style/parser.py
def parse_box_shadow(raw: Any) -> Any:
    """Parse a CSS ``box-shadow`` value.

    Returns a tuple of :class:`BoxShadow` instances, or the string
    ``"none"`` for no shadow.
    """
    if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
        return "none"
    if not isinstance(raw, str):
        return "none"

    parts = _smart_split_comma(raw.strip())
    shadows: List[BoxShadow] = []
    for part in parts:
        tokens = _tokenize_shadow(part.strip())
        if not tokens:
            continue
        inset = False
        clean: List[str] = []
        for t in tokens:
            if t.lower() == "inset":
                inset = True
            else:
                clean.append(t)
        color, lengths = _extract_color_token(clean)
        if color is None:
            color = "rgba(0,0,0,1)"
        nums: List[float] = []
        for t in lengths:
            v = parse_value(t)
            if isinstance(v, (int, float)):
                nums.append(float(v))
        if len(nums) < 2:
            continue  # need at least offset-x and offset-y
        ox = nums[0]
        oy = nums[1]
        blur = nums[2] if len(nums) > 2 else 0.0
        spread = nums[3] if len(nums) > 3 else 0.0
        shadows.append(BoxShadow(
            offset_x=ox, offset_y=oy,
            blur_radius=blur, spread_radius=spread,
            color=color, inset=inset,
        ))
    return tuple(shadows) if shadows else "none"
parse_transform
parse_transform(raw: Any) -> Any

Parse a CSS transform value.

Returns a tuple of :class:TransformFunction instances, or the string "none" for no transform.

Source code in src/latticesvg/style/parser.py
def parse_transform(raw: Any) -> Any:
    """Parse a CSS ``transform`` value.

    Returns a tuple of :class:`TransformFunction` instances, or the string
    ``"none"`` for no transform.
    """
    if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
        return "none"
    if not isinstance(raw, str):
        return "none"

    fns: List[TransformFunction] = []
    for m in _RE_TRANSFORM_FN.finditer(raw):
        name = m.group(1).lower()
        args_str = m.group(2).strip()
        if name == "rotate":
            fns.append(TransformFunction(name, (_parse_angle(args_str),)))
        else:
            parts = [p.strip() for p in args_str.split(",") if p.strip()]
            if len(parts) == 1:
                parts = args_str.split()
            nums: List[float] = []
            for p in parts:
                v = parse_value(p)
                if isinstance(v, (int, float)):
                    nums.append(float(v))
            if nums:
                fns.append(TransformFunction(name, tuple(nums)))
    return tuple(fns) if fns else "none"
parse_filter
parse_filter(raw: Any) -> Any

Parse a CSS filter value.

Returns a tuple of :class:FilterFunction instances, or the string "none" for no filter.

Source code in src/latticesvg/style/parser.py
def parse_filter(raw: Any) -> Any:
    """Parse a CSS ``filter`` value.

    Returns a tuple of :class:`FilterFunction` instances, or the string
    ``"none"`` for no filter.
    """
    if raw is None or (isinstance(raw, str) and raw.strip().lower() == "none"):
        return "none"
    if not isinstance(raw, str):
        return "none"

    fns: List[FilterFunction] = []
    for m in _RE_FILTER_FN.finditer(raw):
        name = m.group(1).lower()
        if name not in _FILTER_NAMES:
            continue
        args_str = m.group(2).strip()
        if name == "drop-shadow":
            # drop-shadow(offset-x offset-y [blur] [color])
            tokens = _tokenize_shadow(args_str)
            color, lengths = _extract_color_token(tokens)
            nums: List[float] = []
            for t in lengths:
                v = parse_value(t)
                if isinstance(v, (int, float)):
                    nums.append(float(v))
            # args = (offset_x, offset_y, blur, color_placeholder)
            ox = nums[0] if len(nums) > 0 else 0.0
            oy = nums[1] if len(nums) > 1 else 0.0
            blur = nums[2] if len(nums) > 2 else 0.0
            # Store color as a negative hack — we'll keep it in the name
            # Actually, use a BoxShadow-like approach: pack color into a
            # special FilterFunction.  But FilterFunction.args is float-only.
            # Solution: store drop-shadow as a BoxShadow wrapped in tuple.
            from dataclasses import replace as _dc_replace  # noqa: local import
            fns.append(FilterFunction(
                name="drop-shadow",
                args=(ox, oy, blur),
            ))
            # Attach color as extra attribute via subclass trick — simpler:
            # just store the raw color string on the object after creation.
            # Since FilterFunction is frozen, we use object.__setattr__.
            if color:
                object.__setattr__(fns[-1], "_color", color)
            else:
                object.__setattr__(fns[-1], "_color", "rgba(0,0,0,1)")
        elif name == "blur":
            v = parse_value(args_str)
            amt = float(v) if isinstance(v, (int, float)) else 0.0
            fns.append(FilterFunction(name, (amt,)))
        else:
            # brightness, contrast, grayscale, opacity, saturate, sepia
            amt = _parse_filter_amount(args_str)
            fns.append(FilterFunction(name, (amt,)))
    return tuple(fns) if fns else "none"

计算样式

computed

ComputedStyle — resolved style object with inheritance and shorthand expansion.

ComputedStyle
ComputedStyle(raw: Optional[Dict[str, Any]] = None, parent_style: Optional['ComputedStyle'] = None)

Holds fully-resolved CSS property values for a single node.

Construction

style = ComputedStyle({"width": "200px", "padding": "10px"}, parent_style=None) style.width # 200.0 style.padding_top # 10.0

Attribute access maps underscores to hyphens so style.font_size reads the font-size property.

Source code in src/latticesvg/style/computed.py
def __init__(
    self,
    raw: Optional[Dict[str, Any]] = None,
    parent_style: Optional["ComputedStyle"] = None,
) -> None:
    self._values: Dict[str, Any] = {}
    self._raw: Optional[Dict[str, Any]] = raw
    self._explicit_props: set = set()
    self._pct_originals: Optional[Dict[str, _Percentage]] = None

    # 1. Inherit inheritable properties from parent
    if parent_style is not None:
        for prop, defn in PROPERTY_REGISTRY.items():
            if defn.inheritable:
                self._values[prop] = parent_style.get(prop)

    # 2. Apply defaults for all non-inherited properties
    for prop, defn in PROPERTY_REGISTRY.items():
        if prop not in self._values:
            if defn.default is not None:
                if defn.parser_hint == "line-height":
                    self._values[prop] = _parse_line_height(defn.default)
                else:
                    self._values[prop] = parse_value(defn.default)
            else:
                self._values[prop] = None

    # 3. Parse & expand user-supplied values
    if raw:
        # Resolve font-size first (needed for em units)
        if "font-size" in raw:
            parent_fs = parent_style.get("font-size") if parent_style else 16.0
            self._values["font-size"] = parse_value(
                raw["font-size"], font_size=parent_fs
            )
            self._explicit_props.add("font-size")

        font_size = self._values.get("font-size", 16.0)
        if not isinstance(font_size, (int, float)):
            font_size = 16.0

        for prop, val in raw.items():
            if prop == "font-size":
                continue  # already handled

            # Warn about unknown CSS properties (P1-3)
            if (prop not in PROPERTY_REGISTRY
                    and prop not in _KNOWN_SHORTHANDS):
                warnings.warn(
                    f"Unknown CSS property: '{prop}'",
                    stacklevel=3,
                )

            expanded = expand_shorthand(prop, val)
            for long_prop, long_val in expanded.items():
                self._explicit_props.add(long_prop)
                self._values[long_prop] = self._parse_prop(
                    long_prop, long_val, font_size
                )

        # Warn about margin (parsed but not applied in grid layout)
        _MARGIN_PROPS = {"margin", "margin-top", "margin-right",
                         "margin-bottom", "margin-left"}
        used_margins = _MARGIN_PROPS & set(raw.keys())
        if used_margins:
            warnings.warn(
                f"margin properties ({', '.join(sorted(used_margins))}) are "
                "parsed but not applied during grid layout. Use 'gap' or "
                "'padding' for spacing instead.",
                stacklevel=3,
            )
border_radii property
border_radii: tuple

Return (top-left, top-right, bottom-right, bottom-left) radii.

has_uniform_radius property
has_uniform_radius: bool

True when all four corner radii are equal.

get
get(prop: str, default: Any = None) -> Any

Get a property value by its CSS name (e.g. 'font-size').

Source code in src/latticesvg/style/computed.py
def get(self, prop: str, default: Any = None) -> Any:
    """Get a property value by its CSS name (e.g. ``'font-size'``)."""
    return self._values.get(prop, default)
set
set(prop: str, value: Any) -> None

Set a CSS property, with shorthand expansion and value parsing.

Supports both longhand and shorthand properties::

style.set("padding", "10px 20px")      # expands to 4 longhands
style.set("border", "1px solid red")    # expands to 12 longhands
style.set("font-size", "18px")          # parsed to float 18.0
style.set("width", 200)                 # numeric values kept as-is
Source code in src/latticesvg/style/computed.py
def set(self, prop: str, value: Any) -> None:
    """Set a CSS property, with shorthand expansion and value parsing.

    Supports both longhand and shorthand properties::

        style.set("padding", "10px 20px")      # expands to 4 longhands
        style.set("border", "1px solid red")    # expands to 12 longhands
        style.set("font-size", "18px")          # parsed to float 18.0
        style.set("width", 200)                 # numeric values kept as-is
    """
    # Warn about unknown CSS properties (P1-3)
    if prop not in PROPERTY_REGISTRY and prop not in _KNOWN_SHORTHANDS:
        warnings.warn(
            f"Unknown CSS property: '{prop}'",
            stacklevel=2,
        )

    font_size = self._values.get("font-size", 16.0)
    if not isinstance(font_size, (int, float)):
        font_size = 16.0

    expanded = expand_shorthand(prop, value)
    for long_prop, long_val in expanded.items():
        self._explicit_props.add(long_prop)
        self._values[long_prop] = self._parse_prop(long_prop, long_val, font_size)
resolve_percentages
resolve_percentages(ref_width: float, ref_height: Optional[float] = None) -> None

Resolve any remaining _Percentage values.

The original _Percentage instances are preserved internally so that this method can be called again with different reference dimensions (e.g. when layout() is invoked multiple times).

Source code in src/latticesvg/style/computed.py
def resolve_percentages(self, ref_width: float, ref_height: Optional[float] = None) -> None:
    """Resolve any remaining ``_Percentage`` values.

    The original ``_Percentage`` instances are preserved internally so
    that this method can be called again with different reference
    dimensions (e.g. when ``layout()`` is invoked multiple times).
    """
    for prop, val in list(self._values.items()):
        pct = val
        # If already resolved, check for the stashed original
        if not isinstance(pct, _Percentage):
            pct = self._pct_originals.get(prop) if self._pct_originals else None
        if isinstance(pct, _Percentage):
            # Choose reference based on property axis
            if "height" in prop or "top" in prop or "bottom" in prop:
                ref = ref_height if ref_height is not None else ref_width
            else:
                ref = ref_width
            # Stash original _Percentage before overwriting
            if self._pct_originals is None:
                self._pct_originals = {}
            self._pct_originals[prop] = pct
            self._values[prop] = pct.resolve(ref)