From f18994fd38d7c27ae1d84700eb982c18c025a28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sun, 30 Aug 2026 09:25:09 +0800 Subject: [PATCH] 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 --- frontend-web/scripts/check-chrome109-css.mjs | 5 + .../src/components/html-preview-frame.tsx | 4 +- .../artifacts/artifact-file-detail.tsx | 38 +- .../core/utils/legacy-html-preview.test.ts | 48 ++ .../src/core/utils/legacy-html-preview.ts | 491 ++++++++++++++++++ 5 files changed, 566 insertions(+), 20 deletions(-) create mode 100644 frontend-web/src/core/utils/legacy-html-preview.test.ts create mode 100644 frontend-web/src/core/utils/legacy-html-preview.ts diff --git a/frontend-web/scripts/check-chrome109-css.mjs b/frontend-web/scripts/check-chrome109-css.mjs index 2f32b8ed..1c779411 100644 --- a/frontend-web/scripts/check-chrome109-css.mjs +++ b/frontend-web/scripts/check-chrome109-css.mjs @@ -36,6 +36,11 @@ const requiredLegacyFallbacks = [ pattern: /htmlLabels:\s*false[\s\S]*secure:\s*\[[\s\S]*"htmlLabels"/, 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) { diff --git a/frontend-web/src/components/html-preview-frame.tsx b/frontend-web/src/components/html-preview-frame.tsx index 2030e398..466987ce 100644 --- a/frontend-web/src/components/html-preview-frame.tsx +++ b/frontend-web/src/components/html-preview-frame.tsx @@ -1,6 +1,6 @@ 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. @@ -26,7 +26,7 @@ export function HtmlPreviewFrame({ const [url, setUrl] = useState(); useEffect(() => { - const blob = new Blob([injectInPageAnchorHandler(html ?? "")], { + const blob = new Blob([prepareGeneratedHtml(html ?? "")], { type: "text/html;charset=utf-8", }); const objectUrl = URL.createObjectURL(blob); diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index 5de1b271..b876ee17 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -97,9 +97,9 @@ import { openMermaidSvgInNewTab, renderMermaidToSvg, } from "@/core/utils/mermaid"; +import { prepareGeneratedHtml } from "@/core/utils/legacy-html-preview"; import { createMarkdownAnchorResolver, - injectInPageAnchorHandler, isInPageAnchor, scrollToInPageAnchor, } from "@/core/utils/markdown"; @@ -1422,7 +1422,9 @@ export function ArtifactFileDetail({ toast.error("文件内容为空,暂时无法在新窗口打开"); 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); window.open(url, "_blank", "noopener,noreferrer"); window.setTimeout(() => URL.revokeObjectURL(url), 60_000); @@ -1460,7 +1462,7 @@ export function ArtifactFileDetail({ return ( - +
)}
-
- +
+ {canPublishToKnowledge && ( - -
- {/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的 - 动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */} - (onClose ? onClose() : setOpen(false))} - tooltip="收起文件面板" - /> - +
+
+ {/* 文件目录会压缩详情区宽度。关闭入口固定在右上角,不再跟随可换行的 + 动作组,避免工具较多或面板较窄时最右侧按钮被裁掉。 */} + (onClose ? onClose() : setOpen(false))} + tooltip="收起文件面板" + /> + 就会中文乱码。与下方下载/新窗口打开的 blobType 保持一致。 // Intercept in-page anchor clicks so they scroll within the iframe instead // 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", }); const url = URL.createObjectURL(blob); diff --git a/frontend-web/src/core/utils/legacy-html-preview.test.ts b/frontend-web/src/core/utils/legacy-html-preview.test.ts new file mode 100644 index 00000000..1a4a8757 --- /dev/null +++ b/frontend-web/src/core/utils/legacy-html-preview.test.ts @@ -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 = ``; + 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 = ``; + assert.equal(downlevelModernColors(html), html); +}); + +test("prepareGeneratedHtml injects the ES5 chart size shim and anchor handler", () => { + const 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, /\?\.|=>|`/); +}); diff --git a/frontend-web/src/core/utils/legacy-html-preview.ts b/frontend-web/src/core/utils/legacy-html-preview.ts new file mode 100644 index 00000000..bcdf8089 --- /dev/null +++ b/frontend-web/src/core/utils/legacy-html-preview.ts @@ -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 = ``; + if (/<\/body>/i.test(html)) { + return html.replace(/<\/body>/i, `${script}`); + } + 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 = { + 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], +};