Bug 1881621 - Add colors/color_canvas.html tests to dom/canvas/test/reftest. r=bradwerth
[gecko.git] / dom / canvas / test / reftest / colors / color_canvas.html
blob7abbc8625528464ae525e9b639ad8a1108b28bfc
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset=utf-8>
5 <title>color_canvas.html</title>
6 </head>
7 <!--
8 # color_canvas.html
10 * Default is a 100x100 'css' canvas-equivalent div, filled with 50% srgb red.
12 * We default to showing the settings pane when loaded without a query string.
13 This way, someone naively opens this in a browser, they can immediately see
14 all available options.
16 * The 'Publish' button updates the url, and so causes the settings pane to
17 hide.
19 * Clicking on the canvas toggles the settings pane for further editing.
20 -->
21 <body>
22 <form id=e_settings><fieldset><legend>Settings</legend>
23 Width: <input id=e_width type=text value=100>
24 <br>
25 Height: <input id=e_height type=text value=100>
26 <br>
27 <fieldset><legend>Canvas Context</legend>
28 Type: <select id=e_context>
29 <option value=css selected>css</option>
30 <option value=2d>2d</option>
31 <option value=webgl>webgl</option>
32 </select>
33 <br>
34 Options: <input id=e_options type=text value={}>
35 <br>
36 Colorspace: <input id=e_cspace type=text placeholder=srgb>
37 <br>
38 WebGL Format: <input id=e_webgl_format type=text placeholder=RGBA8>
39 </fieldset>
40 <br>
41 Color: <input id=e_color type=text value='color(srgb 0 0.5 0)'>
42 <br>
43 <input id=e_publish type=button value=Publish>
44 <input type=checkbox id=e_publish_omit_defaults checked><label for=e_publish_omit_defaults>Omit defaults</label>
45 <hr>
46 </fieldset></form>
47 <div id=e_canvas_list><canvas></canvas></div>
48 <script>
49 'use strict';
51 // -
53 function walk_nodes_depth_first(e, fn) {
54 if (fn(e) === false) return; // Don't stop on `true`, or `undefined`!
55 for (const c of e.childNodes) {
56 walk_nodes_depth_first(c, fn);
60 // -
62 // Click the canvas to toggle the settings pane.
63 e_canvas_list.addEventListener('click', () => {
64 // Toggle display:none to hide/unhide.
65 e_settings.hidden = !e_settings.hidden;
66 });
68 // Hide settings initially if there's a query string in the url.
69 if (window.location.search.startsWith('?')) {
70 e_settings.hidden = true;
73 // -
75 // Imply .name from .id, because `new FormData` collects based on names.
76 walk_nodes_depth_first(e_settings, e => {
77 if (e.id) {
78 e.name = e.id;
79 e._default_value = e.value;
81 });
83 // -
85 const URL_PARAMS = new URLSearchParams(window.location.search);
86 URL_PARAMS.forEach((v,k) => {
87 const e = window[k];
88 if (!e) {
89 if (k) {
90 console.warn(`Unrecognized setting: ${k} = ${v}`);
92 return;
94 v = decode_url_v(k,v);
95 e.value = v;
96 });
98 // -
100 globalThis.ASSERT = (() => {
101 function toPrettyString(arg) {
102 if (!arg) return ''+arg;
104 if (arg.call) {
105 let s = arg.toString();
106 const RE_TRIVIAL_LAMBDA = /\( *\) *=> *(.*)/;
107 const m = RE_TRIVIAL_LAMBDA.exec(s);
108 if (m) {
109 s = '`' + m[1] + '`';
111 return s;
113 if (arg.constructor == Array) {
114 return `[${[].join.call(arg, ', ')}]`;
116 return JSON.stringify(arg);
119 /// new AssertArg(): Construct a wrapper for args to assert functions.
120 function AssertArg(dict) {
121 this.label = Object.keys(dict)[0];
123 this.set = function(arg) {
124 this.arg = arg;
125 this.value = (arg && arg.call) ? arg.call() : arg;
126 this.value = toPrettyString(this.value);
128 this.set(dict[this.label]);
130 this.toString = function() {
131 let ret = `${this.label} ${toPrettyString(this.arg)}`;
132 if (this.arg.call) {
133 ret += ` (${this.value})`;
135 return ret;
139 const eq = (a,b) => a == b;
140 const neq = (a,b) => a != b;
142 const CMP_BY_NAME = {
143 '==': eq,
144 '!=': neq,
147 function IS(cmp, was, expected, _console) {
148 _console = _console || console;
150 _console.assert(was.call, '`was.call` not defined.');
151 was = new AssertArg({was});
152 expected = new AssertArg({expected});
154 const fn_cmp = CMP_BY_NAME[cmp] || cmp;
156 _console.assert(fn_cmp(was.value, expected.value), `${toPrettyString(was.arg)} => ${was.value} not ${cmp} ${expected}`);
157 if (was.value != expected.value) {
158 } else if (globalThis.ASSERT && globalThis.ASSERT.verbose) {
159 const maybe_cmp_str = (cmp == '==') ? '' : ` ${was.value} ${cmp}`;
160 _console.log(`${toPrettyString(was.arg)} => ${maybe_cmp_str}${expected}`);
164 // -
166 const MOCK_CONSOLE = {
167 _asserts: [],
168 assert: function(expr, ...args) {
169 if (!expr) {
170 this._asserts.push(args);
173 log: function(...args) {
174 // Don't record.
178 // -
179 // Test `==`
181 IS('==', () => 1, 1, MOCK_CONSOLE);
182 console.assert(MOCK_CONSOLE._asserts.length == 0, MOCK_CONSOLE._asserts);
183 MOCK_CONSOLE._asserts = [];
185 IS('==', () => 2, 2, MOCK_CONSOLE);
186 console.assert(MOCK_CONSOLE._asserts.length == 0, MOCK_CONSOLE._asserts);
187 MOCK_CONSOLE._asserts = [];
189 IS('==', () => 5, () => 3, MOCK_CONSOLE);
190 console.assert(MOCK_CONSOLE._asserts.length == 1, MOCK_CONSOLE._asserts);
191 MOCK_CONSOLE._asserts = [];
193 IS('==', () => [1,2], () => [1,2], MOCK_CONSOLE);
194 console.assert(MOCK_CONSOLE._asserts.length == 0, MOCK_CONSOLE._asserts);
195 MOCK_CONSOLE._asserts = [];
197 // -
198 // Test `!=`
200 IS('!=', () => [1,2,5], () => [1,2,3], MOCK_CONSOLE);
201 console.assert(MOCK_CONSOLE._asserts.length == 0, MOCK_CONSOLE._asserts);
202 MOCK_CONSOLE._asserts = [];
204 // -
206 const ret = {
207 verbose: false,
210 ret.EQ = (was,expected) => ret.IS('==', was, expected);
211 ret.NEQ = (was,expected) => ret.IS('!=', was, expected);
212 ret.EEQ = (was,expected) => ret.IS('===', was, expected);
213 ret.NEEQ = (was,expected) => ret.IS('!==', was, expected);
214 ret.TRUE = was => ret.EQ(was, true);
215 ret.FALSE = was => ret.EQ(was, false);
216 ret.NULL = was => ret.EEQ(was, null);
217 return ret;
218 })();
220 // -
222 function parse_css_rgb(str) {
223 // rgb (R ,G ,B /A )
224 const m = /rgba?\(([^,]+),([^,]+),([^/)]+)(?: *\/([^)]+))?\)/.exec(str);
225 if (!m) throw str;
226 const rgba = m.slice(1,1+4).map((s,i) => {
227 if (s === undefined && i == 3) {
228 s = '1'; // Alpha defaults to 1.
230 s = s.trim();
231 let v = parseFloat(s);
232 if (s.endsWith('%')) {
233 v /= 100;
234 } else {
235 if (i < 3) { // r,g,b but not a!
236 v /= 255;
239 return v;
241 return rgba;
243 ASSERT.EQ(() => parse_css_rgb('rgb(255,255,255)'), [1,1,1,1]);
244 ASSERT.EQ(() => parse_css_rgb('rgba(255,255,255)'), [1,1,1,1]);
245 ASSERT.EQ(() => parse_css_rgb('rgb(255,255,255)'), [1,1,1,1]);
246 ASSERT.EQ(() => parse_css_rgb('rgba(255,255,255)'), [1,1,1,1]);
247 ASSERT.EQ(() => parse_css_rgb('rgb(20,40,60)'), () => [20/255, 40/255, 60/255, 1]);
248 ASSERT.EQ(() => parse_css_rgb('rgb(20,40,60 / 0.5)'), () => [20/255, 40/255, 60/255, 0.5]);
249 ASSERT.EQ(() => parse_css_rgb('rgb(20,40,60 / 0)'), () => [20/255, 40/255, 60/255, 0]);
251 // -
253 function parse_css_color(str) {
254 // color ( srgb R G B /A )
255 const m = /color\( *([^ ]+) +([^ ]+) +([^ ]+) +([^/)]+)(?:\/([^)]+))?\)/.exec(str);
256 if (!m) {
257 return ['srgb', ...parse_css_rgb(str)];
260 const cspace = m[1].trim();
261 let has_extreme_colors = false;
262 const rgba = m.slice(2, 2+4).map((s,i) => {
263 if (s === undefined && i == 3) {
264 s = '1'; // Alpha defaults to 1.
266 s = s.trim();
267 let v = parseFloat(s);
268 if (s.endsWith('%')) {
269 v /= 100;
271 if (v < 0 || v > 1) {
272 has_extreme_colors = true;
274 return v;
276 if (has_extreme_colors) {
277 console.warn(`parse_css_color('${str}') has colors outside [0.0,1.0]: ${JSON.stringify(rgba)}`);
279 return [cspace, ...rgba];
281 ASSERT.EQ(() => parse_css_color('rgb(255,255,255)'), ['srgb',1,1,1,1]);
282 ASSERT.EQ(() => parse_css_color('rgb(20,40,60 / 0.5)'), () => ['srgb', 20/255, 40/255, 60/255, 0.5]);
283 ASSERT.EQ(() => parse_css_color('color(srgb 1 0 1 /0.3)'), ['srgb',1,0,1,0.3]);
284 ASSERT.EQ(() => parse_css_color('color(display-p3 1 0% 100%/ 30%)'), ['display-p3',1,0,1,0.3]);
286 // -
288 class CssColor {
289 constructor(cspace, r,g,b,a=1) {
290 this.cspace = cspace;
291 this.rgba = [this.r, this.g, this.b, this.a] = [r,g,b,a];
292 this.rgb = this.rgba.slice(0,3);
293 this.tuple = [this.cspace, ...this.rgba];
296 toString() {
297 return `color(${this.cspace} ${this.rgb.join(' ')} / ${this.a})`;
300 CssColor.parse = function(str) {
301 return new CssColor(...parse_css_color(str));
305 let STR;
306 // Test round-trip.
307 STR = 'color(display-p3 1 0 1 / 0.3)';
308 ASSERT.EQ(() => CssColor.parse(STR).toString(), STR);
310 // Test round-trip normalization
311 ASSERT.EQ(() => CssColor.parse('color( display-p3 1 0 1/30% )').toString(), 'color(display-p3 1 0 1 / 0.3)');
314 // -
316 function redraw() {
317 while (e_canvas_list.firstChild) {
318 e_canvas_list.removeChild(e_canvas_list.firstChild);
321 const c = make_canvas(e_color.value.trim());
322 c.style.border = '4px solid black';
323 e_canvas_list.appendChild(c);
326 function fill_canvas_rect(context /*: CanvasRenderingContext | WebGLRenderingContext*/, css_color, rect=null) {
327 rect = rect || {left: 0, top: 0, w: context.canvas.width, h: context.canvas.height};
329 const is_c2d = ('fillRect' in context);
330 if (is_c2d) {
331 const c2d = context;
332 c2d.fillStyle = css_color;
333 c2d.fillRect(rect.left, rect.top, rect.w, rect.h);
334 return;
337 const is_webgl = ('drawArrays' in context);
338 if (is_webgl) {
339 const gl = context;
340 console.assert(context.canvas.width == gl.drawingBufferWidth, context.canvas.width, '!=', gl.drawingBufferWidth);
341 console.assert(context.canvas.height == gl.drawingBufferHeight, context.canvas.height, '!=', gl.drawingBufferHeight);
343 gl.enable(gl.SCISSOR_TEST);
344 gl.disable(gl.DEPTH_TEST);
345 const bottom = rect.top + rect.h; // in y-down c2d coords
346 gl.scissor(rect.left, context.canvas.height - bottom, rect.w, rect.h);
348 const canvas_cspace = context.drawingBufferColorSpace || 'srgb';
349 if (css_color.cspace != canvas_cspace) {
350 console.warn(`Ignoring mismatched color vs webgl canvas cspace: ${css_color.cspace} vs ${canvas_cspace}`);
352 gl.clearColor(...css_color.rgba);
353 gl.clear(gl.COLOR_BUFFER_BIT);
354 return;
357 console.error('Unhandled context kind:', context);
360 window.e_canvas = null;
362 function make_canvas(css_color) {
363 css_color = CssColor.parse(css_color);
365 // `e_width` and e_friends are elements (by id) that we added to the raw HTML above.
366 // `e_width` is an old shorthand for `window.e_width || document.getElementById('e_width')`.
367 const W = parseInt(e_width.value);
368 const H = parseInt(e_height.value);
369 if (e_context.value == 'css') {
370 e_canvas = document.createElement('div');
371 e_canvas.style.width = `${W}px`;
372 e_canvas.style.height = `${H}px`;
373 e_canvas.style.backgroundColor = css_color;
374 return e_canvas;
376 e_canvas = document.createElement('canvas');
377 e_canvas.width = W;
378 e_canvas.height = H;
380 let requested_options = JSON.parse(e_options.value);
381 requested_options.colorSpace = e_cspace.value || undefined;
383 const context = e_canvas.getContext(e_context.value, requested_options);
384 if (requested_options.colorSpace) {
385 if (!context.drawingBufferColorSpace) {
386 console.warn(`${context.constructor.name}.drawingBufferColorSpace not supported by browser.`);
387 } else {
388 context.drawingBufferColorSpace = requested_options.colorSpace;
392 if (e_webgl_format.value) {
393 if (!context.drawingBufferStorage) {
394 console.warn(`${context.constructor.name}.drawingBufferStorage not supported by browser.`);
395 } else {
396 context.drawingBufferStorage(W, H, context[e_webgl_format.value]);
400 let actual_options;
401 if (!context.getContextAttributes) {
402 console.warn(`${canvas.constructor.name}.getContextAttributes not supported by browser.`);
403 actual_options = requested_options;
404 } else {
405 actual_options = context.getContextAttributes();
408 // -
410 fill_canvas_rect(context, css_color);
412 return e_canvas;
415 e_settings.addEventListener('change', async () => {
416 redraw();
417 const e_updated = document.createElement('i');
418 e_updated.textContent = '(Updated!)';
419 document.body.appendChild(e_updated);
420 await new Promise(go => setTimeout(go, 1000));
421 document.body.removeChild(e_updated);
423 redraw();
425 // -
427 function encode_url_v(k,v) {
428 if (k == 'e_color') {
429 v = v.replaceAll(' ', ',');
431 console.assert(!v.includes(' '), v);
432 return v
434 function decode_url_v(k,v) {
435 console.assert(!v.includes(' '), v);
436 if (k == 'e_color') {
437 v = v.replaceAll(',', ' ');
439 return v
441 ASSERT.EQ(() => decode_url_v('e_color', encode_url_v('e_color', 'color(srgb 1 0 0)')), 'color(srgb 1 0 0)')
443 e_publish.addEventListener('click', () => {
444 const fd = new FormData(e_settings);
445 let settings = [];
446 for (let [k,v] of fd) {
447 const e = window[k];
448 if (e_publish_omit_defaults.checked && v == e._default_value) continue;
450 v = encode_url_v(k,v);
451 settings.push(`${k}=${v}`);
453 settings = settings.join('&');
454 if (!settings) {
455 settings = '='; // Empty key-value pair is 'publish with default settings'
457 window.location.search = '?' + settings;
459 </script>
460 </body>
461 </html>