From 96d56bc17053c752194659cc2767e09a5edc1090 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Sat, 28 Feb 2026 20:10:32 +0800 Subject: [PATCH 1/4] refactor: cleanup Takumi glue code --- package.json | 4 +- pnpm-lock.yaml | 12 +- src/runtime/server/og-image/takumi/nodes.ts | 153 +---------------- .../server/og-image/takumi/renderer.ts | 61 ++----- .../server/og-image/takumi/sanitize.ts | 24 --- src/runtime/server/og-image/utils/css.ts | 81 --------- .../server/og-image/utils/gradient-svg.ts | 160 ------------------ 7 files changed, 27 insertions(+), 468 deletions(-) delete mode 100644 src/runtime/server/og-image/utils/gradient-svg.ts diff --git a/package.json b/package.json index b9c06d688..c1fc81d84 100644 --- a/package.json +++ b/package.json @@ -70,8 +70,8 @@ "peerDependencies": { "@resvg/resvg-js": "^2.6.0", "@resvg/resvg-wasm": "^2.6.0", - "@takumi-rs/core": "^0.69.0", - "@takumi-rs/wasm": "^0.69.0", + "@takumi-rs/core": "^0.69.5", + "@takumi-rs/wasm": "^0.69.5", "@unhead/vue": "^2.0.5", "fontless": "^0.2.0", "playwright-core": "^1.50.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ade7bb17..b8a602723 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9673,7 +9673,7 @@ snapshots: '@eslint-community/eslint-plugin-eslint-comments': 4.6.0(eslint@10.0.2(jiti@2.6.1)) '@eslint/markdown': 7.5.1 '@stylistic/eslint-plugin': 5.9.0(eslint@10.0.2(jiti@2.6.1)) - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@vitest/eslint-plugin': 1.6.9(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)(vitest@4.0.18(@types/node@25.3.2)(esbuild@0.27.3)(happy-dom@20.7.0)(jiti@2.6.1)(sass@1.97.3)(terser@5.46.0)(yaml@2.8.2)) ansis: 4.2.0 @@ -9694,7 +9694,7 @@ snapshots: eslint-plugin-regexp: 3.0.0(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-toml: 1.3.0(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-unicorn: 63.0.0(eslint@10.0.2(jiti@2.6.1)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) + eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)) eslint-plugin-vue: 10.8.0(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))) eslint-plugin-yml: 3.3.0(eslint@10.0.2(jiti@2.6.1)) eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.29)(eslint@10.0.2(jiti@2.6.1)) @@ -12947,10 +12947,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.56.1 '@typescript-eslint/type-utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/utils': 8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) @@ -15384,11 +15384,11 @@ snapshots: semver: 7.7.4 strip-indent: 4.1.1 - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): + eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1)): dependencies: eslint: 10.0.2(jiti@2.6.1) optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@6.21.0(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3) eslint-plugin-vue@10.8.0(@stylistic/eslint-plugin@5.9.0(eslint@10.0.2(jiti@2.6.1)))(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@10.0.2(jiti@2.6.1))): dependencies: diff --git a/src/runtime/server/og-image/takumi/nodes.ts b/src/runtime/server/og-image/takumi/nodes.ts index d06a99220..6b53e81ab 100644 --- a/src/runtime/server/og-image/takumi/nodes.ts +++ b/src/runtime/server/og-image/takumi/nodes.ts @@ -1,6 +1,5 @@ import type { OgImageRenderEventContext, VNode } from '../../../types' import { createVNodes, SVG_CAMEL_ATTR_VALUES } from '../core/vnodes' -import { resolveUnsupportedUnits, stripGradientColorSpace } from '../utils/css' export interface TakumiNode { type: 'container' | 'image' | 'text' @@ -15,116 +14,28 @@ export interface TakumiNode { export async function createTakumiNodes(ctx: OgImageRenderEventContext): Promise { const vnodeTree = await createVNodes(ctx) - return await vnodeToTakumiNode(vnodeTree, Number(ctx.options.width), Number(ctx.options.height)) + return await vnodeToTakumiNode(vnodeTree) } -async function vnodeToTakumiNode(vnode: VNode, parentWidth?: number, parentHeight?: number, inheritedColor?: string): Promise { +async function vnodeToTakumiNode(vnode: VNode): Promise { let { style, children, class: cls, tw, src, width, height, ...rest } = vnode.props - if (style && typeof style === 'object') { - style = Object.fromEntries( - Object.entries(style) - .filter(([_, v]) => v !== undefined && v !== null && v !== '') - .map(([k, v]) => [k, typeof v === 'string' ? resolveUnsupportedUnits(v, parentWidth, parentHeight) : v]), - ) - // Takumi expects lineClamp as a number, but HTML/CSS parsing yields a string - if (style.lineClamp != null) - style.lineClamp = Number(style.lineClamp) - if (Object.keys(style).length === 0) - style = undefined - } - - // Helper to resolve units to pixels - const resolvePx = (val: any, total?: number) => { - if (typeof val === 'string') { - if (val.includes('calc(')) - return undefined - const num = Number.parseFloat(val) - if (Number.isNaN(num)) - return undefined - if (val.endsWith('%') && total) - return (num / 100) * total - if (val.endsWith('em') || val.endsWith('rem')) - return num * 16 - return num - } - if (typeof val === 'number') - return val - return undefined - } - - // Dimension fallback logic - const resolvedWidth = resolvePx(width, parentWidth) || resolvePx(style?.width, parentWidth) || extractTwDim(tw || cls, 'w', parentWidth) - const resolvedHeight = resolvePx(height, parentHeight) || resolvePx(style?.height, parentHeight) || extractTwDim(tw || cls, 'h', parentHeight) - - // Resolve color for currentColor inheritance (CSS color cascades to children) - const nodeColor = style?.color || inheritedColor - - // SVG elements → convert to SVG data URI for takumi to handle + // SVG elements → convert to SVG to string if (vnode.type === 'svg') { - let finalWidth = resolvedWidth - let finalHeight = resolvedHeight - - // Parse viewBox for missing dimensions - if ((!finalWidth || !finalHeight) && typeof rest.viewBox === 'string') { - const parts = rest.viewBox.split(/[ ,]+/).map(Number) - if (parts.length === 4 && !parts.some(Number.isNaN)) { - const vbWidth = parts[2]! - const vbHeight = parts[3]! - if (!finalWidth && !finalHeight) { - finalWidth = vbWidth - finalHeight = vbHeight - } - else if (finalWidth) { - finalHeight = Math.round(finalWidth * (vbHeight / vbWidth)) - } - else if (finalHeight) { - finalWidth = Math.round(finalHeight * (vbWidth / vbHeight)) - } - } - } - - if (finalWidth && !finalHeight) - finalHeight = finalWidth - else if (finalHeight && !finalWidth) - finalWidth = finalHeight - - const renderWidth = finalWidth || 100 - const renderHeight = finalHeight || 100 - - const svgProps = { ...vnode.props } - svgProps.width = renderWidth - svgProps.height = renderHeight - let svg = vnodeToHtmlString({ ...vnode, props: svgProps }) - - // Resolve currentColor before base64 encoding — images lose CSS inheritance context - if (nodeColor && svg.includes('currentColor')) - svg = svg.replaceAll('currentColor', nodeColor) + const src = vnodeToHtmlString(vnode) return { type: 'image', - src: `data:image/svg+xml;base64,${Buffer.from(svg).toString('base64')}`, - width: renderWidth, - height: renderHeight, + src, tw: tw || cls || undefined, style, } } if (vnode.type === 'img') { - let finalWidth = resolvedWidth - let finalHeight = resolvedHeight - - if (finalWidth && !finalHeight) - finalHeight = finalWidth - else if (finalHeight && !finalWidth) - finalWidth = finalHeight - return { type: 'image', src: src || rest.href || '', - width: finalWidth, - height: finalHeight, tw: tw || cls || undefined, style, } @@ -143,8 +54,6 @@ async function vnodeToTakumiNode(vnode: VNode, parentWidth?: number, parentHeigh return { type: 'text', text: textContent, - width: resolvedWidth, - height: resolvedHeight, tw: tw || cls || undefined, style, } @@ -155,7 +64,7 @@ async function vnodeToTakumiNode(vnode: VNode, parentWidth?: number, parentHeigh const takumiChildren: TakumiNode[] = [] for (const child of children) { if (child && typeof child === 'object') - takumiChildren.push(await vnodeToTakumiNode(child, resolvedWidth, resolvedHeight, nodeColor)) + takumiChildren.push(await vnodeToTakumiNode(child)) else if (typeof child === 'string' && child.trim()) takumiChildren.push({ type: 'text', text: child.trim() }) } @@ -163,8 +72,6 @@ async function vnodeToTakumiNode(vnode: VNode, parentWidth?: number, parentHeigh return { type: 'container', children: takumiChildren.length ? takumiChildren : undefined, - width: resolvedWidth, - height: resolvedHeight, tw: tw || cls || undefined, style, } @@ -173,61 +80,17 @@ async function vnodeToTakumiNode(vnode: VNode, parentWidth?: number, parentHeigh // No children return { type: 'container', - width: resolvedWidth, - height: resolvedHeight, tw: tw || cls || undefined, style, } } -// Extract dimensions from tailwind classes (enhanced support) -function extractTwDim(twCls: string | undefined, prefix: string, total?: number): number | undefined { - if (!twCls || twCls.includes('calc(')) - return undefined - - // Match arbitrary value classes (w-[31.5%], h-[48%], w-[100px]) - const arbMatch = twCls.match(new RegExp(`\\b${prefix}-\\[([^\\]]+)\\]\\b`)) - if (arbMatch?.[1]) { - const val = arbMatch[1] - if (val.endsWith('%') && total) - return (Number.parseFloat(val) / 100) * total - if (val.endsWith('px')) - return Number.parseFloat(val) - if (!Number.isNaN(Number(val))) - return Number(val) - } - - // Match basic numeric classes (w-32, h-64) - const numMatch = twCls.match(new RegExp(`\\b${prefix}-(\\d+)\\b`)) - if (numMatch?.[1]) - return Number.parseInt(numMatch[1]) * 4 - - // Match fraction classes (w-1/3, w-full, h-screen) - if (total) { - if (twCls.includes(`${prefix}-full`)) - return total - if (twCls.includes(`${prefix}-1/2`)) - return total * 0.5 - if (twCls.includes(`${prefix}-1/3`)) - return total * (1 / 3) - if (twCls.includes(`${prefix}-2/3`)) - return total * (2 / 3) - if (twCls.includes(`${prefix}-1/4`)) - return total * 0.25 - if (twCls.includes(`${prefix}-3/4`)) - return total * 0.75 - } - return undefined -} - function vnodeToHtmlString(vnode: VNode): string { const { style, children, ...attrs } = vnode.props const attrParts: string[] = [] const kebabCase = (str: string) => str.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`) - const stripColorSpace = (val: any) => typeof val === 'string' ? stripGradientColorSpace(val) : val - if (vnode.type === 'svg') { if (!attrs.xmlns) attrParts.push('xmlns="http://www.w3.org/2000/svg"') @@ -254,13 +117,13 @@ function vnodeToHtmlString(vnode: VNode): string { if (style && typeof style === 'object') { const styleStr = Object.entries(style) .filter(([_, v]) => v !== undefined && v !== null && v !== '') - .map(([k, v]) => `${kebabCase(k)}:${stripColorSpace(resolveValue(v))}`) + .map(([k, v]) => `${kebabCase(k)}:${resolveValue(v)}`) .join(';') if (styleStr) attrParts.push(`style="${styleStr.replace(/"/g, '"')}"`) } else if (typeof style === 'string') { - attrParts.push(`style="${stripColorSpace(style as string).replace(/"/g, '"')}"`) + attrParts.push(`style="${(style as string).replace(/"/g, '"')}"`) } for (const [key, val] of Object.entries(attrs)) { diff --git a/src/runtime/server/og-image/takumi/renderer.ts b/src/runtime/server/og-image/takumi/renderer.ts index bf6ed1efe..616aed59d 100644 --- a/src/runtime/server/og-image/takumi/renderer.ts +++ b/src/runtime/server/og-image/takumi/renderer.ts @@ -5,7 +5,6 @@ import { defu } from 'defu' import { withBase } from 'ufo' import { logger } from '../../../logger' import { extractCodepointsFromTakumiNodes, loadAllFonts } from '../fonts' -import { linearGradientToSvg, radialGradientToSvg } from '../utils/gradient-svg' import { detectImageExt } from '../utils/image-detector' import { useExtractResourceUrls, useTakumi } from './instances' import { createTakumiNodes } from './nodes' @@ -167,12 +166,6 @@ function rewriteResourceUrls(node: any, map: Map) { walk(node) } -/** - * Sanitize node styles for the takumi WASM renderer. - * Resolves color-mix() to rgba, strips unresolved var() references, - * and removes other CSS values the WASM renderer can't handle. - */ - async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jpeg' | 'webp') { const { options } = event @@ -192,11 +185,6 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp if (fontFamilyOverride && state.familySubsetNames.has(fontFamilyOverride)) { nodes.style.fontFamily = fontFamilyOverride } - else if (!nodes.style.fontFamily) { - const allSubsetNames = [...state.familySubsetNames.values()].flat() - if (allSubsetNames.length) - nodes.style.fontFamily = allSubsetNames.join(', ') - } rewriteFontFamilies(nodes, state.familySubsetNames) @@ -204,22 +192,6 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp const resourceUrls = extractResourceUrls(nodes) const { dataUris, bgUrls } = extractInlineResources(nodes) - const gradients: { node: any, value: string, prop: string }[] = [] - const walkG = (n: any) => { - for (const prop of ['backgroundImage', 'background'] as const) { - const v = n.style?.[prop] - if (v?.includes('linear-gradient') || v?.includes('radial-gradient')) { - gradients.push({ node: n, value: v, prop }) - break - } - } - if (n.children) { - for (const child of n.children) - walkG(child) - } - } - walkG(nodes) - const allUrls = new Set([...resourceUrls, ...bgUrls]) const origin = useNitroOrigin(event.e) const baseURL = event.runtimeConfig.app.baseURL @@ -266,26 +238,6 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp resourceRewriteMap.set(originalSrc.split('?')[0]!, virtualUrl) } - for (const gradient of gradients) { - const svg = gradient.value.includes('radial-gradient') - ? radialGradientToSvg(gradient.value, gradient.node.style?.backgroundColor) - : linearGradientToSvg(gradient.value, gradient.node.style?.backgroundColor) - if (svg) { - const data = new Uint8Array(Buffer.from(svg)) - const virtualUrl = `virtual:gradient-${fetchedResources.length}.svg` - fetchedResources.push({ src: virtualUrl, data }) - gradient.node.style.backgroundImage = `url(${virtualUrl})` - if (gradient.prop === 'background') - delete gradient.node.style.background - if (!gradient.node.style.backgroundSize) - gradient.node.style.backgroundSize = 'cover' - } - else { - // Conversion failed — remove to prevent WASM crash - delete gradient.node.style[gradient.prop] - } - } - rewriteResourceUrls(nodes, resourceRewriteMap) const dpr = options.takumi?.devicePixelRatio ?? 2 @@ -298,8 +250,17 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp }) const result = await state.renderer.render(nodes, renderOptions) - // @takumi-rs/wasm returns WasmBuffer (zero-copy), @takumi-rs/core returns Buffer - return 'asUint8Array' in result ? result.asUint8Array() : result + + // @takumi-rs/wasm path + if ('asUint8Array' in result) { + const buffer = result.asUint8Array().slice(); + + result.free(); + + return buffer; + } + + return result; } const TakumiRenderer: Renderer = { diff --git a/src/runtime/server/og-image/takumi/sanitize.ts b/src/runtime/server/og-image/takumi/sanitize.ts index 0857b0518..ddee1a19d 100644 --- a/src/runtime/server/og-image/takumi/sanitize.ts +++ b/src/runtime/server/og-image/takumi/sanitize.ts @@ -1,7 +1,3 @@ -import { resolveColorMix } from '../utils/css' - -const VALID_VERTICAL_ALIGN = new Set(['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super', 'initial', 'inherit']) - export function sanitizeTakumiStyles(node: any) { if (node.style) { for (const prop of Object.keys(node.style)) { @@ -9,31 +5,11 @@ export function sanitizeTakumiStyles(node: any) { if (typeof value !== 'string') continue let v = value - // Resolve color-mix() to rgba - if (v.includes('color-mix(')) - v = resolveColorMix(v) - // If color-mix() still present after resolution, strip the property - if (v.includes('color-mix(')) { - delete node.style[prop] - continue - } // Strip properties with unresolved var() references if (v.includes('var(')) { delete node.style[prop] continue } - // Strip modern color functions the WASM renderer can't handle - if (/\b(?:lab|lch|oklab|oklch|color)\(/.test(v)) { - delete node.style[prop] - continue - } - // Takumi only accepts keyword values for vertical-align - if (prop === 'verticalAlign' && !VALID_VERTICAL_ALIGN.has(v)) { - node.style[prop] = 'middle' - continue - } - if (v !== value) - node.style[prop] = v } } if (node.children) { diff --git a/src/runtime/server/og-image/utils/css.ts b/src/runtime/server/og-image/utils/css.ts index b33c412de..c6ac9f164 100644 --- a/src/runtime/server/og-image/utils/css.ts +++ b/src/runtime/server/og-image/utils/css.ts @@ -40,32 +40,6 @@ export function splitCssDeclarations(style: string): string[] { return declarations } -/** - * Convert container query / dynamic viewport units to pixels. - * In OG image context, the "container" is the image itself (default 1200x630). - */ -const UNSUPPORTED_UNIT_RE = /(-?[\d.]+)(cqw|cqh|cqi|cqb|cqmin|cqmax|dvw|dvh|svw|svh|lvw|lvh)\b/g -export function resolveUnsupportedUnits(val: string, containerWidth?: number, containerHeight?: number): string { - if (!UNSUPPORTED_UNIT_RE.test(val)) - return val - return val.replace(UNSUPPORTED_UNIT_RE, (_, num, unit) => { - const n = Number.parseFloat(num) - if (Number.isNaN(n)) - return '0px' - const w = containerWidth || 1200 - const h = containerHeight || 630 - const widthUnits = new Set(['cqw', 'cqi', 'dvw', 'svw', 'lvw']) - const heightUnits = new Set(['cqh', 'cqb', 'dvh', 'svh', 'lvh']) - if (widthUnits.has(unit)) - return `${(n / 100) * w}px` - if (heightUnits.has(unit)) - return `${(n / 100) * h}px` - // cqmin/cqmax - const ref = unit === 'cqmin' ? Math.min(w, h) : Math.max(w, h) - return `${(n / 100) * ref}px` - }) -} - export const GRADIENT_COLOR_SPACE_RE = /\s+in\s+(?:oklab|oklch|srgb(?:-linear)?|display-p3|a98-rgb|prophoto-rgb|rec2020|xyz(?:-d(?:50|65))?|hsl|hwb|lab|lch)/g /** @@ -77,58 +51,3 @@ export function stripGradientColorSpace(value: string): string { return value return value.replace(GRADIENT_COLOR_SPACE_RE, '') } - -/** - * Resolve `color-mix()` to rgba for renderers that don't support it. - * Handles the common TW4 pattern: `color-mix(in , , transparent)` - */ -const COLOR_MIX_HEX_RE = /color-mix\(in\s+\w+,\s*#([0-9a-fA-F]{3,8})\s+([\d.]+%?)\s*,\s*transparent\s*\)/g -// eslint-disable-next-line no-control-regex -const COLOR_MIX_RGBA_RE = /color-mix\(in\s+\w+,\s*rgba?\((\d+)[\x09-\x0D\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?: |(?: \s*)?,)\s*(\d+)[\x09-\x0D\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?: |(?: \s*)?,)\s*(\d+)(?:\s*[/,]\s*[\d.]+)?\)\s+([\d.]+%?)\s*,\s*transparent\s*\)/g - -function hexToRgb(hex: string): [number, number, number] | null { - if (hex.length === 3) - hex = hex[0]! + hex[0]! + hex[1]! + hex[1]! + hex[2]! + hex[2]! - if (hex.length !== 6 && hex.length !== 8) - return null - const r = Number.parseInt(hex.slice(0, 2), 16) - const g = Number.parseInt(hex.slice(2, 4), 16) - const b = Number.parseInt(hex.slice(4, 6), 16) - if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) - return null - return [r, g, b] -} - -function parseColorMixAmount(amount: string): number { - let opacity = Number.parseFloat(amount) - if (Number.isNaN(opacity)) - return Number.NaN - if (amount.endsWith('%')) - opacity /= 100 - else if (opacity > 1) - opacity /= 100 - return opacity -} - -export function resolveColorMix(value: string): string { - if (!value.includes('color-mix(')) - return value - // Handle hex colors: color-mix(in oklab, #00bcfe .14, transparent) - let result = value.replace(COLOR_MIX_HEX_RE, (_match, hex: string, amount: string) => { - const rgb = hexToRgb(hex) - if (!rgb) - return _match - const opacity = parseColorMixAmount(amount) - if (Number.isNaN(opacity)) - return _match - return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${opacity})` - }) - // Handle rgb/rgba colors: color-mix(in oklab, rgb(0, 188, 254) 14%, transparent) - result = result.replace(COLOR_MIX_RGBA_RE, (_match, r: string, g: string, b: string, amount: string) => { - const opacity = parseColorMixAmount(amount) - if (Number.isNaN(opacity)) - return _match - return `rgba(${r}, ${g}, ${b}, ${opacity})` - }) - return result -} diff --git a/src/runtime/server/og-image/utils/gradient-svg.ts b/src/runtime/server/og-image/utils/gradient-svg.ts deleted file mode 100644 index 3845e9db6..000000000 --- a/src/runtime/server/og-image/utils/gradient-svg.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { stripGradientColorSpace } from './css' - -function parseGradientStops(parts: string[]): string { - return parts - .map((stop, i, arr) => { - // Color hints (e.g. `linear-gradient(red, 50%, blue)`) — skip - if (!Number.isNaN(Number(stop))) - return null - - // Split color from optional offset (e.g. `#fff 50%` or `rgba(...) 100%`) - let color = stop - let offset = `${Math.round((i / (arr.length - 1)) * 100)}%` - const lastSpaceIdx = stop.lastIndexOf(' ') - if (lastSpaceIdx !== -1) { - const maybeOffset = stop.slice(lastSpaceIdx + 1) - if (maybeOffset.endsWith('%') && !Number.isNaN(Number.parseFloat(maybeOffset))) { - color = stop.slice(0, lastSpaceIdx).trim() - offset = maybeOffset - } - } - - let opacity = '1' - const rgbaMatch = color.match(/rgba?\((.+)\)/) - if (rgbaMatch) { - const cParts = rgbaMatch[1]!.split(',').map(p => p.trim()) - if (cParts.length === 4) { - color = `rgb(${cParts[0]},${cParts[1]},${cParts[2]})` - opacity = cParts[3]! - } - } - return `` - }) - .filter(Boolean) - .join('') -} - -export function linearGradientToSvg(gradient: string, backgroundColor?: string): string | null { - const match = gradient.match(/linear-gradient\((.*)\)/) - if (!match) - return null - - // Strip gradient color interpolation methods (e.g. `in oklab`, `in oklch`) - // TW4 generates these but image renderers (Satori, Takumi) don't support them. - const cleanedGradient = stripGradientColorSpace(match[1]!) - - const parts = cleanedGradient.split(/,(?![^(]*\))/).map(p => p.trim()) - let x1 = '0%' - let y1 = '0%' - let x2 = '0%' - let y2 = '100%' - let stopsStartIdx = 0 - - if (parts[0]!.includes('deg')) { - const angle = Number.parseInt(parts[0]!) || 180 - const rad = (angle - 90) * (Math.PI / 180) - x1 = `${50 - Math.cos(rad) * 50}%` - y1 = `${50 - Math.sin(rad) * 50}%` - x2 = `${50 + Math.cos(rad) * 50}%` - y2 = `${50 + Math.sin(rad) * 50}%` - stopsStartIdx = 1 - } - else if (parts[0]!.includes('to ')) { - if (parts[0]!.includes('right')) { - x1 = '0%' - x2 = '100%' - y1 = '0%' - y2 = '0%' - } - else if (parts[0]!.includes('bottom')) { - x1 = '0%' - x2 = '0%' - y1 = '0%' - y2 = '100%' - } - stopsStartIdx = 1 - } - - const stops = parseGradientStops(parts.slice(stopsStartIdx)) - const bgRect = backgroundColor ? `` : '' - return `${stops}${bgRect}` -} - -function parseGradientPositionValue(v: string): number { - if (v === 'center') - return 0.5 - if (v === 'left' || v === 'top') - return 0 - if (v === 'right' || v === 'bottom') - return 1 - const num = Number.parseFloat(v) - if (v.endsWith('%')) - return num / 100 - return num / 100 -} - -export function radialGradientToSvg(gradient: string, backgroundColor?: string): string | null { - const match = gradient.match(/radial-gradient\((.*)\)/) - if (!match) - return null - - const cleanedGradient = stripGradientColorSpace(match[1]!) - const parts = cleanedGradient.split(/,(?![^(]*\))/).map(p => p.trim()) - - let cx = 0.5 - let cy = 0.5 - let rx = -1 - let ry = -1 - let stopsStartIdx = 0 - - const firstPart = parts[0]! - - // Detect if first part is a shape/size/position definition (not a color stop) - const isDefinition = /\bat\b|^circle\b|^ellipse\b|^closest-|^farthest-/.test(firstPart) - || (/^\d+(?:\.\d+)?(?:%|px)?\s+\d/.test(firstPart) && !/(?:#|rgb|hsl|transparent|currentcolor)\b/i.test(firstPart)) - - if (isDefinition) { - stopsStartIdx = 1 - - // Parse position: "at " - const atMatch = firstPart.match(/at\s+(?[\w.]+(?:%|px)?)\s+(?[\w.]+(?:%|px)?)/) - if (atMatch?.groups) { - cx = parseGradientPositionValue(atMatch.groups.x!) - cy = parseGradientPositionValue(atMatch.groups.y!) - } - - // Parse size (strip shape keyword and position for extraction) - const sizePart = firstPart.replace(/^(?:circle|ellipse)\s*/, '').replace(/\s*at\s+(?:\S.*)?$/, '').trim() - if (sizePart && !(/^(?:closest|farthest)-(?:side|corner)$/.test(sizePart))) { - const sizeVals = sizePart.match(/[\d.]+(?:%|px)?/g) - if (sizeVals && sizeVals.length >= 2) { - rx = Number.parseFloat(sizeVals[0]!) / 100 - ry = Number.parseFloat(sizeVals[1]!) / 100 - } - else if (sizeVals && sizeVals.length === 1) { - rx = ry = Number.parseFloat(sizeVals[0]!) / 100 - } - } - } - - // Default: farthest-corner radius from center - if (rx < 0 || ry < 0) { - const maxDx = Math.max(cx, 1 - cx) - const maxDy = Math.max(cy, 1 - cy) - rx = ry = Math.sqrt(maxDx ** 2 + maxDy ** 2) - } - - const stops = parseGradientStops(parts.slice(stopsStartIdx)) - const bgRect = backgroundColor ? `` : '' - - // Circular or effectively circular - if (Math.abs(rx - ry) < 0.001) { - return `${stops}${bgRect}` - } - - // Elliptical: use gradientTransform to scale Y axis - const r = rx - const scaleY = ry / rx - const transform = `translate(${cx}, ${cy}) scale(1, ${scaleY}) translate(${-cx}, ${-cy})` - return `${stops}${bgRect}` -} From 3cfa68ec8cf03315e0c66546230c16166e7b9538 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Sat, 28 Feb 2026 20:14:32 +0800 Subject: [PATCH 2/4] chore: remove unused test cases --- test/unit/css-sanitization.test.ts | 138 ----------------------------- test/unit/takumiNodes.test.ts | 73 --------------- 2 files changed, 211 deletions(-) diff --git a/test/unit/css-sanitization.test.ts b/test/unit/css-sanitization.test.ts index 052419e32..fff4d2634 100644 --- a/test/unit/css-sanitization.test.ts +++ b/test/unit/css-sanitization.test.ts @@ -1,47 +1,6 @@ import { describe, expect, it } from 'vitest' import { downlevelColor, resolveCssVars, stripAtSupports } from '../../src/build/css/css-utils' import { sanitizeTakumiStyles } from '../../src/runtime/server/og-image/takumi/sanitize' -import { resolveColorMix } from '../../src/runtime/server/og-image/utils/css' -import { linearGradientToSvg, radialGradientToSvg } from '../../src/runtime/server/og-image/utils/gradient-svg' - -describe('resolveColorMix', () => { - it('resolves hex color with decimal fraction', () => { - expect(resolveColorMix('color-mix(in oklab, #00bcfe .14, transparent)')) - .toBe('rgba(0, 188, 254, 0.14)') - }) - - it('resolves hex color with percentage', () => { - expect(resolveColorMix('color-mix(in oklab, #00bcfe 14%, transparent)')) - .toBe('rgba(0, 188, 254, 0.14)') - }) - - it('resolves short hex color', () => { - expect(resolveColorMix('color-mix(in oklab, #fff 8%, transparent)')) - .toBe('rgba(255, 255, 255, 0.08)') - }) - - it('resolves with srgb color space', () => { - expect(resolveColorMix('color-mix(in srgb, #ffffff 4%, transparent)')) - .toBe('rgba(255, 255, 255, 0.04)') - }) - - it('passes through non-color-mix values', () => { - expect(resolveColorMix('rgba(0, 188, 254, 0.14)')).toBe('rgba(0, 188, 254, 0.14)') - expect(resolveColorMix('red')).toBe('red') - expect(resolveColorMix('#00bcfe')).toBe('#00bcfe') - }) - - it('returns unresolvable color-mix unchanged (for stripping later)', () => { - const oklch = 'color-mix(in oklab, oklch(0.7 0.1 200) 14%, transparent)' - expect(resolveColorMix(oklch)).toBe(oklch) - }) - - it('resolves color-mix embedded in other values', () => { - // e.g. a border shorthand - expect(resolveColorMix('1px solid color-mix(in oklab, #00bcfe 50%, transparent)')) - .toBe('1px solid rgba(0, 188, 254, 0.5)') - }) -}) describe('stripAtSupports', () => { it('strips @supports blocks with nested braces', () => { @@ -91,107 +50,10 @@ describe('build-time inline style var resolution', () => { }) }) -describe('linearGradientToSvg', () => { - it('converts simple top-to-bottom gradient', () => { - const svg = linearGradientToSvg('linear-gradient(#fff, #000)') - expect(svg).toContain(' { - const svg = linearGradientToSvg('linear-gradient(90deg, transparent 5%, #3b82f6 35%, #3b82f6 65%, transparent 95%)') - expect(svg).toContain(' { - expect(linearGradientToSvg('#fff')).toBeNull() - }) -}) - -describe('radialGradientToSvg', () => { - it('converts elliptical gradient with position', () => { - const svg = radialGradientToSvg('radial-gradient(70% 60% at 30% 40%, #252525 0%, #101010 100%)') - expect(svg).toContain(' { - const svg = radialGradientToSvg('radial-gradient(#fff, #000)') - expect(svg).toContain(' { - const svg = radialGradientToSvg('radial-gradient(circle at 50% 50%, #fff, #000)') - expect(svg).toContain(' { - const svg = radialGradientToSvg('radial-gradient(rgba(0,0,0,0.5) 0%, rgba(255,255,255,0.8) 100%)') - expect(svg).toContain('stop-opacity="0.5"') - expect(svg).toContain('stop-opacity="0.8"') - }) - - it('includes background color rect when provided', () => { - const svg = radialGradientToSvg('radial-gradient(#fff, #000)', '#ff0000') - expect(svg).toContain('fill="#ff0000"') - }) - - it('returns null for non-gradient', () => { - expect(radialGradientToSvg('#fff')).toBeNull() - }) -}) - describe('sanitizeTakumiStyles', () => { - it('converts numeric vertical-align to middle', () => { - const node = { style: { verticalAlign: '-0.1em' } } - sanitizeTakumiStyles(node) - expect(node.style.verticalAlign).toBe('middle') - }) - - it('preserves keyword vertical-align values', () => { - for (const v of ['baseline', 'top', 'middle', 'bottom', 'text-top', 'text-bottom', 'sub', 'super']) { - const node = { style: { verticalAlign: v } } - sanitizeTakumiStyles(node) - expect(node.style.verticalAlign).toBe(v) - } - }) - - it('sanitizes vertical-align in nested children', () => { - const node = { - style: {}, - children: [{ style: { verticalAlign: '-0.1em' } }], - } - sanitizeTakumiStyles(node) - expect(node.children[0].style.verticalAlign).toBe('middle') - }) - it('strips unresolved var() references', () => { const node = { style: { color: 'var(--missing)' } } sanitizeTakumiStyles(node) expect(node.style.color).toBeUndefined() }) - - it('resolves color-mix to rgba', () => { - const node = { style: { color: 'color-mix(in oklab, #00bcfe 50%, transparent)' } } - sanitizeTakumiStyles(node) - expect(node.style.color).toBe('rgba(0, 188, 254, 0.5)') - }) - - it('strips modern color functions', () => { - const node = { style: { color: 'oklch(0.7 0.1 200)' } } - sanitizeTakumiStyles(node) - expect(node.style.color).toBeUndefined() - }) }) diff --git a/test/unit/takumiNodes.test.ts b/test/unit/takumiNodes.test.ts index fa53cfa7b..1939d7db1 100644 --- a/test/unit/takumiNodes.test.ts +++ b/test/unit/takumiNodes.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it } from 'vitest' -import { resolveUnsupportedUnits } from '../../src/runtime/server/og-image/utils/css' // Test style parsing logic in isolation (same logic used in takumi/nodes.ts) function parseStyleAttr(style: string | null): Record | undefined { @@ -97,71 +96,6 @@ describe('takumiNodes', () => { }) }) - describe('resolveUnsupportedUnits', () => { - it('converts cqw to pixels using container width', () => { - expect(resolveUnsupportedUnits('50cqw', 1200, 630)).toBe('600px') - }) - - it('converts cqh to pixels using container height', () => { - expect(resolveUnsupportedUnits('50cqh', 1200, 630)).toBe('315px') - }) - - it('uses default 1200x630 when no dimensions provided', () => { - expect(resolveUnsupportedUnits('100cqw')).toBe('1200px') - expect(resolveUnsupportedUnits('100cqh')).toBe('630px') - }) - - it('handles cqi (inline) as width-relative', () => { - expect(resolveUnsupportedUnits('50cqi', 800, 400)).toBe('400px') - }) - - it('handles cqb (block) as height-relative', () => { - expect(resolveUnsupportedUnits('50cqb', 800, 400)).toBe('200px') - }) - - it('handles cqmin using smaller dimension', () => { - expect(resolveUnsupportedUnits('100cqmin', 1200, 630)).toBe('630px') - }) - - it('handles cqmax using larger dimension', () => { - expect(resolveUnsupportedUnits('100cqmax', 1200, 630)).toBe('1200px') - }) - - it('converts dynamic viewport units (dvw, dvh)', () => { - expect(resolveUnsupportedUnits('50dvw', 1200, 630)).toBe('600px') - expect(resolveUnsupportedUnits('50dvh', 1200, 630)).toBe('315px') - }) - - it('converts small viewport units (svw, svh)', () => { - expect(resolveUnsupportedUnits('25svw', 1200, 630)).toBe('300px') - expect(resolveUnsupportedUnits('25svh', 1200, 630)).toBe('157.5px') - }) - - it('converts large viewport units (lvw, lvh)', () => { - expect(resolveUnsupportedUnits('10lvw', 1200, 630)).toBe('120px') - expect(resolveUnsupportedUnits('10lvh', 1200, 630)).toBe('63px') - }) - - it('handles negative values', () => { - expect(resolveUnsupportedUnits('-10cqw', 1200, 630)).toBe('-120px') - }) - - it('handles decimal values', () => { - expect(resolveUnsupportedUnits('33.33cqw', 1200, 630)).toBe('399.96px') - }) - - it('passes through regular CSS values unchanged', () => { - expect(resolveUnsupportedUnits('100px')).toBe('100px') - expect(resolveUnsupportedUnits('50%')).toBe('50%') - expect(resolveUnsupportedUnits('2rem')).toBe('2rem') - expect(resolveUnsupportedUnits('red')).toBe('red') - }) - - it('handles mixed values with unsupported units', () => { - expect(resolveUnsupportedUnits('calc(50cqw + 10px)', 1200, 630)).toBe('calc(600px + 10px)') - }) - }) - describe('camelCase', () => { it('converts basic kebab case', () => { expect(camelCase('font-size')).toBe('fontSize') @@ -202,12 +136,5 @@ describe('takumiNodes', () => { // No "font" shorthand key should appear expect(parsed!.font).toBeUndefined() }) - - it('resolveUnsupportedUnits preserves rem values (takumi handles rem natively)', () => { - // Verify rem values pass through to takumi renderer - expect(resolveUnsupportedUnits('6rem')).toBe('6rem') - expect(resolveUnsupportedUnits('1rem')).toBe('1rem') - expect(resolveUnsupportedUnits('-.05em')).toBe('-.05em') - }) }) }) From 678d16d2ec3d1f1e2c7b356e1e29d876f0488136 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Sat, 28 Feb 2026 20:26:41 +0800 Subject: [PATCH 3/4] lint --- src/runtime/server/og-image/takumi/nodes.ts | 2 +- src/runtime/server/og-image/takumi/renderer.ts | 12 ++++++------ src/runtime/server/og-image/takumi/sanitize.ts | 6 +----- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/runtime/server/og-image/takumi/nodes.ts b/src/runtime/server/og-image/takumi/nodes.ts index 6b53e81ab..1f7bba0c2 100644 --- a/src/runtime/server/og-image/takumi/nodes.ts +++ b/src/runtime/server/og-image/takumi/nodes.ts @@ -18,7 +18,7 @@ export async function createTakumiNodes(ctx: OgImageRenderEventContext): Promise } async function vnodeToTakumiNode(vnode: VNode): Promise { - let { style, children, class: cls, tw, src, width, height, ...rest } = vnode.props + const { style, children, class: cls, tw, src, width, height, ...rest } = vnode.props // SVG elements → convert to SVG to string if (vnode.type === 'svg') { diff --git a/src/runtime/server/og-image/takumi/renderer.ts b/src/runtime/server/og-image/takumi/renderer.ts index 616aed59d..95abbd15e 100644 --- a/src/runtime/server/og-image/takumi/renderer.ts +++ b/src/runtime/server/og-image/takumi/renderer.ts @@ -1,6 +1,6 @@ import type { FontConfig, OgImageRenderEventContext, Renderer } from '../../../types' +import { getNitroOrigin } from '#imports' import resolvedFonts from '#og-image/fonts' -import { useNitroOrigin } from '#site-config/server/composables/useNitroOrigin' import { defu } from 'defu' import { withBase } from 'ufo' import { logger } from '../../../logger' @@ -193,7 +193,7 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp const { dataUris, bgUrls } = extractInlineResources(nodes) const allUrls = new Set([...resourceUrls, ...bgUrls]) - const origin = useNitroOrigin(event.e) + const origin = getNitroOrigin(event.e) const baseURL = event.runtimeConfig.app.baseURL const resourceMap = new Map() @@ -253,14 +253,14 @@ async function createImage(event: OgImageRenderEventContext, format: 'png' | 'jp // @takumi-rs/wasm path if ('asUint8Array' in result) { - const buffer = result.asUint8Array().slice(); + const buffer = result.asUint8Array().slice() - result.free(); + result.free() - return buffer; + return buffer } - return result; + return result } const TakumiRenderer: Renderer = { diff --git a/src/runtime/server/og-image/takumi/sanitize.ts b/src/runtime/server/og-image/takumi/sanitize.ts index ddee1a19d..e10dbef22 100644 --- a/src/runtime/server/og-image/takumi/sanitize.ts +++ b/src/runtime/server/og-image/takumi/sanitize.ts @@ -2,13 +2,9 @@ export function sanitizeTakumiStyles(node: any) { if (node.style) { for (const prop of Object.keys(node.style)) { const value = node.style[prop] - if (typeof value !== 'string') - continue - let v = value // Strip properties with unresolved var() references - if (v.includes('var(')) { + if (typeof value === 'string' && value.includes('var(')) { delete node.style[prop] - continue } } } From e1037d56b9f84da09dc2bd12f070199a220a2de4 Mon Sep 17 00:00:00 2001 From: Kane Wang Date: Sat, 28 Feb 2026 20:54:13 +0800 Subject: [PATCH 4/4] fix: bring back `resolveColorMix` for satori --- src/runtime/server/og-image/utils/css.ts | 55 ++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/runtime/server/og-image/utils/css.ts b/src/runtime/server/og-image/utils/css.ts index c6ac9f164..0d88fd2d6 100644 --- a/src/runtime/server/og-image/utils/css.ts +++ b/src/runtime/server/og-image/utils/css.ts @@ -51,3 +51,58 @@ export function stripGradientColorSpace(value: string): string { return value return value.replace(GRADIENT_COLOR_SPACE_RE, '') } + +/** + * Resolve `color-mix()` to rgba for renderers that don't support it. + * Handles the common TW4 pattern: `color-mix(in , , transparent)` + */ +const COLOR_MIX_HEX_RE = /color-mix\(in\s+\w+,\s*#([0-9a-fA-F]{3,8})\s+([\d.]+%?)\s*,\s*transparent\s*\)/g +// eslint-disable-next-line no-control-regex +const COLOR_MIX_RGBA_RE = /color-mix\(in\s+\w+,\s*rgba?\((\d+)[\x09-\x0D\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?: |(?: \s*)?,)\s*(\d+)[\x09-\x0D\xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?: |(?: \s*)?,)\s*(\d+)(?:\s*[/,]\s*[\d.]+)?\)\s+([\d.]+%?)\s*,\s*transparent\s*\)/g + +function hexToRgb(hex: string): [number, number, number] | null { + if (hex.length === 3) + hex = hex[0]! + hex[0]! + hex[1]! + hex[1]! + hex[2]! + hex[2]! + if (hex.length !== 6 && hex.length !== 8) + return null + const r = Number.parseInt(hex.slice(0, 2), 16) + const g = Number.parseInt(hex.slice(2, 4), 16) + const b = Number.parseInt(hex.slice(4, 6), 16) + if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) + return null + return [r, g, b] +} + +function parseColorMixAmount(amount: string): number { + let opacity = Number.parseFloat(amount) + if (Number.isNaN(opacity)) + return Number.NaN + if (amount.endsWith('%')) + opacity /= 100 + else if (opacity > 1) + opacity /= 100 + return opacity +} + +export function resolveColorMix(value: string): string { + if (!value.includes('color-mix(')) + return value + // Handle hex colors: color-mix(in oklab, #00bcfe .14, transparent) + let result = value.replace(COLOR_MIX_HEX_RE, (_match, hex: string, amount: string) => { + const rgb = hexToRgb(hex) + if (!rgb) + return _match + const opacity = parseColorMixAmount(amount) + if (Number.isNaN(opacity)) + return _match + return `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${opacity})` + }) + // Handle rgb/rgba colors: color-mix(in oklab, rgb(0, 188, 254) 14%, transparent) + result = result.replace(COLOR_MIX_RGBA_RE, (_match, r: string, g: string, b: string, amount: string) => { + const opacity = parseColorMixAmount(amount) + if (Number.isNaN(opacity)) + return _match + return `rgba(${r}, ${g}, ${b}, ${opacity})` + }) + return result +}