lib/css/src/media.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 //! Media types, the media environment, and media query evaluation.
2 //!
3 //! Queries are evaluated during ingestion so that inactive rule subtrees never
4 //! reach the rule store. An unknown feature evaluates to unknown rather than
5 //! false, which keeps a negated unknown query inactive as the specification
6 //! requires.
7
8 const std = @import("std");
9
10 const scan = @import("scan.zig");
11
12 const consumePrefixIgnoreCase = scan.consumePrefixIgnoreCase;
13 const consumeWord = scan.consumeWord;
14 const containsTopLevelLogical = scan.containsTopLevelLogical;
15 const endsWithIgnoreCase = scan.endsWithIgnoreCase;
16 const findParenEnd = scan.findParenEnd;
17 const findTopLevelByte = scan.findTopLevelByte;
18 const readIdent = scan.readIdent;
19 const skipCssSpace = scan.skipCssSpace;
20 const skipString = scan.skipString;
21
22 pub const MediaType = enum {
23 screen,
24 print,
25 };
26
27 pub const MediaEnvironment = struct {
28 media_type: MediaType = .screen,
29 width: f32 = 800,
30 height: f32 = 600,
31 font_size: f32 = 16,
32 color_scheme: ColorScheme = .light,
33 reduced_motion: ReducedMotion = .no_preference,
34
35 pub fn default() MediaEnvironment {
36 return .{};
37 }
38
39 pub fn screen(width: f32, height: f32) MediaEnvironment {
40 return .{ .media_type = .screen, .width = width, .height = height };
41 }
42 };
43
44 pub const ColorScheme = enum { light, dark };
45 pub const ReducedMotion = enum { no_preference, reduce };
46
47 pub fn mediaListApplies(query_list: []const u8, media: MediaEnvironment) bool {
48 const trimmed = std.mem.trim(u8, query_list, " \t\r\n");
49 if (trimmed.len == 0) return true;
50 var query_start: usize = 0;
51 while (query_start < trimmed.len) {
52 const query_end = findMediaQueryEnd(trimmed, query_start);
53 if (mediaQueryApplies(trimmed[query_start..query_end], media)) return true;
54 query_start = if (query_end < trimmed.len) skipCssSpace(trimmed, query_end + 1) else trimmed.len;
55 }
56 return false;
57 }
58
59 pub fn mediaApplies(prelude: []const u8, media: MediaEnvironment) bool {
60 const trimmed = std.mem.trim(u8, prelude, " \t\r\n");
61 const name_end = readIdent(trimmed, 0);
62 if (name_end == 0 or !std.ascii.eqlIgnoreCase(trimmed[0..name_end], "media")) return false;
63 var query_start = skipCssSpace(trimmed, name_end);
64 if (query_start >= trimmed.len) return true;
65 while (query_start < trimmed.len) {
66 const query_end = findMediaQueryEnd(trimmed, query_start);
67 if (mediaQueryApplies(trimmed[query_start..query_end], media)) return true;
68 query_start = if (query_end < trimmed.len) skipCssSpace(trimmed, query_end + 1) else trimmed.len;
69 }
70 return false;
71 }
72
73 fn findMediaQueryEnd(source: []const u8, start: usize) usize {
74 var index = start;
75 var paren_depth: usize = 0;
76 while (index < source.len) : (index += 1) {
77 const byte = source[index];
78 if (byte == '"' or byte == '\'') {
79 index = skipString(source, index);
80 continue;
81 }
82 switch (byte) {
83 '(' => paren_depth += 1,
84 ')' => {
85 if (paren_depth > 0) paren_depth -= 1;
86 },
87 ',' => if (paren_depth == 0) return index,
88 else => {},
89 }
90 }
91 return source.len;
92 }
93
94 const QueryResult = enum {
95 match,
96 miss,
97 unknown,
98 };
99
100 const Comparison = enum {
101 lt,
102 lte,
103 eq,
104 gte,
105 gt,
106 };
107
108 const ComparisonPosition = struct {
109 index: usize,
110 comparison: Comparison,
111 width: usize,
112 };
113
114 fn mediaQueryApplies(query: []const u8, media: MediaEnvironment) bool {
115 return evaluateMediaQuery(query, media) == .match;
116 }
117
118 fn evaluateMediaQuery(query: []const u8, media: MediaEnvironment) QueryResult {
119 const trimmed = std.mem.trim(u8, query, " \t\r\n");
120 if (trimmed.len == 0) return .miss;
121 var index: usize = 0;
122 var negated = false;
123 if (consumeWord(trimmed, &index, "not")) negated = true;
124 _ = consumeWord(trimmed, &index, "only");
125 index = skipCssSpace(trimmed, index);
126 var result = QueryResult.match;
127 const type_start = index;
128 const type_end = readIdent(trimmed, index);
129 if (type_end != type_start) {
130 const word = trimmed[type_start..type_end];
131 if (mediaTypeWord(word)) {
132 result = queryAnd(result, mediaTypeMatches(word, media));
133 index = skipCssSpace(trimmed, type_end);
134 if (index < trimmed.len) {
135 if (!consumeWord(trimmed, &index, "and")) return .miss;
136 result = queryAnd(result, evaluateMediaCondition(trimmed[index..], media));
137 }
138 } else {
139 result = evaluateMediaCondition(trimmed[index..], media);
140 }
141 } else {
142 result = evaluateMediaCondition(trimmed[index..], media);
143 }
144 return if (negated) queryNot(result) else result;
145 }
146
147 fn evaluateMediaCondition(source: []const u8, media: MediaEnvironment) QueryResult {
148 const trimmed = std.mem.trim(u8, source, " \t\r\n");
149 if (trimmed.len == 0) return .unknown;
150 var index: usize = 0;
151 const result = evaluateMediaConditionSequence(trimmed, &index, media);
152 index = skipCssSpace(trimmed, index);
153 return if (index == trimmed.len) result else .unknown;
154 }
155
156 fn evaluateMediaConditionSequence(source: []const u8, index: *usize, media: MediaEnvironment) QueryResult {
157 var result = QueryResult.match;
158 var first = true;
159 var next_operator: enum { logical_and, logical_or } = .logical_and;
160 while (true) {
161 index.* = skipCssSpace(source, index.*);
162 var negated = false;
163 while (consumeWord(source, index, "not")) {
164 negated = !negated;
165 index.* = skipCssSpace(source, index.*);
166 }
167 if (index.* >= source.len or source[index.*] != '(') return .unknown;
168 const close = findParenEnd(source, index.*) orelse return .unknown;
169 const body = std.mem.trim(u8, source[index.* + 1 .. close], " \t\r\n");
170 index.* = close + 1;
171 var term = if (body.len == 0)
172 QueryResult.unknown
173 else if (body[0] == '(' or containsTopLevelLogical(body)) nested: {
174 var nested_index: usize = 0;
175 const nested_result = evaluateMediaConditionSequence(body, &nested_index, media);
176 nested_index = skipCssSpace(body, nested_index);
177 break :nested if (nested_index == body.len) nested_result else .unknown;
178 } else evaluateMediaFeature(body, media);
179 if (negated) term = queryNot(term);
180 if (first) {
181 result = term;
182 first = false;
183 } else {
184 result = switch (next_operator) {
185 .logical_and => queryAnd(result, term),
186 .logical_or => queryOr(result, term),
187 };
188 }
189 index.* = skipCssSpace(source, index.*);
190 if (index.* >= source.len) return result;
191 if (consumeWord(source, index, "and")) {
192 next_operator = .logical_and;
193 continue;
194 }
195 if (consumeWord(source, index, "or")) {
196 next_operator = .logical_or;
197 continue;
198 }
199 return .unknown;
200 }
201 }
202
203 fn evaluateMediaFeature(source: []const u8, media: MediaEnvironment) QueryResult {
204 const trimmed = std.mem.trim(u8, source, " \t\r\n");
205 if (trimmed.len == 0) return .unknown;
206 if (findComparison(trimmed, 0)) |comparison| return evaluateRangeFeature(trimmed, comparison, media);
207 if (findTopLevelByte(trimmed, 0, ':')) |colon| {
208 const raw_name = std.mem.trim(u8, trimmed[0..colon], " \t\r\n");
209 const raw_value = std.mem.trim(u8, trimmed[colon + 1 ..], " \t\r\n");
210 if (raw_name.len == 0 or raw_value.len == 0) return .unknown;
211 var comparison = Comparison.eq;
212 var name = raw_name;
213 if (consumePrefixIgnoreCase(raw_name, "min-")) |rest| {
214 comparison = .gte;
215 name = rest;
216 } else if (consumePrefixIgnoreCase(raw_name, "max-")) |rest| {
217 comparison = .lte;
218 name = rest;
219 }
220 if (std.ascii.eqlIgnoreCase(name, "orientation")) {
221 if (comparison != .eq) return .unknown;
222 return boolResult(orientationMatches(raw_value, media) orelse return .unknown);
223 }
224 if (std.ascii.eqlIgnoreCase(name, "aspect-ratio")) {
225 const actual = aspectRatio(media) orelse return .unknown;
226 const expected = parseAspectRatio(raw_value) orelse return .unknown;
227 return boolResult(compareNumbers(actual, comparison, expected));
228 }
229 if (std.ascii.eqlIgnoreCase(name, "prefers-color-scheme")) {
230 if (comparison != .eq) return .unknown;
231 const expected: ColorScheme = if (std.ascii.eqlIgnoreCase(raw_value, "light"))
232 .light
233 else if (std.ascii.eqlIgnoreCase(raw_value, "dark"))
234 .dark
235 else
236 return .unknown;
237 return boolResult(media.color_scheme == expected);
238 }
239 if (std.ascii.eqlIgnoreCase(name, "prefers-reduced-motion")) {
240 if (comparison != .eq) return .unknown;
241 const expected: ReducedMotion = if (std.ascii.eqlIgnoreCase(raw_value, "reduce"))
242 .reduce
243 else if (std.ascii.eqlIgnoreCase(raw_value, "no-preference"))
244 .no_preference
245 else
246 return .unknown;
247 return boolResult(media.reduced_motion == expected);
248 }
249 const actual = mediaFeatureLength(name, media) orelse return .unknown;
250 const expected = parseMediaLength(raw_value, media.font_size) orelse return .unknown;
251 return boolResult(compareNumbers(actual, comparison, expected));
252 }
253 if (std.ascii.eqlIgnoreCase(trimmed, "width")) return boolResult(media.width != 0);
254 if (std.ascii.eqlIgnoreCase(trimmed, "height")) return boolResult(media.height != 0);
255 if (std.ascii.eqlIgnoreCase(trimmed, "orientation")) return .match;
256 if (std.ascii.eqlIgnoreCase(trimmed, "aspect-ratio")) return boolResult(aspectRatio(media) != null);
257 return .unknown;
258 }
259
260 fn evaluateRangeFeature(source: []const u8, first: ComparisonPosition, media: MediaEnvironment) QueryResult {
261 const left = std.mem.trim(u8, source[0..first.index], " \t\r\n");
262 const right_source = source[first.index + first.width ..];
263 if (findComparison(right_source, 0)) |second| {
264 const middle = std.mem.trim(u8, right_source[0..second.index], " \t\r\n");
265 const right = std.mem.trim(u8, right_source[second.index + second.width ..], " \t\r\n");
266 return queryAnd(evaluateComparison(left, first.comparison, middle, media), evaluateComparison(middle, second.comparison, right, media));
267 }
268 const right = std.mem.trim(u8, right_source, " \t\r\n");
269 return evaluateComparison(left, first.comparison, right, media);
270 }
271
272 fn evaluateComparison(left: []const u8, comparison: Comparison, right: []const u8, media: MediaEnvironment) QueryResult {
273 if (std.ascii.eqlIgnoreCase(left, "aspect-ratio")) {
274 const actual = aspectRatio(media) orelse return .unknown;
275 const expected = parseAspectRatio(right) orelse return .unknown;
276 return boolResult(compareNumbers(actual, comparison, expected));
277 }
278 if (std.ascii.eqlIgnoreCase(right, "aspect-ratio")) {
279 const expected = aspectRatio(media) orelse return .unknown;
280 const actual = parseAspectRatio(left) orelse return .unknown;
281 return boolResult(compareNumbers(actual, comparison, expected));
282 }
283 const left_value = mediaOperand(left, media) orelse return .unknown;
284 const right_value = mediaOperand(right, media) orelse return .unknown;
285 return boolResult(compareNumbers(left_value, comparison, right_value));
286 }
287
288 fn aspectRatio(media: MediaEnvironment) ?f32 {
289 if (!std.math.isFinite(media.width) or !std.math.isFinite(media.height) or media.height <= 0 or media.width < 0) return null;
290 return media.width / media.height;
291 }
292
293 fn parseAspectRatio(source: []const u8) ?f32 {
294 const trimmed = std.mem.trim(u8, source, " \t\r\n");
295 if (std.mem.indexOfScalar(u8, trimmed, '/')) |slash| {
296 const numerator = std.fmt.parseUnsigned(u32, std.mem.trim(u8, trimmed[0..slash], " \t\r\n"), 10) catch return null;
297 const denominator = std.fmt.parseUnsigned(u32, std.mem.trim(u8, trimmed[slash + 1 ..], " \t\r\n"), 10) catch return null;
298 if (denominator == 0) return null;
299 return @as(f32, @floatFromInt(numerator)) / @as(f32, @floatFromInt(denominator));
300 }
301 const numerator = std.fmt.parseUnsigned(u32, trimmed, 10) catch return null;
302 return @floatFromInt(numerator);
303 }
304
305 test "media evaluates aspect ratio and user preferences" {
306 const env = MediaEnvironment{
307 .width = 1200,
308 .height = 800,
309 .color_scheme = .dark,
310 .reduced_motion = .reduce,
311 };
312 try std.testing.expect(mediaListApplies("(min-aspect-ratio: 3/2)", env));
313 try std.testing.expect(mediaListApplies("(1/1 < aspect-ratio < 2/1)", env));
314 try std.testing.expect(!mediaListApplies("(aspect-ratio: 16/9)", env));
315 try std.testing.expect(mediaListApplies("(prefers-color-scheme: dark) and (prefers-reduced-motion: reduce)", env));
316 try std.testing.expect(!mediaListApplies("(prefers-reduced-motion: no-preference)", env));
317 try std.testing.expect(!mediaListApplies("not (resolution: 2dppx)", env));
318 try std.testing.expect(!mediaListApplies("not (-webkit-device-pixel-ratio: 2)", env));
319 }
320
321 fn mediaOperand(source: []const u8, media: MediaEnvironment) ?f32 {
322 const trimmed = std.mem.trim(u8, source, " \t\r\n");
323 if (mediaFeatureLength(trimmed, media)) |value| return value;
324 return parseMediaLength(trimmed, media.font_size);
325 }
326
327 fn mediaFeatureLength(name: []const u8, media: MediaEnvironment) ?f32 {
328 if (std.ascii.eqlIgnoreCase(name, "width")) return media.width;
329 if (std.ascii.eqlIgnoreCase(name, "height")) return media.height;
330 return null;
331 }
332
333 fn mediaTypeWord(word: []const u8) bool {
334 return std.ascii.eqlIgnoreCase(word, "all") or
335 std.ascii.eqlIgnoreCase(word, "screen") or
336 std.ascii.eqlIgnoreCase(word, "print") or
337 std.ascii.eqlIgnoreCase(word, "tty") or
338 std.ascii.eqlIgnoreCase(word, "tv") or
339 std.ascii.eqlIgnoreCase(word, "projection") or
340 std.ascii.eqlIgnoreCase(word, "handheld") or
341 std.ascii.eqlIgnoreCase(word, "braille") or
342 std.ascii.eqlIgnoreCase(word, "embossed") or
343 std.ascii.eqlIgnoreCase(word, "aural") or
344 std.ascii.eqlIgnoreCase(word, "speech");
345 }
346
347 fn mediaTypeMatches(word: []const u8, media: MediaEnvironment) QueryResult {
348 if (std.ascii.eqlIgnoreCase(word, "all")) return .match;
349 if (std.ascii.eqlIgnoreCase(word, "screen")) return boolResult(media.media_type == .screen);
350 if (std.ascii.eqlIgnoreCase(word, "print")) return boolResult(media.media_type == .print);
351 return .miss;
352 }
353
354 fn orientationMatches(value: []const u8, media: MediaEnvironment) ?bool {
355 if (std.ascii.eqlIgnoreCase(value, "portrait")) return media.height >= media.width;
356 if (std.ascii.eqlIgnoreCase(value, "landscape")) return media.width > media.height;
357 return null;
358 }
359
360 fn parseMediaLength(value: []const u8, font_size: f32) ?f32 {
361 const trimmed = std.mem.trim(u8, value, " \t\r\n");
362 if (std.mem.eql(u8, trimmed, "0")) return 0;
363 if (endsWithIgnoreCase(trimmed, "rem")) {
364 const number = std.fmt.parseFloat(f32, trimmed[0 .. trimmed.len - 3]) catch return null;
365 return number * font_size;
366 }
367 if (endsWithIgnoreCase(trimmed, "em")) {
368 const number = std.fmt.parseFloat(f32, trimmed[0 .. trimmed.len - 2]) catch return null;
369 return number * font_size;
370 }
371 if (endsWithIgnoreCase(trimmed, "px")) {
372 return std.fmt.parseFloat(f32, trimmed[0 .. trimmed.len - 2]) catch null;
373 }
374 return null;
375 }
376
377 fn compareNumbers(left: f32, comparison: Comparison, right: f32) bool {
378 return switch (comparison) {
379 .lt => left < right,
380 .lte => left <= right,
381 .eq => left == right,
382 .gte => left >= right,
383 .gt => left > right,
384 };
385 }
386
387 fn queryNot(value: QueryResult) QueryResult {
388 return switch (value) {
389 .match => .miss,
390 .miss => .match,
391 .unknown => .unknown,
392 };
393 }
394
395 fn queryAnd(left: QueryResult, right: QueryResult) QueryResult {
396 if (left == .miss or right == .miss) return .miss;
397 if (left == .unknown or right == .unknown) return .unknown;
398 return .match;
399 }
400
401 fn queryOr(left: QueryResult, right: QueryResult) QueryResult {
402 if (left == .match or right == .match) return .match;
403 if (left == .unknown or right == .unknown) return .unknown;
404 return .miss;
405 }
406
407 fn boolResult(value: bool) QueryResult {
408 return if (value) .match else .miss;
409 }
410
411 fn findComparison(source: []const u8, start: usize) ?ComparisonPosition {
412 var index = start;
413 while (index < source.len) : (index += 1) {
414 switch (source[index]) {
415 '<' => {
416 if (index + 1 < source.len and source[index + 1] == '=') return .{ .index = index, .comparison = .lte, .width = 2 };
417 return .{ .index = index, .comparison = .lt, .width = 1 };
418 },
419 '>' => {
420 if (index + 1 < source.len and source[index + 1] == '=') return .{ .index = index, .comparison = .gte, .width = 2 };
421 return .{ .index = index, .comparison = .gt, .width = 1 };
422 },
423 '=' => return .{ .index = index, .comparison = .eq, .width = 1 },
424 else => {},
425 }
426 }
427 return null;
428 }