Source code
Revision control
Copy as Markdown
Other Tools
Test Info: Warnings
- This test has a WPT meta file that expects 13 subtest issues.
- This WPT test may be referenced by the following Test IDs:
- /css/css-color/canvas-srgb-gamut-mapping.html - WPT Dashboard Interop Dashboard
<!DOCTYPE html>
<meta charset="utf-8">
<title>CSS Color 4: colors outside sRGB are gamut mapped, not clipped, on an sRGB unorm8 canvas</title>
<meta name="assert" content="Painting a color outside the sRGB gamut into a 2d canvas whose colorSpace is srgb and colorType is unorm8 applies one of the three CSS gamut mapping algorithms, which reduce chroma at constant Oklch lightness and hue, rather than clipping each channel.">
<script src="/resources/testharness.js"></script>
<script src="/resources/testharnessreport.js"></script>
<body>
<script>
// mapped: the mean of the three CSS gamut mapping algorithms, in 8-bit sRGB
// tolerance: per channel; all three algorithms are within it of mapped, and the clipped color is not
// clipped: the color clipped per channel, in 8-bit sRGB, for reporting what happened
// The comment above each case gives the three algorithms' own results.
const cases = [
// MINDE [255.00, 200.23, 219.68], EdgeSeeker [254.96, 207.22, 220.33], Ray Trace [255.00, 207.20, 220.32]
{ color: "oklch(90% 0.35 0)", mapped: [255, 205, 220], tolerance: 6, clipped: [255, 50, 213], clipTolerance: 1 },
// MINDE [255.00, 153.55, 135.55], EdgeSeeker [255.00, 161.03, 145.46], Ray Trace [255.00, 161.04, 145.47]
{ color: "oklch(80% 0.35 30)", mapped: [255, 159, 142], tolerance: 8, clipped: [255, 0, 0], clipTolerance: 1 },
// MINDE [255.00, 145.71, 185.67], EdgeSeeker [255.00, 154.72, 186.34], Ray Trace [255.00, 154.75, 186.34]
{ color: "oklch(80% 0.35 0)", mapped: [255, 152, 186], tolerance: 8, clipped: [255, 0, 181], clipTolerance: 1 },
// MINDE [204.30, 167.06, 255.00], EdgeSeeker [201.44, 171.94, 255.00], Ray Trace [201.40, 172.01, 255.00]
{ color: "oklch(80% 0.35 300)", mapped: [202, 170, 255], tolerance: 4, clipped: [230, 100, 255], clipTolerance: 1 },
// MINDE [175.12, 229.75, 255.00], EdgeSeeker [190.33, 227.67, 255.00], Ray Trace [190.37, 227.67, 255.00]
{ color: "oklch(90% 0.3 240)", mapped: [185, 228, 255], tolerance: 11, clipped: [0, 236, 255], clipTolerance: 1 },
// MINDE [255.00, 200.22, 219.68], EdgeSeeker [254.96, 207.22, 220.33], Ray Trace [255.00, 207.20, 220.32]
{ color: "oklch(90% 0.3 0)", mapped: [255, 205, 220], tolerance: 6, clipped: [255, 104, 214], clipTolerance: 1 },
// MINDE [0.00, 83.62, 97.90], EdgeSeeker [0.00, 81.45, 92.18], Ray Trace [0.00, 81.45, 92.18]
{ color: "oklch(40% 0.35 210)", mapped: [0, 82, 94], tolerance: 5, clipped: [0, 95, 172], clipTolerance: 1 },
// MINDE [195.29, 98.41, 0.00], EdgeSeeker [187.35, 104.01, 0.00], Ray Trace [187.70, 103.73, 0.00]
{ color: "oklch(60% 0.35 60)", mapped: [190, 102, 0], tolerance: 7, clipped: [255, 0, 0], clipTolerance: 1 },
// MINDE [92.60, 67.69, 0.00], EdgeSeeker [88.77, 68.97, 0.00], Ray Trace [88.77, 68.97, 0.00]
{ color: "oklch(40% 0.35 90)", mapped: [90, 69, 0], tolerance: 4, clipped: [139, 14, 0], clipTolerance: 1 },
// MINDE [0.00, 114.49, 132.39], EdgeSeeker [0.00, 112.16, 126.35], Ray Trace [0.00, 112.16, 126.35]
{ color: "oklch(50% 0.35 210)", mapped: [0, 113, 128], tolerance: 6, clipped: [0, 130, 206], clipTolerance: 1 },
// MINDE [153.88, 74.54, 0.00], EdgeSeeker [146.31, 79.95, 0.00], Ray Trace [146.62, 79.71, 0.00]
{ color: "oklch(50% 0.3 60)", mapped: [149, 78, 0], tolerance: 6, clipped: [205, 0, 0], clipTolerance: 1 },
// MINDE [229.95, 210.38, 255.00], EdgeSeeker [227.41, 214.11, 255.00], Ray Trace [227.36, 214.18, 255.00]
{ color: "oklch(90% 0.3 300)", mapped: [228, 213, 255], tolerance: 4, clipped: [255, 160, 255], clipTolerance: 1 },
// MINDE [175.12, 229.75, 255.00], EdgeSeeker [190.33, 227.67, 255.00], Ray Trace [190.37, 227.67, 255.00]
{ color: "oklch(90% 0.2 240)", mapped: [185, 228, 255], tolerance: 11, clipped: [37, 236, 255], clipTolerance: 1 },
];
// A color inside the gamut, in the canvas's own color space, which neither conversion
// nor gamut mapping should touch. If it changes, the canvas alters every color (for
// example by converting to the display's color profile) and the cases above cannot be judged.
const control = { color: "rgb(200 100 50)", expected: [200, 100, 50], tolerance: 1 };
// Paint a 5×5 patch and sample its center pixel, well clear of any antialiased edge
function paint(color) {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 5;
const ctx = canvas.getContext("2d", { colorSpace: "srgb", colorType: "unorm8" });
ctx.fillStyle = color;
ctx.fillRect(0, 0, 5, 5);
return Array.from(ctx.getImageData(2, 2, 1, 1, { colorSpace: "srgb" }).data.slice(0, 3));
}
function within(actual, expected, tolerance) {
return actual.every((value, i) => Math.abs(value - expected[i]) <= tolerance);
}
test(() => {
const actual = paint(control.color);
assert_true(within(actual, control.expected, control.tolerance),
`read back [${actual}], expected [${control.expected}] ± ${control.tolerance}: this canvas changes in-gamut colors, so gamut mapping cannot be judged`);
}, `control: in-gamut ${control.color} is unchanged on an sRGB canvas`);
for (const { color, mapped, tolerance, clipped, clipTolerance } of cases) {
test(() => {
const actual = paint(color);
const result = within(actual, mapped, tolerance) ? "gamut mapped"
: within(actual, clipped, clipTolerance) ? "clipped"
: "neither gamut mapped nor clipped";
assert_equals(result, "gamut mapped",
`read back [${actual}]; gamut mapped is [${mapped}] ± ${tolerance}, clipped is [${clipped}]`);
}, `${color} painted on an sRGB canvas`);
}
</script>