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 }