commit 0e9819b3c8ae56ed15e71ddbfad10faa67d81c5c parent d04ae371a1e1c4fc9a4ef80c53cb734ecd8af717 Author: Noam Rosenthal <nrosenthal@chromium.org> Date: Mon, 8 Dec 2025 12:27:38 +0000 Bug 2004337 [wpt PR 56503] - Fix corner-shape shadow & outline rendering, a=testonly Automatic update from web-platform-tests Fix corner-shape shadow & outline rendering As per resolution in https://github.com/w3c/csswg-drafts/issues/13037#issuecomment-3582530992 Shadows should be border-aligned, like border inset, with external "miters" that are clipped to the outer edge. The miter is computed by extending the start and end tangents of the curve until they reach the outer edge. The tangent is the line between the start point, and the "control point": a point that would be the quadratic control point if this was a normal quadratic curve with the same half-corner. As part of this work, refactoring the rendering tests to be both more precise and closer to something that can be reasoned about being close to the spec. See spec PR: https://github.com/w3c/csswg-drafts/pull/13173 The PR and the WPT rendering code were developed side by side to ensure that the tests assert the spec. Since the spec defines that curves are approximate, and ref tests can fail on antialiasing and curve inaccuracies, the corners are also stroked with a blue 3px line on both the actual and ref versions, to avoid test failures in these occasions. (This change is guarded by the BorderRadiusCorrectionCoverageFactor, as without it shadow rects wouldn't have an origin rect, so the changed code paths is never reached). Bug: 463996869 Change-Id: I7f586440f47757416b8eb9a2002629cd29e0174a Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7215437 Reviewed-by: Philip Rogers <pdr@chromium.org> Commit-Queue: Noam Rosenthal <nrosenthal@google.com> Cr-Commit-Position: refs/heads/main@{#1554276} -- wpt-commits: c37199cbb0213e61f2aa1e9d53c2479ff7ab8a22 wpt-pr: 56503 Diffstat:
8 files changed, 713 insertions(+), 302 deletions(-)
diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-any-ref.html b/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-any-ref.html @@ -1,28 +0,0 @@ -<!DOCTYPE html> -<meta charset="utf-8"> -<title>CSS Borders and Box Decorations 4: 'corner-shape' parametric rendering</title> -<canvas id="target" width="400" height="400"></canvas> -<script src="resources/resolve-corner-style.js"></script> -<script src="resources/corner-utils.js"></script> -<script src="resources/corner-shape.js"></script> -<style> - body { - margin: 0; - } -</style> -<script> -const canvas = document.getElementById("target"); -const params = new URLSearchParams(location.search); -const width = params.get("width") || 200; -const height = params.get("height") || 100; -canvas.width = width * 3; -canvas.height = height * 3; -canvas.style.top = -height + "px"; -canvas.style.left = -width + "px"; -canvas.style.position = "relative"; -const ctx = canvas.getContext("2d"); -ctx.translate(width, height); -const style = resolve_corner_style(Object.fromEntries(params.entries()), width, height); -style['background-color'] = "green"; -render_rect_with_corner_shapes(style, ctx, width, height); -</script> diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-gallery.manual.html b/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-gallery.manual.html @@ -20,8 +20,8 @@ label { iframe { overflow: clip; - width: 250px; - height: 150px; + width: 500px; + height: 300px; border: none; } @@ -41,7 +41,7 @@ main { </template> <script> addEventListener("DOMContentLoaded", async () => { - const test_files = ["corner-shape-render-precise.html", "corner-shape-render-fuzzy.html"] + const test_files = ["render-corner-shape.html"] const dom_parser = new DOMParser(); for (const test_file of test_files) { const test_html = await (await fetch(test_file)).text(); @@ -51,7 +51,7 @@ main { const scenario = document.getElementById("scenario").content.cloneNode(true); scenario.querySelector(".variant").innerText = variant; scenario.querySelector(".test").src = `${test_file}${variant}`; - scenario.querySelector(".ref").src = `corner-shape-any-ref.html${variant}`; + scenario.querySelector(".ref").src = `render-corner-shape-ref.html${variant}`; document.querySelector("main").append(scenario); } } diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-render-fuzzy.html b/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-render-fuzzy.html @@ -1,48 +0,0 @@ -<!DOCTYPE html> -<meta charset="utf-8"> -<title>CSS Borders and Box Decorations 4: 'corner-shape' parametric rendering</title> -<link rel="help" href="https://drafts.csswg.org/css-borders-4/#corner-shaping"> -<link rel="match" href="corner-shape-any-ref.html"> -<meta name="fuzzy" content="maxDifference=0-200;totalPixels=0-550"> -<meta name="variant" content="?corner-shape=scoop&border-radius=20%&border-width=20px"> -<meta name="variant" content="?corner-shape=superellipse(-2)&border-radius=20%&border-width=20px"> -<meta name="variant" content="?corner-top-left-shape=bevel&border-radius=40px&border-width=10px"> -<meta name="variant" content="?corner-top-left-shape=scoop&corner-top-right-shape=scoop&border-radius=50%"> -<meta name="variant" content="?corner-shape=squircle&border-radius=25%&border-width=20px"> -<meta name="variant" content="?corner-shape=squircle&border-radius=25%&box-shadow=10px 10px 0 10px black"> -<meta name="variant" content="?corner-shape=squircle&border-radius=50%"> -<meta name="variant" content="?corner-shape=superellipse(-7)&border-radius=20%&border-width=20px"> -<meta name="variant" content="?corner-shape=superellipse(5)&border-radius=20%&border-width=20px"> -<meta name="variant" content="?corner-top-left-shape=bevel&corner-bottom-right-shape=bevel&border-radius=40px&border-width=10px"> -<meta name="variant" content="?corner-top-left-shape=superellipse(-4)&border-radius=40%"> -<meta name="variant" content="?corner-top-left-shape=superellipse(2.5)&border-radius=20%&border-width=10px"> -<meta name="variant" content="?corner-top-right-shape=scoop&border-radius=20%&border-width=10px"> -<meta name="variant" content="?corner-shape=superellipse(0.8)&border-radius=40px&border-width=10px"> -<meta name="variant" content="?corner-shape=superellipse(3)&border-radius=40px&box-shadow=10px 10px 0 10px black"> -<meta name="variant" content="?border-radius=30%&corner-shape=superellipse(-1.5)&box-shadow=10px%2010px%200%2010px%20black"> -<meta name="variant" content="?border-radius=40%&corner-shape=notch&box-shadow=10px%2010px%200%2010px%20yellow"> -<meta name="variant" content="?border-radius=50%&corner-top-left-shape=scoop&corner-bottom-right-shape=scoop&corner-top-right-shape=notch&corner-bottom-left-shape=notch&border-width=10px"> -<meta name="variant" content="?border-radius=50%&corner-top-right-shape=scoop&corner-bottom-left-shape=scoop&corner-top-left-shape=notch&corner-bottom-right-shape=notch&border-width=10px"> -<meta name="variant" content="?border-radius=50%&corner-shape=bevel&box-shadow=10px%2010px%200%2010px%20black"> -<style> - body { - margin: 0; - } - #target { - width: 200px; - height: 100px; - box-sizing: border-box; - background: green; - border-style: solid; - border-color: black; - border-width: 0; - } -</style> -<div id="target"></div> -<script> - const target = document.getElementById("target"); - const params = new URLSearchParams(location.search); - for (const [k, v] of params) { - target.style[k] = v; - } -</script> diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-render-precise.html b/testing/web-platform/tests/css/css-borders/corner-shape/corner-shape-render-precise.html @@ -1,50 +0,0 @@ -<!DOCTYPE html> -<meta charset="utf-8"> -<title>CSS Borders and Box Decorations 4: 'corner-shape' parametric rendering</title> -<link rel="help" href="https://drafts.csswg.org/css-borders-4/#corner-shaping"> -<link rel="match" href="corner-shape-any-ref.html"> -<meta name="fuzzy" content="maxDifference=0-180;totalPixels=0-350"> -<meta name="variant" content="?corner-shape=squircle&border-top-left-radius=30%"> -<meta name="variant" content="?corner-shape=superellipse(-2)&border-top-left-radius=40%&border-width=20px"> -<meta name="variant" content="?corner-shape=squircle&border-top-right-radius=30px"> -<meta name="variant" content="?corner-shape=square&border-bottom-left-radius=5px"> -<meta name="variant" content="?corner-shape=superellipse(2.3)&border-radius=40%"> -<meta name="variant" content="?corner-shape=superellipse(3)&border-top-right-radius=33px"> -<meta name="variant" content="?corner-top-right-shape=superellipse(-4)&border-top-right-radius=50px"> -<meta name="variant" content="?corner-bottom-right-shape=superellipse(0.8)&border-bottom-right-radius=50%"> -<meta name="variant" content="?corner-top-left-shape=bevel&border-radius=40px"> -<meta name="variant" content="?corner-top-left-shape=scoop&border-radius=40px"> -<meta name="variant" content="?corner-top-left-shape=superellipse(-4)&border-radius=40px"> -<meta name="variant" content="?corner-top-left-shape=superellipse(0.5)&border-radius=40px"> -<meta name="variant" content="?corner-top-left-shape=superellipse(-0.5)&border-radius=40px"> -<meta name="variant" content="?corner-shape=squircle&border-top-left-radius=25%&border-width=10px"> -<meta name="variant" content="?corner-bottom-left-shape=bevel&border-bottom-left-radius=30px"> -<meta name="variant" content="?corner-top-left-shape=bevel&border-width=10px"> -<meta name="variant" content="?corner-top-right-shape=bevel&border-width=10px"> -<meta name="variant" content="?corner-bottom-left-shape=bevel&border-width=10px&border-radius=20px"> -<meta name="variant" content="?corner-bottom-right-shape=bevel&border-width=10px&border-radius=20px"> -<meta name="variant" content="?corner-bottom-right-shape=bevel&corner-bottom-left-shape=bevel"> -<meta name="variant" content="?border-top-left-radius=50%&corner-shape=superellipse(0.7)&border-left-width=30px&border-top-width=30px"> -<meta name="variant" content="?corner-shape=notch&border-radius=30px&border-width=30px"> -<style> - body { - margin: 0; - } - #target { - width: 200px; - height: 100px; - box-sizing: border-box; - background: green; - border-style: solid; - border-color: black; - border-width: 0; - } -</style> -<div id="target"></div> -<script> - const target = document.getElementById("target"); - const params = new URLSearchParams(location.search); - for (const [k, v] of params) { - target.style[k] = v; - } -</script> diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/render-corner-shape-ref.html b/testing/web-platform/tests/css/css-borders/corner-shape/render-corner-shape-ref.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> + <head> + <title>Render corner shape</title> + <script src="./resources/render-corner-shape.js"></script> + </head> + <body> + <script> + const element = create_element_with_corner_shape( + new URLSearchParams(location.search), + "ref" + ); + document.body.appendChild(element); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/render-corner-shape.html b/testing/web-platform/tests/css/css-borders/corner-shape/render-corner-shape.html @@ -0,0 +1,188 @@ +<!DOCTYPE html> +<html> + <head> + <title>Render corner shape</title> + <link rel="match" href="render-corner-shape-ref.html" > + <link rel="help" href="https://drafts.csswg.org/css-borders-4/#corner-shape-rendering"> + <meta name="fuzzy" content="maxDifference=0-64;totalPixels=0-128"> + <meta + name="variant" + content="?border-top-right-radius=100&corner-top-right-shape=0&shadow-spread=30" + > + <meta + name="variant" + content="?border-radius=40&corner-shape=3&shadow-spread=10" + > + <meta + name="variant" + content="?border-radius=40&corner-shape=-1.5&shadow-spread=10" + > + <meta + name="variant" + content="?border-radius=40&corner-shape=-infinity&shadow-spread=10" + > + <meta + name="variant" + content="?border-radius=50&corner-shape=0&shadow-spread=10" + > + <meta + name="variant" + content="?border-radius=25&corner-shape=2&shadow-spread=10" + > + <meta + name="variant" + content="?border-radius=5&corner-top-left-shape=0.5&corner-bottom-right-shape=-0.5&shadow-spread=10" + > + <meta + name="variant" + content="?corner-top-left-shape=0&corner-top-right-shape=notch&corner-bottom-right-shape=2&corner-bottom-left-shape=-1&border-width=20&shadow-spread=20&border-radius=50" + > + <meta + name="variant" + content="?corner-shape=scoop&border-radius=20%&border-width=20" + > + <meta + name="variant" + content="?corner-shape=-2&border-radius=20%&border-width=20" + > + <meta + name="variant" + content="?corner-top-left-shape=bevel&border-radius=40&border-width=10" + > + <meta + name="variant" + content="?corner-top-left-shape=scoop&corner-top-right-shape=scoop&border-radius=50%" + > + <meta + name="variant" + content="?corner-shape=squircle&border-radius=25%&border-width=20" + > + <meta name="variant" content="?corner-shape=squircle&border-radius=50%" > + <meta + name="variant" + content="?corner-shape=-7&border-radius=20%&border-width=20" + > + <meta + name="variant" + content="?corner-shape=5&border-radius=20%&border-width=20" + > + <meta + name="variant" + content="?corner-top-left-shape=bevel&corner-bottom-right-shape=bevel&border-radius=40&border-width=10" + > + <meta + name="variant" + content="?corner-top-left-shape=-4&border-radius=40%" + > + <meta + name="variant" + content="?corner-top-left-shape=2.5&border-radius=20%&border-width=10" + > + <meta + name="variant" + content="?corner-top-right-shape=scoop&border-radius=20%&border-width=10" + > + <meta + name="variant" + content="?corner-shape=0.8&border-radius=40&border-width=10" + > + <meta + name="variant" + content="?border-radius=50%&corner-top-left-shape=scoop&corner-bottom-right-shape=scoop&corner-top-right-shape=notch&corner-bottom-left-shape=notch&border-width=10" + > + <meta + name="variant" + content="?border-radius=50%&corner-top-right-shape=scoop&corner-bottom-left-shape=scoop&corner-top-left-shape=notch&corner-bottom-right-shape=notch&border-width=10" + > + <meta + name="variant" + content="?corner-shape=squircle&border-top-left-radius=30%" + > + <meta + name="variant" + content="?corner-shape=-2&border-top-left-radius=40%&border-width=20" + > + <meta + name="variant" + content="?corner-shape=squircle&border-top-right-radius=30" + > + <meta + name="variant" + content="?corner-shape=square&border-bottom-left-radius=5" + > + <meta name="variant" content="?corner-shape=2.3&border-radius=40%" > + <meta name="variant" content="?corner-shape=3&border-top-right-radius=33" > + <meta + name="variant" + content="?corner-top-right-shape=-4&border-top-right-radius=50" + > + <meta + name="variant" + content="?corner-bottom-right-shape=0.8&border-bottom-right-radius=50%" + > + <meta + name="variant" + content="?corner-top-left-shape=bevel&border-radius=40" + > + <meta + name="variant" + content="?corner-top-left-shape=scoop&border-radius=40" + > + <meta name="variant" content="?corner-top-left-shape=-4&border-radius=40" > + <meta + name="variant" + content="?corner-top-left-shape=0.5&border-radius=40" + > + <meta + name="variant" + content="?corner-top-left-shape=-0.5&border-radius=40" + > + <meta + name="variant" + content="?corner-shape=squircle&border-top-left-radius=25%&border-width=10" + > + <meta + name="variant" + content="?corner-bottom-left-shape=bevel&border-bottom-left-radius=30" + > + <meta + name="variant" + content="?corner-top-left-shape=bevel&border-width=10" + > + <meta + name="variant" + content="?corner-top-right-shape=bevel&border-width=10" + > + <meta + name="variant" + content="?corner-bottom-left-shape=bevel&border-width=10&border-radius=20" + > + <meta + name="variant" + content="?corner-bottom-right-shape=bevel&border-width=10&border-radius=20" + > + <meta + name="variant" + content="?corner-bottom-right-shape=bevel&corner-bottom-left-shape=bevel" + > + <meta + name="variant" + content="?border-top-left-radius=50%&corner-shape=0.7&border-left-width=30&border-top-width=30" + > + <meta + name="variant" + content="?corner-shape=notch&border-radius=30&border-width=30" + > + + <script src="./resources/render-corner-shape.js"></script> + </head> + <body> + <script> + const element = create_element_with_corner_shape( + new URLSearchParams(location.search), + "actual" + ); + document.body.appendChild(element); + </script> + </body> +</html> diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/resources/corner-shape.js b/testing/web-platform/tests/css/css-borders/corner-shape/resources/corner-shape.js @@ -1,172 +0,0 @@ -/** - * Use short lines that follow the superellipse formula to generate - * a path that approximates a superellipse. - * - * @param {CanvasRenderingContext2D} ctx - * @param {number} ax - * @param {number} ay - * @param {number} bx - * @param {number} by - * @param {number} curvature - * @param {*} phase - * @param {*} direction - * @returns - */ -function add_corner(ctx, ax, ay, bx, by, curvature) { - const vertical_first = Math.sign(bx - ax) === Math.sign(by - ay); - function map_point({ x, y }) { - if (vertical_first) { - y = 1 - y; - } else { - [x, y] = [1 - y, x]; - } - - return [ax + x * (bx - ax), ay + y * (by - ay)]; - } - - if (curvature > 1000) { - ctx.lineTo(...map_point({ x: 0, y: 1 })); - ctx.lineTo(...map_point({ x: 1, y: 1 })); - ctx.lineTo(...map_point({ x: 0, y: 1 })); - return; - } - - if (curvature <= 0.001) { - ctx.lineTo(...map_point({ x: 0, y: 1 })); - ctx.lineTo(...map_point({ x: 0, y: 0 })); - ctx.lineTo(...map_point({ x: 1, y: 0 })); - return; - } - - function xy_for_t(t) { - return map_point(superellipse(curvature, t)); - } - - ctx.lineTo(ax, ay); - const t_values = new Set(); - const antialiasing_offset = 0.25; - for ( - let x = Math.min(ax, bx) + antialiasing_offset; - x < Math.max(ax, bx); - ++x - ) { - const nx = (x - ax) / (bx - ax); - const t = vertical_first - ? superellipse_t_for_x(nx, curvature) - : superellipse_t_for_y(1 - nx, curvature); - if (t > 0 && t < 1) t_values.add(t); - } - - for ( - let y = Math.min(ay, by) + antialiasing_offset; - y < Math.max(ay, by); - ++y - ) { - const ny = (y - ay) / (by - ay); - const t = vertical_first - ? superellipse_t_for_y(1 - ny, curvature) - : superellipse_t_for_x(1 - ny, curvature); - if (t > 0 && t < 1) t_values.add(t); - } - - for (const t of [...t_values].sort()) { - const [x, y] = xy_for_t(t); - ctx.lineTo(x, y); - } - ctx.lineTo(bx, by); -} - -/** - * - * @param {{ - * 'corner-top-left-shape': number, - * 'corner-top-right-shape': number, - * 'corner-bottom-right-shape': number, - * 'corner-bottom-left-shape': number, - * 'border-top-left-radius': [number, number], - * 'border-top-right-radius': [number, number], - * 'border-bottom-left-radius': [number, number], - * 'border-bottom-right-radius': [number, number], - * 'border-top-width': number, - * 'border-right-width': number, - * 'border-bottom-width': number, - * 'border-left-width': number, - * 'shadow': { blur: number, offset: [number, number], spread: number, color: string } - * }} style - * @param {CanvasRenderingContext2D} ctx - * @param {number} width - * @param {number} height - */ -function render_rect_with_corner_shapes(style, ctx, width, height) { - const corner_params = resolve_corner_params(style, width, height); - - function draw_outer_corner(corner, spread) { - const params = (spread ? resolve_corner_params(style, width, height, spread) : corner_params)[corner]; - add_corner(ctx, ...params.outer_rect, params.shape); - } - - function draw_inner_corner(corner) { - add_corner(ctx, ...corner_params[corner].inner_rect, corner_params[corner].shape); - } - - function draw_outer_path(spread) { - ctx.beginPath(); - draw_outer_corner("top-right", spread); - draw_outer_corner("bottom-right", spread); - draw_outer_corner("bottom-left", spread); - draw_outer_corner("top-left", spread); - ctx.closePath(); - ctx.fill("nonzero"); - } - - for (const {spread, offset, color} of (style.shadow || [])) { - ctx.save(); - ctx.translate(offset[0] - spread, offset[1] - spread); - ctx.fillStyle = color; - draw_outer_path(spread); - ctx.restore(); - } - - const inner_rect = [ - style["border-left-width"], - style["border-top-width"], - width - style["border-right-width"], - height - style["border-bottom-width"], - ]; - - ctx.fillStyle = "black"; - draw_outer_path(0); - - ctx.save(); - ctx.beginPath(); - draw_inner_corner("top-right"); - ctx.lineTo(inner_rect[2], inner_rect[3]); - ctx.lineTo(inner_rect[0], inner_rect[3]); - ctx.lineTo(inner_rect[0], inner_rect[1]); - ctx.closePath(); - ctx.clip(); - ctx.beginPath(); - draw_inner_corner("bottom-right"); - ctx.lineTo(inner_rect[0], inner_rect[3]); - ctx.lineTo(inner_rect[0], inner_rect[1]); - ctx.lineTo(inner_rect[2], inner_rect[1]); - ctx.closePath(); - ctx.clip(); - ctx.beginPath(); - draw_inner_corner("bottom-left"); - ctx.lineTo(inner_rect[0], inner_rect[1]); - ctx.lineTo(inner_rect[2], inner_rect[1]); - ctx.lineTo(inner_rect[2], inner_rect[3]); - ctx.closePath(); - ctx.clip(); - ctx.beginPath(); - draw_inner_corner("top-left"); - ctx.lineTo(inner_rect[2], inner_rect[1]); - ctx.lineTo(inner_rect[2], inner_rect[3]); - ctx.lineTo(inner_rect[0], inner_rect[3]); - ctx.closePath(); - ctx.clip(); - ctx.fillStyle = style["background-color"]; - ctx.fill(); - ctx.restore(); -} diff --git a/testing/web-platform/tests/css/css-borders/corner-shape/resources/render-corner-shape.js b/testing/web-platform/tests/css/css-borders/corner-shape/resources/render-corner-shape.js @@ -0,0 +1,505 @@ +class Vector2D { + /** @type {number} */ + x; + /** @type {number} */ + y; + constructor(x, y) { + this.x = x; + this.y = y; + } + + /** + * + * @param {number} s + * @returns {Vector2D} + */ + scale(s) { + return new Vector2D(this.x * s, this.y * s); + } + + length() { + return Math.hypot(this.x, this.y); + } + + normalized() { + const length = this.length(); + return length ? this.scale(1 / length) : this; + } + + perpendicular() { + return new Vector2D(-this.y, this.x); + } + + /** + * + * @param {Vector2D} v1 + * @param {Vector2D} v2 + * + * @returns {number} + */ + static cross(v1, v2) { + return v1.x * v2.y - v1.y * v2.x; + } + + /** + * + * @param {DOMPointReadOnly} p1 + * @param {DOMPointReadOnly} p2 + * @returns {Vector2D} + */ + static fromPoints(p1, p2) { + return new Vector2D(p2.x - p1.x, p2.y - p1.y); + } + + /** + * + * @param {...Vector2D} v + * @returns {Vector2D} + */ + static concat(...v) { + return new Vector2D( + v.reduce((acc, v) => acc + v.x, 0), v.reduce((acc, v) => acc + v.y, 0)); + } +} + +/** + * + * @param {DOMPointReadOnly} point + * @param {...Vector2D} vectors + * + * @return {DOMPointReadOnly} + */ +function extend_point(point, ...vectors) { + const vector = Vector2D.concat(...vectors); + return new DOMPointReadOnly(point.x + vector.x, point.y + vector.y); +} + +/** + * Calculates the intersection point of two line segments. + * + * @param {[DOMPointReadOnly, DOMPointReadOnly]} line0 - The first line segment + * [A0, A1]. + * @param {[DOMPointReadOnly, DOMPointReadOnly]} line1 - The second line segment + * [B0, B1]. + * @returns {DOMPointReadOnly} The intersection point, or the starting point if + * parallel + */ +function intersection_of([a0, a1], [b0, b1]) { + const a_length = Vector2D.fromPoints(a0, a1); + const b_length = Vector2D.fromPoints(b0, b1); + const denom = Vector2D.cross(a_length, b_length); + if (Math.abs(denom) < 1e-6) { + return null; + } + + const a_scale = Vector2D.cross(Vector2D.fromPoints(a0, b0), b_length) / denom; + return extend_point(a0, a_length.scale(a_scale)); +} + +/** + * @param {number} x + * @param {number} y + * @param {Vector2D} vectorTowardsStart + * @param {Vector2D} vectorTowardsEnd + * @param {number} curvature + * @param {number} startInset + * @param {number} endInset + * @param {"fill" | "stroke"} mode + */ +function get_path_for_corner( + x, y, vectorTowardsStart, vectorTowardsEnd, superellipse_param, startInset, + endInset, mode = 'fill') { + if (superellipse_param === Infinity || vectorTowardsStart.length() < 0.1 || + vectorTowardsEnd.length() < 0.1) { + const path = new Path2D(); + path.moveTo(x, y); + return path; + } + + let curvature = + superellipse_param < -10 ? 0.01 : Math.pow(2, superellipse_param); + const outer = new DOMPoint(x, y); + const inverse = curvature < 1; + const near_notch = superellipse_param < -8; + if (inverse) + curvature = 1 / curvature; + + const corner = { + start: extend_point(outer, vectorTowardsStart), + end: extend_point(outer, vectorTowardsEnd), + outer, + center: extend_point(outer, vectorTowardsStart, vectorTowardsEnd) + } + + const extendStart = Vector2D.fromPoints(corner.start, corner.center) + .normalized() + .scale(startInset); + const extendEnd = Vector2D.fromPoints(corner.end, corner.center) + .normalized() + .scale(endInset); + const clipStart = extend_point(corner.start, extendStart); + const clipOuter = extend_point(outer, extendStart, extendEnd); + const clipEnd = extend_point(corner.end, extendEnd); + const convexClampedHalfCorner = + near_notch ? 0.75 : Math.pow(0.5, 1 / Math.min(2, curvature)); + const clampedHalfCorner = + inverse ? 1 - convexClampedHalfCorner : convexClampedHalfCorner; + const unitVectorFromStartToControlPoint = + new Vector2D(2 * clampedHalfCorner - 0.5, 1.5 - 2 * clampedHalfCorner); + const singlePixelStrokeVector = + unitVectorFromStartToControlPoint.normalized().perpendicular(); + + const offsets = [ + Vector2D.fromPoints(corner.start, outer) + .normalized() + .scale(startInset * singlePixelStrokeVector.x), + Vector2D.fromPoints(outer, corner.end) + .normalized() + .scale(startInset * singlePixelStrokeVector.y), + Vector2D.fromPoints(corner.end, corner.center) + .normalized() + .scale(endInset * singlePixelStrokeVector.y), + Vector2D.fromPoints(corner.center, corner.start) + .normalized() + .scale(endInset * singlePixelStrokeVector.x) + ]; + + const adjusted_start = extend_point(corner.start, offsets[0], offsets[1]); + const adjusted_outer = extend_point(corner.outer, offsets[1], offsets[2]); + const adjusted_end = extend_point(corner.end, offsets[2], offsets[3]); + const adjusted_center = extend_point(corner.center, offsets[3], offsets[0]); + const curve_center = inverse ? adjusted_outer : adjusted_center; + + const map_point_to_corner = p => extend_point( + curve_center, Vector2D.fromPoints(curve_center, adjusted_end).scale(p.x), + Vector2D.fromPoints(curve_center, adjusted_start).scale(p.y)); + + const controlPoint = map_point_to_corner(new DOMPointReadOnly( + unitVectorFromStartToControlPoint.y, + 1 - unitVectorFromStartToControlPoint.x)); + const axisAlignedCornerStart = + intersection_of([adjusted_start, controlPoint], [clipStart, clipOuter]) || + adjusted_start; + const axisAlignedCornerEnd = + intersection_of([adjusted_end, controlPoint], [clipOuter, clipEnd]) || + adjusted_end; + + const path = new Path2D(); + const lineTo = ({x, y}) => path.lineTo(x, y); + + path.moveTo(axisAlignedCornerStart.x, axisAlignedCornerStart.y); + if (near_notch) { + lineTo(adjusted_center); + } else { + const t_set = new Set([0, 1]); + const denom = Math.log(1 / curvature); + for (let x = Math.min(adjusted_start.x, adjusted_end.x); + x < Math.max(adjusted_start.x, adjusted_end.x); x++) { + const t = + Math.log( + (x - adjusted_start.x) / (adjusted_end.x - adjusted_start.x)) / + denom; + if (t > 0 && t < 1) + t_set.add(t); + } + for (let y = Math.min(adjusted_start.y, adjusted_end.y); + y < Math.max(adjusted_start.y, adjusted_end.y); y++) { + const t = + Math.log( + 1 - + (y - adjusted_start.y) / (adjusted_end.y - adjusted_start.y)) / + denom; + if (t > 0 && t < 1) + t_set.add(t); + } + + for (const t of [...t_set].toSorted((a, b) => a - b)) { + const a = Math.pow(t, 1 / curvature); + const b = Math.pow(1 - t, 1 / curvature); + const point = map_point_to_corner(new DOMPointReadOnly(a, b)); + lineTo(point); + } + } + lineTo(axisAlignedCornerEnd); + + if (mode === 'fill') + lineTo(clipOuter); + return path; +} + +/** + * + * @param {CanvasRenderingContext2D} ctx + * @param {object} style + * @param {DOMRectReadOnly} borderEdge + * @param {{left: number, top: number, right: number, bottom: number}} inset + * @param {"fill" | "stroke"} mode + */ +function draw_contoured_path( + ctx, style, borderEdge, inset = { + left: 0, + top: 0, + right: 0, + bottom: 0 + }, + mode = 'fill') { + const targetEdge = new DOMRectReadOnly( + borderEdge.left + inset.left, borderEdge.top + inset.top, + borderEdge.width - inset.left - inset.right, + borderEdge.height - inset.top - inset.bottom); + + const add_corner = + (path) => { + if (!path) + return; + const clip_out_path = new Path2D(); + clip_out_path.rect( + targetEdge.x, targetEdge.y, targetEdge.width, targetEdge.height); + + if (mode === 'fill') { + clip_out_path.addPath(path); + ctx.clip(clip_out_path, 'evenodd'); + } else { + ctx.clip(clip_out_path, 'evenodd'); + ctx.strokeStyle = 'blue'; + ctx.lineWidth = 3; + ctx.stroke(path); + } + } + + ctx.save(); + add_corner(get_path_for_corner( + borderEdge.right, borderEdge.top, + new Vector2D(-style['border-top-right-radius'][0], 0), + new Vector2D(0, style['border-top-right-radius'][1]), + style['corner-top-right-shape'], inset.top, inset.right, mode)); + add_corner(get_path_for_corner( + borderEdge.right, borderEdge.bottom, + new Vector2D(0, -style['border-bottom-right-radius'][1]), + new Vector2D(-style['border-bottom-right-radius'][0], 0), + style['corner-bottom-right-shape'], inset.right, inset.bottom, mode)); + add_corner(get_path_for_corner( + borderEdge.left, borderEdge.bottom, + new Vector2D(style['border-bottom-left-radius'][0], 0), + new Vector2D(0, -style['border-bottom-left-radius'][1]), + style['corner-bottom-left-shape'], inset.bottom, inset.left, mode)); + add_corner(get_path_for_corner( + borderEdge.left, borderEdge.top, + new Vector2D(0, style['border-top-left-radius'][1]), + new Vector2D(style['border-top-left-radius'][0], 0), + style['corner-top-left-shape'], inset.left, inset.top, mode)); + if (mode === 'fill') { + ctx.fillRect( + targetEdge.x, targetEdge.y, targetEdge.width, targetEdge.height); + } + ctx.restore(); +} + + +function adjust_radii(s, spread, width, height) { + const style = {...s}; + style['border-top-left-radius'] = adjusted_radius( + width, height, ...style['border-top-left-radius'], spread); + style['border-top-right-radius'] = adjusted_radius( + width, height, ...style['border-top-right-radius'], spread); + style['border-bottom-right-radius'] = adjusted_radius( + width, height, ...style['border-bottom-right-radius'], spread); + style['border-bottom-left-radius'] = adjusted_radius( + width, height, ...style['border-bottom-left-radius'], spread); + return style; +} + +function adjusted_radius(width, height, h_radius, v_radius, outset) { + const coverage = 2 * Math.min(h_radius / width, v_radius / height); + return [ + adjusted_radius_dimension(coverage, h_radius, outset) - outset, + adjusted_radius_dimension(coverage, v_radius, outset) - outset + ]; +} + +function adjusted_radius_dimension(coverage, radius, outset) { + radius = Math.max(radius, 0.01); + if (radius > outset || coverage > 1 || radius) { + return radius + outset; + } + const ratio = radius / outset; + return radius + outset * (1 - (1 - ratio) ** 3 * (1 - coverage ** 3)); +} + +/** + * + * @param {object} style + * @param {CanvasRenderingContext2D} ctx + * @param {number} width + * @param {number} height + */ +function render(style, ctx, width, height, mode = 'fill') { + const border_rect = new DOMRect(0, 0, width, height); + const shadow_spread = style['shadow-spread'] || 0; + const shadow_offset = + [style['shadow-offset-x'] || 0, style['shadow-offset-y'] || 0]; + if (shadow_offset[0] || shadow_offset[1] || shadow_spread) { + ctx.save(); + ctx.translate(...shadow_offset); + ctx.fillStyle = 'black'; + draw_contoured_path( + ctx, adjust_radii(style, shadow_spread, width, height), border_rect, { + left: -shadow_spread, + top: -shadow_spread, + right: -shadow_spread, + bottom: -shadow_spread + }, + mode); + ctx.restore(); + } + ctx.fillStyle = 'purple'; + draw_contoured_path( + ctx, style, border_rect, {left: 0, top: 0, right: 0, bottom: 0}, mode); + ctx.fillStyle = 'yellow'; + draw_contoured_path( + ctx, style, border_rect, { + left: style['border-left-width'], + top: style['border-top-width'], + right: style['border-right-width'], + bottom: style['border-bottom-width'] + }, + mode); +} + +const padding = 100; +function create_ref_canvas(style, width, height, mode = 'fill') { + const canvas = document.createElement('canvas'); + canvas.width = width + padding * 2; + canvas.height = height + padding * 2; + const ctx = canvas.getContext('2d'); + ctx.translate(padding, padding); + canvas.style.position = 'absolute'; + canvas.style.top = '0'; + canvas.style.left = '0'; + render(style, ctx, width, height, mode); + return canvas; +} + +function create_ref(style, width, height) { + const div = document.createElement('div'); + div.style.width = width + 'px'; + div.style.height = height + 'px'; + div.style.position = 'relative'; + const fill_canvas = create_ref_canvas(style, width, height, 'fill'); + const stroke_canvas = create_ref_canvas(style, width, height, 'stroke'); + div.appendChild(fill_canvas); + div.appendChild(stroke_canvas); + return div; +} + +function create_actual(style, width, height) { + const div = document.createElement('div'); + div.style.width = width + 'px'; + div.style.height = height + 'px'; + div.style.position = 'relative'; + div.style.left = `${padding}px`; + div.style.top = `${padding}px`; + for (const prop + of ['border-left-width', 'border-top-width', 'border-bottom-width', + 'border-right-width']) { + div.style[prop] = style[prop] + 'px'; + } + + let border_radius = ''; + for (const prop + of ['border-top-left-radius', 'border-top-right-radius', + 'border-bottom-right-radius', 'border-bottom-left-radius']) { + border_radius += style[prop][0] + 'px '; + } + border_radius += ' / '; + for (const prop + of ['border-top-left-radius', 'border-top-right-radius', + 'border-bottom-right-radius', 'border-bottom-left-radius']) { + border_radius += style[prop][1] + 'px '; + } + + for (const prop + of ['corner-top-left-shape', 'corner-top-right-shape', + 'corner-bottom-right-shape', 'corner-bottom-left-shape']) { + div.style[prop] = `superellipse(${style[prop]})`; + } + + div.style.boxShadow = `${style['shadow-offset-x'] || 0}px ${ + style['shadow-offset-y'] || + 0}px 0px ${style['shadow-spread'] || 0}px black`; + + div.style.borderRadius = border_radius; + + div.style.borderColor = 'purple'; + div.style.borderStyle = 'solid'; + div.style.backgroundColor = 'yellow'; + div.style.boxSizing = 'border-box'; + div.id = 'ref'; + const canvas = create_ref_canvas(style, width, height, 'stroke'); + canvas.style.position = 'absolute'; + canvas.style.left = `${- padding - style['border-left-width']}px`; + canvas.style.top = `${- padding - style['border-top-width']}px`; + div.appendChild(canvas); + return div; +} + +const corner_shape_keywords = new Map([ + ['infinity', Infinity], + ['-infinity', -Infinity], + ['square', Infinity], + ['notch', -Infinity], + ['scoop', -1], + ['round', 1], + ['bevel', 0], + ['squircle', 2], +]); + +/** + * + * @param {URLSearchParams} params + * @param {"ref" | "actual"} mode + * @returns + */ +function create_element_with_corner_shape(params, mode) { + const style = Object.fromEntries(params.entries()); + const width = +(params.get('width') || 200); + const height = +(params.get('height') || 100); + for (const prop + of ['border-left-width', 'border-top-width', 'border-bottom-width', + 'border-right-width', 'shadow-spread', 'shadow-offset-x', + 'shadow-offset-y']) { + style[prop] = params.has(prop) ? parseFloat(params.get(prop)) : 0; + } + for (const prop + of ['corner-top-left-shape', 'corner-top-right-shape', + 'corner-bottom-right-shape', 'corner-bottom-left-shape']) { + const value = params.has(prop) ? params.get(prop) : + params.has('corner-shape') ? params.get('corner-shape') : + 1; + style[prop] = corner_shape_keywords.has(value) ? + corner_shape_keywords.get(value) : + parseFloat(value); + } + + for (const prop + of ['border-top-left-radius', 'border-top-right-radius', + 'border-bottom-right-radius', 'border-bottom-left-radius']) { + style[prop] = params.has(prop) ? + params.get(prop) : + (params.has('border-radius') ? params.get('border-radius') : '0'); + style[prop] = style[prop].split(','); + if (style[prop].length === 1) { + style[prop] = [style[prop][0], style[prop][0]]; + } + style[prop] = style[prop].map((v, i) => { + const n = parseFloat(v); + if (v.endsWith('%')) { + return (n / 100) * (i ? height : width); + } + return n; + }); + } + + return mode === 'ref' ? create_ref(style, width, height) : + create_actual(style, width, height); +}