fix: show generated HTML charts on Win7 Chrome
Rewrite oklch/color-mix and space-separated rgb before iframe preview so pie and bar fills are not dropped on Chrome 109. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
7c15ca6710
commit
f18994fd38
5 files changed
+566
-20
No files matched your search
@@ -36,6 +36,11 @@ const requiredLegacyFallbacks = [
|
|||||||
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/,
|
||||||
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
description: "Sandbox flowcharts must use native SVG labels in legacy browsers",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
file: "src/core/utils/legacy-html-preview.ts",
|
||||||
|
pattern: /export function downlevelModernColors[\s\S]*function injectLegacyChartShim/,
|
||||||
|
description: "Generated HTML charts must downlevel modern CSS colors for Chrome 109",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const file of cssFiles) {
|
for (const file of cssFiles) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { injectInPageAnchorHandler } from "@/core/utils/markdown";
|
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sandboxed preview of a generated HTML document.
|
* Sandboxed preview of a generated HTML document.
|
||||||
@@ -26,7 +26,7 @@ export function HtmlPreviewFrame({
|
|||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const blob = new Blob([injectInPageAnchorHandler(html ?? "")], {
|
const blob = new Blob([prepareGeneratedHtml(html ?? "")], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ import {
|
|||||||
openMermaidSvgInNewTab,
|
openMermaidSvgInNewTab,
|
||||||
renderMermaidToSvg,
|
renderMermaidToSvg,
|
||||||
} from "@/core/utils/mermaid";
|
} from "@/core/utils/mermaid";
|
||||||
|
import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview";
|
||||||
import {
|
import {
|
||||||
createMarkdownAnchorResolver,
|
createMarkdownAnchorResolver,
|
||||||
injectInPageAnchorHandler,
|
|
||||||
isInPageAnchor,
|
isInPageAnchor,
|
||||||
scrollToInPageAnchor,
|
scrollToInPageAnchor,
|
||||||
} from "@/core/utils/markdown";
|
} from "@/core/utils/markdown";
|
||||||
@@ -1422,7 +1422,9 @@ export function ArtifactFileDetail({
|
|||||||
toast.error("文件内容为空,暂时无法在新窗口打开");
|
toast.error("文件内容为空,暂时无法在新窗口打开");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const blob = new Blob([source], { type: "text/html;charset=utf-8" });
|
const blob = new Blob([prepareGeneratedHtml(source)], {
|
||||||
|
type: "text/html;charset=utf-8",
|
||||||
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
window.open(url, "_blank", "noopener,noreferrer");
|
window.open(url, "_blank", "noopener,noreferrer");
|
||||||
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||||
@@ -1460,7 +1462,7 @@ export function ArtifactFileDetail({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Artifact className={cn(className)}>
|
<Artifact className={cn(className)}>
|
||||||
<ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14">
|
<ArtifactHeader className="relative flex flex-wrap items-center gap-3 px-4 py-3 pr-14">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -1554,8 +1556,8 @@ export function ArtifactFileDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
<div className="flex min-w-0 w-full items-center justify-end gap-2 md:ml-auto md:w-auto">
|
||||||
<ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
<ArtifactActions className="bg-muted/45 min-w-0 max-w-full flex-wrap justify-end rounded-3xl border border-black/6 p-1 dark:border-white/8">
|
||||||
{canPublishToKnowledge && (
|
{canPublishToKnowledge && (
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
|
icon={isPublishingToKnowledge ? LoaderIcon : UploadCloudIcon}
|
||||||
@@ -1683,18 +1685,18 @@ export function ArtifactFileDetail({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</ArtifactActions>
|
</ArtifactActions>
|
||||||
</div>
|
</div>
|
||||||
{/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的
|
{/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的
|
||||||
动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */}
|
动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */}
|
||||||
<ArtifactAction
|
<ArtifactAction
|
||||||
className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm"
|
className="bg-background/90 hover:bg-accent absolute top-3 right-3 z-10 border shadow-sm"
|
||||||
icon={XIcon}
|
icon={XIcon}
|
||||||
label="收起文件面板"
|
label="收起文件面板"
|
||||||
onClick={() => (onClose ? onClose() : setOpen(false))}
|
onClick={() => (onClose ? onClose() : setOpen(false))}
|
||||||
tooltip="收起文件面板"
|
tooltip="收起文件面板"
|
||||||
/>
|
/>
|
||||||
</ArtifactHeader>
|
</ArtifactHeader>
|
||||||
<ArtifactContent
|
<ArtifactContent
|
||||||
ref={artifactContentRef}
|
ref={artifactContentRef}
|
||||||
className="relative p-0"
|
className="relative p-0"
|
||||||
@@ -2084,7 +2086,7 @@ export function ArtifactFilePreview({
|
|||||||
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
// <meta charset> 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。
|
||||||
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
// Intercept in-page anchor clicks so they scroll within the iframe instead
|
||||||
// of navigating the sandboxed blob: document (which blanks the frame).
|
// of navigating the sandboxed blob: document (which blanks the frame).
|
||||||
const blob = new Blob([injectInPageAnchorHandler(content ?? "")], {
|
const blob = new Blob([prepareGeneratedHtml(content ?? "")], {
|
||||||
type: "text/html;charset=utf-8",
|
type: "text/html;charset=utf-8",
|
||||||
});
|
});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { downlevelModernColors, prepareGeneratedHtml } from "./legacy-html-preview";
|
||||||
|
|
||||||
|
test("rewrites oklch chart fills to comma rgb", () => {
|
||||||
|
const html = `<svg><path fill="oklch(0.7 0.15 40)"/></svg>`;
|
||||||
|
const out = downlevelModernColors(html);
|
||||||
|
assert.match(out, /fill="rgb\(\d+, \d+, \d+\)"/);
|
||||||
|
assert.doesNotMatch(out, /oklch\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rewrites space-separated rgb and hsl used by pie slices", () => {
|
||||||
|
const html = `.slice{fill:rgb(255 99 71 / 80%);stroke:hsl(220 90% 56%)}`;
|
||||||
|
const out = downlevelModernColors(html);
|
||||||
|
assert.match(out, /rgba\(255, 99, 71, 0\.8\)/);
|
||||||
|
assert.match(out, /rgb\(\d+, \d+, \d+\)/);
|
||||||
|
assert.doesNotMatch(out, /rgb\(255 99 71/);
|
||||||
|
assert.doesNotMatch(out, /hsl\(220 90%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rewrites color-mix after nested oklch is converted", () => {
|
||||||
|
const html = `background:color-mix(in srgb, oklch(0.63 0.2 27) 40%, #ffffff);`;
|
||||||
|
const out = downlevelModernColors(html);
|
||||||
|
assert.match(out, /rgb\(\d+, \d+, \d+\)/);
|
||||||
|
assert.doesNotMatch(out, /color-mix\(|oklch\(/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("turns Tailwind-style rgb channels on custom props into comma form", () => {
|
||||||
|
const html = `:root{--c: 37 99 235;} .bar{background:rgb(var(--c) / 50%)}`;
|
||||||
|
const out = downlevelModernColors(html);
|
||||||
|
assert.match(out, /--c: 37, 99, 235;/);
|
||||||
|
assert.match(out, /rgba\(var\(--c\), 0\.5\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leaves already-legacy rgb() alone", () => {
|
||||||
|
const html = `<rect fill="rgb(16, 185, 129)"/>`;
|
||||||
|
assert.equal(downlevelModernColors(html), html);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler", () => {
|
||||||
|
const html = `<!doctype html><html><body><svg viewBox="0 0 100 80"></svg></body></html>`;
|
||||||
|
const out = prepareGeneratedHtml(html);
|
||||||
|
assert.match(out, /data-df-legacy-charts="1"/);
|
||||||
|
assert.match(out, /getElementsByTagName\("svg"\)/);
|
||||||
|
assert.match(out, /getElementsByTagName\("canvas"\)/);
|
||||||
|
assert.doesNotMatch(out, /\?\.|=>|`/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/**
|
||||||
|
* Prepare generated HTML so pie/bar/line charts still paint on Chrome 100–109
|
||||||
|
* (the Win7-era baseline this app targets).
|
||||||
|
*
|
||||||
|
* Model-written pages often copy modern design tokens: `oklch()`, `color-mix()`,
|
||||||
|
* and space-separated `rgb(r g b / a)`. Those declarations are ignored on
|
||||||
|
* Chrome 109, so SVG/CSS/canvas fills become transparent and the chart looks
|
||||||
|
* blank even though the markup is there. This rewrite happens in the parent
|
||||||
|
* before the blob iframe loads; the injected shim is ES5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { injectInPageAnchorHandler } from "./markdown";
|
||||||
|
|
||||||
|
const MAX_PASSES = 12;
|
||||||
|
|
||||||
|
export function prepareGeneratedHtml(html: string): string {
|
||||||
|
return injectInPageAnchorHandler(injectLegacyChartShim(downlevelModernColors(html ?? "")));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function downlevelModernColors(html: string): string {
|
||||||
|
let current = rewriteSpaceSeparatedCustomProps(html);
|
||||||
|
current = rewriteVarAlphaRgb(current);
|
||||||
|
let previous = "";
|
||||||
|
for (let pass = 0; pass < MAX_PASSES && current !== previous; pass += 1) {
|
||||||
|
previous = current;
|
||||||
|
current = replaceFunctionCalls(current, "oklch", convertOklch);
|
||||||
|
current = replaceFunctionCalls(current, "oklab", convertOklab);
|
||||||
|
current = replaceFunctionCalls(current, "lch", convertLch);
|
||||||
|
current = replaceFunctionCalls(current, "lab", convertLab);
|
||||||
|
current = replaceFunctionCalls(current, "rgba", convertModernRgb);
|
||||||
|
current = replaceFunctionCalls(current, "rgb", convertModernRgb);
|
||||||
|
current = replaceFunctionCalls(current, "hsla", convertModernHsl);
|
||||||
|
current = replaceFunctionCalls(current, "hsl", convertModernHsl);
|
||||||
|
current = replaceFunctionCalls(current, "color-mix", convertColorMix);
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function injectLegacyChartShim(html: string): string {
|
||||||
|
if (html.includes("data-df-legacy-charts")) return html;
|
||||||
|
const script = `<script data-df-legacy-charts="1">(function(){
|
||||||
|
function viewBox(svg){
|
||||||
|
var vb=svg.getAttribute("viewBox")||svg.getAttribute("viewbox");
|
||||||
|
if(!vb)return null;
|
||||||
|
var p=vb.replace(/,/g," ").replace(/^\\s+|\\s+$/g,"").split(/\\s+/);
|
||||||
|
if(p.length<4)return null;
|
||||||
|
var w=parseFloat(p[2]), h=parseFloat(p[3]);
|
||||||
|
if(!(w>0&&h>0))return null;
|
||||||
|
return {w:w,h:h};
|
||||||
|
}
|
||||||
|
function fixSvg(svg){
|
||||||
|
var box=svg.getBoundingClientRect();
|
||||||
|
if(box.height>=8&&box.width>=8)return;
|
||||||
|
var vb=viewBox(svg);
|
||||||
|
if(!vb)return;
|
||||||
|
if(!svg.getAttribute("width"))svg.setAttribute("width",String(vb.w));
|
||||||
|
if(!svg.getAttribute("height"))svg.setAttribute("height",String(vb.h));
|
||||||
|
svg.style.maxWidth="100%";
|
||||||
|
svg.style.height="auto";
|
||||||
|
svg.style.display="block";
|
||||||
|
}
|
||||||
|
function fixCanvas(c){
|
||||||
|
var box=c.getBoundingClientRect();
|
||||||
|
var parent=c.parentElement;
|
||||||
|
var pw=parent?parent.clientWidth:0;
|
||||||
|
var ph=parent?parent.clientHeight:0;
|
||||||
|
if(c.width<8||box.width<8){
|
||||||
|
c.width=Math.max(pw,320);
|
||||||
|
c.style.width="100%";
|
||||||
|
}
|
||||||
|
if(c.height<8||box.height<8){
|
||||||
|
c.height=Math.max(ph,220);
|
||||||
|
c.style.height=c.height+"px";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function run(){
|
||||||
|
var svgs=document.getElementsByTagName("svg");
|
||||||
|
for(var i=0;i<svgs.length;i++)fixSvg(svgs[i]);
|
||||||
|
var canvases=document.getElementsByTagName("canvas");
|
||||||
|
for(var j=0;j<canvases.length;j++)fixCanvas(canvases[j]);
|
||||||
|
}
|
||||||
|
if(document.readyState==="complete"||document.readyState==="interactive"){
|
||||||
|
setTimeout(run,0);
|
||||||
|
setTimeout(run,400);
|
||||||
|
}else if(document.addEventListener){
|
||||||
|
document.addEventListener("DOMContentLoaded",function(){
|
||||||
|
run();
|
||||||
|
setTimeout(run,400);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if(window.addEventListener)window.addEventListener("load",function(){setTimeout(run,50);});
|
||||||
|
})();</script>`;
|
||||||
|
if (/<\/body>/i.test(html)) {
|
||||||
|
return html.replace(/<\/body>/i, `${script}</body>`);
|
||||||
|
}
|
||||||
|
return html + script;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteSpaceSeparatedCustomProps(html: string): string {
|
||||||
|
return html.replace(
|
||||||
|
/(--[\w-]+)\s*:\s*(\d{1,3})\s+(\d{1,3})\s+(\d{1,3})\s*;/g,
|
||||||
|
"$1: $2, $3, $4;",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rewriteVarAlphaRgb(html: string): string {
|
||||||
|
return html.replace(
|
||||||
|
/\brgba?\(\s*var\((--[\w-]+)\)\s*\/\s*([^)]+?)\)/gi,
|
||||||
|
(_, name: string, alpha: string) => `rgba(var(${name}), ${normalizeAlpha(alpha.trim())})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFunctionCalls(
|
||||||
|
source: string,
|
||||||
|
name: string,
|
||||||
|
replace: (args: string) => string | null,
|
||||||
|
): string {
|
||||||
|
const lower = source.toLowerCase();
|
||||||
|
const token = `${name.toLowerCase()}(`;
|
||||||
|
let output = "";
|
||||||
|
let cursor = 0;
|
||||||
|
while (cursor < source.length) {
|
||||||
|
const index = lower.indexOf(token, cursor);
|
||||||
|
if (index < 0) {
|
||||||
|
output += source.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const before = index === 0 ? "" : source[index - 1]!;
|
||||||
|
if (index > 0 && /[A-Za-z0-9_-]/.test(before)) {
|
||||||
|
output += source.slice(cursor, index + 1);
|
||||||
|
cursor = index + 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const open = index + name.length;
|
||||||
|
const close = matchClosingParen(source, open);
|
||||||
|
if (close < 0) {
|
||||||
|
output += source.slice(cursor);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const args = source.slice(open + 1, close);
|
||||||
|
const next = replace(args);
|
||||||
|
output += source.slice(cursor, index) + (next ?? source.slice(index, close + 1));
|
||||||
|
cursor = close + 1;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchClosingParen(source: string, openIndex: number): number {
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = openIndex; i < source.length; i += 1) {
|
||||||
|
const char = source[i]!;
|
||||||
|
if (quote) {
|
||||||
|
if (char === "\\") {
|
||||||
|
i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === quote) quote = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "(") depth += 1;
|
||||||
|
else if (char === ")") {
|
||||||
|
depth -= 1;
|
||||||
|
if (depth === 0) return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertOklch(args: string): string | null {
|
||||||
|
if (/\bfrom\b/i.test(args)) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const L = parseLightness(values[0]!);
|
||||||
|
const C = parseNumber(values[1]!);
|
||||||
|
const H = parseHue(values[2]!);
|
||||||
|
if (L == null || C == null || H == null) return null;
|
||||||
|
const [a, b] = polarToLab(C, H);
|
||||||
|
return formatRgba(oklabToSrgb(L, a, b), alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertOklab(args: string): string | null {
|
||||||
|
if (/\bfrom\b/i.test(args)) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const L = parseLightness(values[0]!);
|
||||||
|
const a = parseNumber(values[1]!);
|
||||||
|
const b = parseNumber(values[2]!);
|
||||||
|
if (L == null || a == null || b == null) return null;
|
||||||
|
return formatRgba(oklabToSrgb(L, a, b), alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLch(args: string): string | null {
|
||||||
|
if (/\bfrom\b/i.test(args)) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const L = parsePercentOrNumber(values[0]!, 100);
|
||||||
|
const C = parseNumber(values[1]!);
|
||||||
|
const H = parseHue(values[2]!);
|
||||||
|
if (L == null || C == null || H == null) return null;
|
||||||
|
const [a, b] = polarToLab(C, H);
|
||||||
|
return formatRgba(labToSrgb(L, a, b), alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLab(args: string): string | null {
|
||||||
|
if (/\bfrom\b/i.test(args)) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const L = parsePercentOrNumber(values[0]!, 100);
|
||||||
|
const a = parseNumber(values[1]!);
|
||||||
|
const b = parseNumber(values[2]!);
|
||||||
|
if (L == null || a == null || b == null) return null;
|
||||||
|
return formatRgba(labToSrgb(L, a, b), alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertColorMix(args: string): string | null {
|
||||||
|
const parts = splitTopLevel(args, ",");
|
||||||
|
if (parts.length < 2) return null;
|
||||||
|
const colorParts = parts.slice(1);
|
||||||
|
const left = parseMixStop(colorParts[0] ?? "");
|
||||||
|
const right = parseMixStop(colorParts[1] ?? "transparent");
|
||||||
|
if (!left.color && !right.color) return null;
|
||||||
|
const leftPct = left.percent ?? (right.percent == null ? 50 : 100 - right.percent);
|
||||||
|
const rightPct = right.percent ?? 100 - leftPct;
|
||||||
|
const sum = leftPct + rightPct || 1;
|
||||||
|
const leftRgb = resolveMixColor(left.color) ?? [0, 0, 0, 0];
|
||||||
|
const rightRgb = resolveMixColor(right.color) ?? [0, 0, 0, 0];
|
||||||
|
const t = rightPct / sum;
|
||||||
|
return formatRgba(
|
||||||
|
[
|
||||||
|
leftRgb[0] + (rightRgb[0] - leftRgb[0]) * t,
|
||||||
|
leftRgb[1] + (rightRgb[1] - leftRgb[1]) * t,
|
||||||
|
leftRgb[2] + (rightRgb[2] - leftRgb[2]) * t,
|
||||||
|
],
|
||||||
|
leftRgb[3] + (rightRgb[3] - leftRgb[3]) * t,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertModernRgb(args: string): string | null {
|
||||||
|
if (args.includes("var(") || /\bfrom\b/i.test(args)) return null;
|
||||||
|
if (args.includes(",") && !args.includes("/")) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const r = parseRgbChannel(values[0]!);
|
||||||
|
const g = parseRgbChannel(values[1]!);
|
||||||
|
const b = parseRgbChannel(values[2]!);
|
||||||
|
if (r == null || g == null || b == null) return null;
|
||||||
|
return formatRgba([r, g, b], alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertModernHsl(args: string): string | null {
|
||||||
|
if (args.includes("var(") || /\bfrom\b/i.test(args)) return null;
|
||||||
|
if (args.includes(",") && !args.includes("/")) return null;
|
||||||
|
const { values, alpha } = splitSlashAlpha(args);
|
||||||
|
if (values.length < 3) return null;
|
||||||
|
const h = parseHue(values[0]!);
|
||||||
|
const s = parsePercentOrNumber(values[1]!, 1);
|
||||||
|
const l = parsePercentOrNumber(values[2]!, 1);
|
||||||
|
if (h == null || s == null || l == null) return null;
|
||||||
|
return formatRgba(hslToSrgb(h, s, l), alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMixStop(raw: string): { color: string; percent: number | null } {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
const match = trimmed.match(/^(.*?)(?:\s+(-?[\d.]+%))?\s*$/);
|
||||||
|
const color = (match?.[1] ?? trimmed).trim();
|
||||||
|
const percent = match?.[2] ? Number.parseFloat(match[2]) : null;
|
||||||
|
return { color, percent: Number.isFinite(percent) ? percent : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMixColor(value: string): [number, number, number, number] | null {
|
||||||
|
const text = value.trim();
|
||||||
|
if (!text || /^var\(/i.test(text)) return null;
|
||||||
|
if (/^transparent$/i.test(text)) return [0, 0, 0, 0];
|
||||||
|
const hex = parseHex(text);
|
||||||
|
if (hex) return hex;
|
||||||
|
const rgbMatch = text.match(/^rgba?\((.+)\)$/i);
|
||||||
|
if (rgbMatch) {
|
||||||
|
const converted = convertModernRgb(rgbMatch[1]!) ?? (/,\s*/.test(rgbMatch[1]!) ? `rgba(${rgbMatch[1]})` : null);
|
||||||
|
if (converted) {
|
||||||
|
const inner = converted.match(/^rgba?\((.+)\)$/i);
|
||||||
|
if (inner) {
|
||||||
|
const bits = inner[1]!.split(",").map((part) => Number.parseFloat(part.trim()));
|
||||||
|
if (bits.length >= 3 && bits.every((n) => Number.isFinite(n))) {
|
||||||
|
return [bits[0]!, bits[1]!, bits[2]!, bits[3] ?? 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NAMED[text.toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitSlashAlpha(args: string): { values: string[]; alpha: number } {
|
||||||
|
const pieces = splitTopLevel(args, "/");
|
||||||
|
const values = tokenizeCssList(pieces[0] ?? "");
|
||||||
|
const alpha = pieces.length > 1 ? normalizeAlpha(pieces[1]!.trim()) : 1;
|
||||||
|
return { values, alpha };
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitTopLevel(source: string, separator: string): string[] {
|
||||||
|
const parts: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let depth = 0;
|
||||||
|
let quote: string | null = null;
|
||||||
|
for (let i = 0; i < source.length; i += 1) {
|
||||||
|
const char = source[i]!;
|
||||||
|
if (quote) {
|
||||||
|
current += char;
|
||||||
|
if (char === "\\" && i + 1 < source.length) {
|
||||||
|
current += source[i + 1];
|
||||||
|
i += 1;
|
||||||
|
} else if (char === quote) {
|
||||||
|
quote = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === '"' || char === "'") {
|
||||||
|
quote = char;
|
||||||
|
current += char;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "(") depth += 1;
|
||||||
|
if (char === ")") depth -= 1;
|
||||||
|
if (depth === 0 && char === separator) {
|
||||||
|
parts.push(current);
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
if (current.trim()) parts.push(current);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenizeCssList(source: string): string[] {
|
||||||
|
return source
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLightness(raw: string): number | null {
|
||||||
|
const value = parseNumber(raw);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (/%$/.test(raw.trim()) || value > 1) return clamp(value / 100, 0, 1);
|
||||||
|
return clamp(value, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePercentOrNumber(raw: string, percentBase: number): number | null {
|
||||||
|
const value = parseNumber(raw);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (/%$/.test(raw.trim())) return value / 100 * (percentBase === 100 ? 100 : 1);
|
||||||
|
if (percentBase === 100 && value <= 1) return value * 100;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgbChannel(raw: string): number | null {
|
||||||
|
const value = parseNumber(raw);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (/%$/.test(raw.trim())) return clamp(value * 2.55, 0, 255);
|
||||||
|
if (value >= 0 && value <= 1 && /0?\.\d+/.test(raw.trim())) return clamp(value * 255, 0, 255);
|
||||||
|
return clamp(value, 0, 255);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHue(raw: string): number | null {
|
||||||
|
const text = raw.trim().toLowerCase();
|
||||||
|
const value = parseNumber(text);
|
||||||
|
if (value == null) return null;
|
||||||
|
if (text.endsWith("rad")) return (value * 180) / Math.PI;
|
||||||
|
if (text.endsWith("turn")) return value * 360;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(raw: string): number | null {
|
||||||
|
const value = Number.parseFloat(raw.trim().replace(/deg|rad|turn|%/gi, ""));
|
||||||
|
return Number.isFinite(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAlpha(raw: string): number {
|
||||||
|
const value = parseNumber(raw);
|
||||||
|
if (value == null) return 1;
|
||||||
|
if (/%$/.test(raw.trim()) || value > 1) return clamp(value / 100, 0, 1);
|
||||||
|
return clamp(value, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHex(value: string): [number, number, number, number] | null {
|
||||||
|
const hex = value.trim();
|
||||||
|
const short = /^#([\da-f]{3})$/i.exec(hex);
|
||||||
|
if (short) {
|
||||||
|
const [r, g, b] = short[1]!.split("").map((ch) => Number.parseInt(ch + ch, 16));
|
||||||
|
return [r!, g!, b!, 1];
|
||||||
|
}
|
||||||
|
const long = /^#([\da-f]{6})([\da-f]{2})?$/i.exec(hex);
|
||||||
|
if (!long) return null;
|
||||||
|
const n = Number.parseInt(long[1]!, 16);
|
||||||
|
const alpha = long[2] ? Number.parseInt(long[2], 16) / 255 : 1;
|
||||||
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, alpha];
|
||||||
|
}
|
||||||
|
|
||||||
|
function polarToLab(c: number, hueDeg: number): [number, number] {
|
||||||
|
const hue = (hueDeg * Math.PI) / 180;
|
||||||
|
return [c * Math.cos(hue), c * Math.sin(hue)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function oklabToSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||||
|
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||||
|
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
||||||
|
const l = l_ * l_ * l_;
|
||||||
|
const m = m_ * m_ * m_;
|
||||||
|
const s = s_ * s_ * s_;
|
||||||
|
return linearToSrgb([
|
||||||
|
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
||||||
|
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
||||||
|
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labToSrgb(L: number, a: number, b: number): [number, number, number] {
|
||||||
|
const y = (L + 16) / 116;
|
||||||
|
const x = a / 500 + y;
|
||||||
|
const z = y - b / 200;
|
||||||
|
const xn = 0.95047;
|
||||||
|
const yn = 1;
|
||||||
|
const zn = 1.08883;
|
||||||
|
const [X, Y, Z] = [xn * labFn(x), yn * labFn(y), zn * labFn(z)];
|
||||||
|
return linearToSrgb([
|
||||||
|
3.2404542 * X - 1.5371385 * Y - 0.4985314 * Z,
|
||||||
|
-0.969266 * X + 1.8760108 * Y + 0.041556 * Z,
|
||||||
|
0.0556434 * X - 0.2040259 * Y + 1.0572252 * Z,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function labFn(t: number): number {
|
||||||
|
const d = 6 / 29;
|
||||||
|
return t > d ? t * t * t : 3 * d * d * (t - 4 / 29);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToSrgb(h: number, s: number, l: number): [number, number, number] {
|
||||||
|
const sat = clamp(s, 0, 1);
|
||||||
|
const light = clamp(l, 0, 1);
|
||||||
|
const c = (1 - Math.abs(2 * light - 1)) * sat;
|
||||||
|
const hp = (((h % 360) + 360) % 360) / 60;
|
||||||
|
const x = c * (1 - Math.abs((hp % 2) - 1));
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (hp >= 0 && hp < 1) [r, g, b] = [c, x, 0];
|
||||||
|
else if (hp < 2) [r, g, b] = [x, c, 0];
|
||||||
|
else if (hp < 3) [r, g, b] = [0, c, x];
|
||||||
|
else if (hp < 4) [r, g, b] = [0, x, c];
|
||||||
|
else if (hp < 5) [r, g, b] = [x, 0, c];
|
||||||
|
else [r, g, b] = [c, 0, x];
|
||||||
|
const m = light - c / 2;
|
||||||
|
return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
|
||||||
|
}
|
||||||
|
|
||||||
|
function linearToSrgb(channels: [number, number, number]): [number, number, number] {
|
||||||
|
return channels.map((channel) => {
|
||||||
|
const c = clamp(channel, 0, 1);
|
||||||
|
const gamma = c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055;
|
||||||
|
return gamma * 255;
|
||||||
|
}) as [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRgba(rgb: [number, number, number], alpha: number): string {
|
||||||
|
const r = Math.round(clamp(rgb[0], 0, 255));
|
||||||
|
const g = Math.round(clamp(rgb[1], 0, 255));
|
||||||
|
const b = Math.round(clamp(rgb[2], 0, 255));
|
||||||
|
const a = clamp(alpha, 0, 1);
|
||||||
|
if (a >= 0.999) return `rgb(${r}, ${g}, ${b})`;
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const NAMED: Record<string, [number, number, number, number]> = {
|
||||||
|
black: [0, 0, 0, 1],
|
||||||
|
white: [255, 255, 255, 1],
|
||||||
|
red: [255, 0, 0, 1],
|
||||||
|
green: [0, 128, 0, 1],
|
||||||
|
blue: [0, 0, 255, 1],
|
||||||
|
transparent: [0, 0, 0, 0],
|
||||||
|
};
|
||||||
Reference in new issue
Block a user