Apply some normalization to CSS values

"10px 10px" should present as "10px".  A length of "0" should present as "0px"

Fixes a handful of WPT tests.
This commit is contained in:
Karl Seguin
2026-03-09 17:47:59 +08:00
parent 034b089433
commit 1399bd3065
3 changed files with 430 additions and 14 deletions

View File

@@ -275,3 +275,69 @@
testing.expectEqual('red', div.style.getPropertyValue('color')); testing.expectEqual('red', div.style.getPropertyValue('color'));
} }
</script> </script>
<script id="CSSStyleDeclaration_normalize_zero_to_0px">
{
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
const div = document.createElement('div');
div.style.width = '0';
testing.expectEqual('0px', div.style.width);
div.style.margin = '0';
testing.expectEqual('0px', div.style.margin);
div.style.padding = '0';
testing.expectEqual('0px', div.style.padding);
div.style.top = '0';
testing.expectEqual('0px', div.style.top);
// Non-length properties should not be affected
div.style.opacity = '0';
testing.expectEqual('0', div.style.opacity);
div.style.zIndex = '0';
testing.expectEqual('0', div.style.zIndex);
}
</script>
<script id="CSSStyleDeclaration_normalize_first_baseline">
{
// "first baseline" should serialize canonically as "baseline"
const div = document.createElement('div');
div.style.alignItems = 'first baseline';
testing.expectEqual('baseline', div.style.alignItems);
div.style.alignContent = 'first baseline';
testing.expectEqual('baseline', div.style.alignContent);
// "last baseline" should remain unchanged
div.style.alignItems = 'last baseline';
testing.expectEqual('last baseline', div.style.alignItems);
}
</script>
<script id="CSSStyleDeclaration_normalize_duplicate_values">
{
// For 2-value shorthand properties, "X X" should collapse to "X"
const div = document.createElement('div');
div.style.placeContent = 'center center';
testing.expectEqual('center', div.style.placeContent);
div.style.placeContent = 'start start';
testing.expectEqual('start', div.style.placeContent);
div.style.gap = '10px 10px';
testing.expectEqual('10px', div.style.gap);
// Different values should not collapse
div.style.placeContent = 'center start';
testing.expectEqual('center start', div.style.placeContent);
div.style.gap = '10px 20px';
testing.expectEqual('10px 20px', div.style.gap);
}
</script>

View File

@@ -26,6 +26,8 @@ const js = @import("../../js/js.zig");
const Page = @import("../../Page.zig"); const Page = @import("../../Page.zig");
const Element = @import("../Element.zig"); const Element = @import("../Element.zig");
const Allocator = std.mem.Allocator;
const CSSStyleDeclaration = @This(); const CSSStyleDeclaration = @This();
_element: ?*Element = null, _element: ?*Element = null,
@@ -114,9 +116,12 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const normalized = normalizePropertyName(property_name, &page.buf); const normalized = normalizePropertyName(property_name, &page.buf);
// Normalize the value for canonical serialization
const normalized_value = try normalizePropertyValue(page.call_arena, normalized, value);
// Find existing property // Find existing property
if (self.findProperty(normalized)) |existing| { if (self.findProperty(normalized)) |existing| {
existing._value = try String.init(page.arena, value, .{}); existing._value = try String.init(page.arena, normalized_value, .{});
existing._important = important; existing._important = important;
return; return;
} }
@@ -125,7 +130,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
const prop = try page._factory.create(Property{ const prop = try page._factory.create(Property{
._node = .{}, ._node = .{},
._name = try String.init(page.arena, normalized, .{}), ._name = try String.init(page.arena, normalized, .{}),
._value = try String.init(page.arena, value, .{}), ._value = try String.init(page.arena, normalized_value, .{}),
._important = important, ._important = important,
}); });
self._properties.append(&prop._node); self._properties.append(&prop._node);
@@ -227,6 +232,166 @@ fn normalizePropertyName(name: []const u8, buf: []u8) []const u8 {
return std.ascii.lowerString(buf, name); return std.ascii.lowerString(buf, name);
} }
// Normalize CSS property values for canonical serialization
fn normalizePropertyValue(arena: Allocator, property_name: []const u8, value: []const u8) ![]const u8 {
// Per CSSOM spec, unitless zero in length properties should serialize as "0px"
if (std.mem.eql(u8, value, "0") and isLengthProperty(property_name)) {
return "0px";
}
// "first baseline" serializes canonically as "baseline" (first is the default)
if (std.ascii.startsWithIgnoreCase(value, "first baseline")) {
if (value.len == 14) {
// Exact match "first baseline"
return "baseline";
}
if (value.len > 14 and value[14] == ' ') {
// "first baseline X" -> "baseline X"
return try std.mem.concat(arena, u8, &.{ "baseline", value[14..] });
}
}
// For 2-value shorthand properties, collapse "X X" to "X"
if (isTwoValueShorthand(property_name)) {
if (collapseDuplicateValue(value)) |single| {
return single;
}
}
return value;
}
// Check if a value is "X X" (duplicate) and return just "X"
fn collapseDuplicateValue(value: []const u8) ?[]const u8 {
const space_idx = std.mem.indexOfScalar(u8, value, ' ') orelse return null;
if (space_idx == 0 or space_idx >= value.len - 1) return null;
const first = value[0..space_idx];
const rest = std.mem.trimLeft(u8, value[space_idx + 1 ..], " ");
// Check if there's only one more value (no additional spaces)
if (std.mem.indexOfScalar(u8, rest, ' ') != null) return null;
if (std.mem.eql(u8, first, rest)) {
return first;
}
return null;
}
fn isTwoValueShorthand(name: []const u8) bool {
const shorthands = std.StaticStringMap(void).initComptime(.{
.{ "place-content", {} },
.{ "place-items", {} },
.{ "place-self", {} },
.{ "margin-block", {} },
.{ "margin-inline", {} },
.{ "padding-block", {} },
.{ "padding-inline", {} },
.{ "inset-block", {} },
.{ "inset-inline", {} },
.{ "border-block-style", {} },
.{ "border-inline-style", {} },
.{ "border-block-width", {} },
.{ "border-inline-width", {} },
.{ "border-block-color", {} },
.{ "border-inline-color", {} },
.{ "overflow", {} },
.{ "overscroll-behavior", {} },
.{ "gap", {} },
});
return shorthands.has(name);
}
fn isLengthProperty(name: []const u8) bool {
// Properties that accept <length> or <length-percentage> values
const length_properties = std.StaticStringMap(void).initComptime(.{
// Sizing
.{ "width", {} },
.{ "height", {} },
.{ "min-width", {} },
.{ "min-height", {} },
.{ "max-width", {} },
.{ "max-height", {} },
// Margins
.{ "margin", {} },
.{ "margin-top", {} },
.{ "margin-right", {} },
.{ "margin-bottom", {} },
.{ "margin-left", {} },
.{ "margin-block", {} },
.{ "margin-block-start", {} },
.{ "margin-block-end", {} },
.{ "margin-inline", {} },
.{ "margin-inline-start", {} },
.{ "margin-inline-end", {} },
// Padding
.{ "padding", {} },
.{ "padding-top", {} },
.{ "padding-right", {} },
.{ "padding-bottom", {} },
.{ "padding-left", {} },
.{ "padding-block", {} },
.{ "padding-block-start", {} },
.{ "padding-block-end", {} },
.{ "padding-inline", {} },
.{ "padding-inline-start", {} },
.{ "padding-inline-end", {} },
// Positioning
.{ "top", {} },
.{ "right", {} },
.{ "bottom", {} },
.{ "left", {} },
.{ "inset", {} },
.{ "inset-block", {} },
.{ "inset-block-start", {} },
.{ "inset-block-end", {} },
.{ "inset-inline", {} },
.{ "inset-inline-start", {} },
.{ "inset-inline-end", {} },
// Border
.{ "border-width", {} },
.{ "border-top-width", {} },
.{ "border-right-width", {} },
.{ "border-bottom-width", {} },
.{ "border-left-width", {} },
.{ "border-block-width", {} },
.{ "border-block-start-width", {} },
.{ "border-block-end-width", {} },
.{ "border-inline-width", {} },
.{ "border-inline-start-width", {} },
.{ "border-inline-end-width", {} },
.{ "border-radius", {} },
.{ "border-top-left-radius", {} },
.{ "border-top-right-radius", {} },
.{ "border-bottom-left-radius", {} },
.{ "border-bottom-right-radius", {} },
// Text
.{ "font-size", {} },
.{ "line-height", {} },
.{ "letter-spacing", {} },
.{ "word-spacing", {} },
.{ "text-indent", {} },
// Flexbox/Grid
.{ "gap", {} },
.{ "row-gap", {} },
.{ "column-gap", {} },
.{ "flex-basis", {} },
// Outline
.{ "outline-width", {} },
.{ "outline-offset", {} },
// Other
.{ "border-spacing", {} },
.{ "text-shadow", {} },
.{ "box-shadow", {} },
.{ "baseline-shift", {} },
.{ "vertical-align", {} },
// Grid lanes
.{ "flow-tolerance", {} },
});
return length_properties.has(name);
}
fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 { fn getDefaultPropertyValue(self: *const CSSStyleDeclaration, normalized_name: []const u8) []const u8 {
if (std.mem.eql(u8, normalized_name, "visibility")) { if (std.mem.eql(u8, normalized_name, "visibility")) {
return "visible"; return "visible";

View File

@@ -91,39 +91,224 @@ pub fn getNamed(self: *CSSStyleProperties, name: []const u8, page: *Page) ![]con
} }
fn isKnownCSSProperty(dash_case: []const u8) bool { fn isKnownCSSProperty(dash_case: []const u8) bool {
// List of common/known CSS properties
// In a full implementation, this would include all standard CSS properties
const known_properties = std.StaticStringMap(void).initComptime(.{ const known_properties = std.StaticStringMap(void).initComptime(.{
// Colors & backgrounds
.{ "color", {} }, .{ "color", {} },
.{ "background", {} },
.{ "background-color", {} }, .{ "background-color", {} },
.{ "background-image", {} },
.{ "background-position", {} },
.{ "background-repeat", {} },
.{ "background-size", {} },
.{ "background-attachment", {} },
.{ "background-clip", {} },
.{ "background-origin", {} },
// Typography
.{ "font", {} },
.{ "font-family", {} },
.{ "font-size", {} }, .{ "font-size", {} },
.{ "font-style", {} },
.{ "font-weight", {} },
.{ "font-variant", {} },
.{ "line-height", {} },
.{ "letter-spacing", {} },
.{ "word-spacing", {} },
.{ "text-align", {} },
.{ "text-decoration", {} },
.{ "text-indent", {} },
.{ "text-transform", {} },
.{ "white-space", {} },
.{ "word-break", {} },
.{ "word-wrap", {} },
.{ "overflow-wrap", {} },
// Box model
.{ "margin", {} },
.{ "margin-top", {} }, .{ "margin-top", {} },
.{ "margin-right", {} },
.{ "margin-bottom", {} }, .{ "margin-bottom", {} },
.{ "margin-left", {} }, .{ "margin-left", {} },
.{ "margin-right", {} }, .{ "margin-block", {} },
.{ "margin-block-start", {} },
.{ "margin-block-end", {} },
.{ "margin-inline", {} },
.{ "margin-inline-start", {} },
.{ "margin-inline-end", {} },
.{ "padding", {} },
.{ "padding-top", {} }, .{ "padding-top", {} },
.{ "padding-right", {} },
.{ "padding-bottom", {} }, .{ "padding-bottom", {} },
.{ "padding-left", {} }, .{ "padding-left", {} },
.{ "padding-right", {} }, .{ "padding-block", {} },
.{ "padding-block-start", {} },
.{ "padding-block-end", {} },
.{ "padding-inline", {} },
.{ "padding-inline-start", {} },
.{ "padding-inline-end", {} },
// Border
.{ "border", {} },
.{ "border-width", {} },
.{ "border-style", {} },
.{ "border-color", {} },
.{ "border-top", {} },
.{ "border-top-width", {} },
.{ "border-top-style", {} },
.{ "border-top-color", {} },
.{ "border-right", {} },
.{ "border-right-width", {} },
.{ "border-right-style", {} },
.{ "border-right-color", {} },
.{ "border-bottom", {} },
.{ "border-bottom-width", {} },
.{ "border-bottom-style", {} },
.{ "border-bottom-color", {} },
.{ "border-left", {} },
.{ "border-left-width", {} },
.{ "border-left-style", {} },
.{ "border-left-color", {} },
.{ "border-radius", {} },
.{ "border-top-left-radius", {} }, .{ "border-top-left-radius", {} },
.{ "border-top-right-radius", {} }, .{ "border-top-right-radius", {} },
.{ "border-bottom-left-radius", {} }, .{ "border-bottom-left-radius", {} },
.{ "border-bottom-right-radius", {} }, .{ "border-bottom-right-radius", {} },
.{ "float", {} }, .{ "border-collapse", {} },
.{ "z-index", {} }, .{ "border-spacing", {} },
// Sizing
.{ "width", {} }, .{ "width", {} },
.{ "height", {} }, .{ "height", {} },
.{ "min-width", {} },
.{ "min-height", {} },
.{ "max-width", {} },
.{ "max-height", {} },
.{ "box-sizing", {} },
// Positioning
.{ "position", {} },
.{ "top", {} },
.{ "right", {} },
.{ "bottom", {} },
.{ "left", {} },
.{ "inset", {} },
.{ "inset-block", {} },
.{ "inset-block-start", {} },
.{ "inset-block-end", {} },
.{ "inset-inline", {} },
.{ "inset-inline-start", {} },
.{ "inset-inline-end", {} },
.{ "z-index", {} },
.{ "float", {} },
.{ "clear", {} },
// Display & visibility
.{ "display", {} }, .{ "display", {} },
.{ "visibility", {} }, .{ "visibility", {} },
.{ "opacity", {} }, .{ "opacity", {} },
.{ "filter", {} }, .{ "overflow", {} },
.{ "overflow-x", {} },
.{ "overflow-y", {} },
.{ "clip", {} },
.{ "clip-path", {} },
// Flexbox
.{ "flex", {} },
.{ "flex-direction", {} },
.{ "flex-wrap", {} },
.{ "flex-flow", {} },
.{ "flex-grow", {} },
.{ "flex-shrink", {} },
.{ "flex-basis", {} },
.{ "order", {} },
// Grid
.{ "grid", {} },
.{ "grid-template", {} },
.{ "grid-template-columns", {} },
.{ "grid-template-rows", {} },
.{ "grid-template-areas", {} },
.{ "grid-auto-columns", {} },
.{ "grid-auto-rows", {} },
.{ "grid-auto-flow", {} },
.{ "grid-column", {} },
.{ "grid-column-start", {} },
.{ "grid-column-end", {} },
.{ "grid-row", {} },
.{ "grid-row-start", {} },
.{ "grid-row-end", {} },
.{ "grid-area", {} },
.{ "gap", {} },
.{ "row-gap", {} },
.{ "column-gap", {} },
// Alignment (flexbox & grid)
.{ "align-content", {} },
.{ "align-items", {} },
.{ "align-self", {} },
.{ "justify-content", {} },
.{ "justify-items", {} },
.{ "justify-self", {} },
.{ "place-content", {} },
.{ "place-items", {} },
.{ "place-self", {} },
// Transforms & animations
.{ "transform", {} }, .{ "transform", {} },
.{ "transform-origin", {} },
.{ "transform-style", {} },
.{ "perspective", {} },
.{ "perspective-origin", {} },
.{ "transition", {} }, .{ "transition", {} },
.{ "position", {} }, .{ "transition-property", {} },
.{ "top", {} }, .{ "transition-duration", {} },
.{ "bottom", {} }, .{ "transition-timing-function", {} },
.{ "left", {} }, .{ "transition-delay", {} },
.{ "right", {} }, .{ "animation", {} },
.{ "animation-name", {} },
.{ "animation-duration", {} },
.{ "animation-timing-function", {} },
.{ "animation-delay", {} },
.{ "animation-iteration-count", {} },
.{ "animation-direction", {} },
.{ "animation-fill-mode", {} },
.{ "animation-play-state", {} },
// Filters & effects
.{ "filter", {} },
.{ "backdrop-filter", {} },
.{ "box-shadow", {} },
.{ "text-shadow", {} },
// Outline
.{ "outline", {} },
.{ "outline-width", {} },
.{ "outline-style", {} },
.{ "outline-color", {} },
.{ "outline-offset", {} },
// Lists
.{ "list-style", {} },
.{ "list-style-type", {} },
.{ "list-style-position", {} },
.{ "list-style-image", {} },
// Tables
.{ "table-layout", {} },
.{ "caption-side", {} },
.{ "empty-cells", {} },
// Misc
.{ "cursor", {} },
.{ "pointer-events", {} },
.{ "user-select", {} },
.{ "resize", {} },
.{ "object-fit", {} },
.{ "object-position", {} },
.{ "vertical-align", {} },
.{ "content", {} },
.{ "quotes", {} },
.{ "counter-reset", {} },
.{ "counter-increment", {} },
// Scrolling
.{ "scroll-behavior", {} },
.{ "scroll-margin", {} },
.{ "scroll-padding", {} },
.{ "overscroll-behavior", {} },
.{ "overscroll-behavior-x", {} },
.{ "overscroll-behavior-y", {} },
// Containment
.{ "contain", {} },
.{ "container", {} },
.{ "container-type", {} },
.{ "container-name", {} },
// Aspect ratio
.{ "aspect-ratio", {} },
}); });
return known_properties.has(dash_case); return known_properties.has(dash_case);