mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-31 10:47:38 +00:00
173 lines
6.7 KiB
TypeScript
173 lines
6.7 KiB
TypeScript
/**
|
||
* Shared utilities for exporting dashboard/report charts via html2canvas.
|
||
*
|
||
* Problem: html2canvas serializes SVG elements to a data URL before drawing
|
||
* them on canvas. Inside that data URL the parent document's CSS is not
|
||
* available, so `fill="var(--primary)"` renders as nothing / black.
|
||
* Additionally, html2canvas's internal CSS parser cannot handle oklch() color
|
||
* values used by Tailwind v4.
|
||
*
|
||
* Solution:
|
||
* 1. Patch oklch() in <style> tags and inject :root overrides so html2canvas's
|
||
* CSS parser sees rgb() values everywhere.
|
||
* 2. Resolve CSS variable references in SVG presentation attributes
|
||
* (fill, stroke, stop-color) to their computed rgb() values before capture,
|
||
* then restore them afterward.
|
||
*/
|
||
|
||
// ── Oklch → RGB conversion ────────────────────────────────────────────────────
|
||
|
||
export function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
|
||
const hr = (h * Math.PI) / 180;
|
||
const a = c * Math.cos(hr);
|
||
const b = c * Math.sin(hr);
|
||
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.2914855480 * b;
|
||
const lc = l_ ** 3, mc = m_ ** 3, sc = s_ ** 3;
|
||
const gam = (x: number) =>
|
||
x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
|
||
const clamp = (x: number) => Math.max(0, Math.min(255, Math.round(x * 255)));
|
||
return [
|
||
clamp(gam(+4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc)),
|
||
clamp(gam(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc)),
|
||
clamp(gam(-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc)),
|
||
];
|
||
}
|
||
|
||
export const OKLCH_RE = /oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)[^)]*\)/g;
|
||
|
||
export function oklchMatchToRgb(_: string, l: string, c: string, h: string): string {
|
||
const lv = l.endsWith("%") ? parseFloat(l) / 100 : parseFloat(l);
|
||
const [r, g, b] = oklchToRgb(lv, parseFloat(c), parseFloat(h));
|
||
return `rgb(${r},${g},${b})`;
|
||
}
|
||
|
||
// ── CSS oklch patching ────────────────────────────────────────────────────────
|
||
|
||
function collectOklchOverrides(
|
||
rules: CSSRuleList,
|
||
overrides: Map<string, string>,
|
||
): void {
|
||
for (const rule of Array.from(rules)) {
|
||
if (rule instanceof CSSStyleRule) {
|
||
for (let i = 0; i < rule.style.length; i++) {
|
||
const prop = rule.style[i];
|
||
const val = rule.style.getPropertyValue(prop).trim();
|
||
if (!val.includes("oklch") || overrides.has(prop)) continue;
|
||
const m = val.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/);
|
||
if (m) {
|
||
const lv = m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
|
||
const [r, g, b] = oklchToRgb(lv, parseFloat(m[2]), parseFloat(m[3]));
|
||
overrides.set(prop, `rgb(${r},${g},${b})`);
|
||
}
|
||
}
|
||
}
|
||
if ("cssRules" in rule && (rule as CSSGroupingRule).cssRules) {
|
||
collectOklchOverrides((rule as CSSGroupingRule).cssRules, overrides);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Patches oklch() values in inline <style> tags and injects a :root override
|
||
* block for external stylesheets so html2canvas's CSS parser sees only rgb().
|
||
* Returns a `restore()` function that undoes all patches.
|
||
*/
|
||
export function patchDocumentOklch(): { restore: () => void } {
|
||
const patched = new Map<HTMLStyleElement, string>();
|
||
for (const el of Array.from(document.querySelectorAll("style"))) {
|
||
const s = el as HTMLStyleElement;
|
||
if (s.textContent?.includes("oklch")) {
|
||
patched.set(s, s.textContent);
|
||
s.textContent = s.textContent.replace(OKLCH_RE, oklchMatchToRgb);
|
||
}
|
||
}
|
||
|
||
const overrideVars = new Map<string, string>();
|
||
for (const sheet of Array.from(document.styleSheets)) {
|
||
try { collectOklchOverrides(sheet.cssRules, overrideVars); } catch {}
|
||
}
|
||
|
||
let varOverride: HTMLStyleElement | null = null;
|
||
if (overrideVars.size > 0) {
|
||
varOverride = document.createElement("style");
|
||
varOverride.textContent = `:root { ${
|
||
Array.from(overrideVars.entries())
|
||
.map(([p, v]) => `${p}: ${v}`)
|
||
.join("; ")
|
||
} }`;
|
||
document.head.appendChild(varOverride);
|
||
}
|
||
|
||
return {
|
||
restore() {
|
||
for (const [s, orig] of patched.entries()) s.textContent = orig;
|
||
varOverride?.remove();
|
||
},
|
||
};
|
||
}
|
||
|
||
// ── SVG presentation attribute inlining ──────────────────────────────────────
|
||
|
||
const SVG_PRES_ATTRS = ["fill", "stroke", "stop-color"] as const;
|
||
|
||
/**
|
||
* Walks every SVG descendant of `root` and replaces any presentation attribute
|
||
* that contains a CSS variable (`var(…)`) with its computed rgb() value.
|
||
*
|
||
* This must be called AFTER patchDocumentOklch() so that getComputedStyle
|
||
* already returns rgb values rather than oklch.
|
||
*
|
||
* Returns a `restore()` function that reverts all attribute changes.
|
||
* For off-screen clones that are discarded after capture, calling restore()
|
||
* is optional.
|
||
*/
|
||
export function inlineSvgPresentationAttrs(
|
||
root: Element,
|
||
): { restore: () => void } {
|
||
const patches: Array<{ el: SVGElement; attr: string; orig: string }> = [];
|
||
|
||
for (const node of Array.from(root.querySelectorAll("*"))) {
|
||
if (!(node instanceof SVGElement)) continue;
|
||
const cs = window.getComputedStyle(node);
|
||
for (const attr of SVG_PRES_ATTRS) {
|
||
const val = node.getAttribute(attr);
|
||
if (!val || !val.includes("var(")) continue;
|
||
const resolved = cs.getPropertyValue(attr).trim().replace(OKLCH_RE, oklchMatchToRgb);
|
||
if (!resolved || resolved === val) continue;
|
||
patches.push({ el: node, attr, orig: val });
|
||
node.setAttribute(attr, resolved);
|
||
}
|
||
}
|
||
|
||
return {
|
||
restore() {
|
||
for (const { el, attr, orig } of patches) el.setAttribute(attr, orig);
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Convenience wrapper that runs the full pre-capture patch sequence on a DOM
|
||
* element:
|
||
* 1. patchDocumentOklch – fixes oklch in <style>/<link> CSS
|
||
* 2. inlineSvgPresentationAttrs – resolves var() in SVG fill/stroke attrs
|
||
*
|
||
* Returns a single `restore()` that undoes both steps in reverse order.
|
||
*
|
||
* Usage:
|
||
* const patch = prepareForHtml2Canvas(element);
|
||
* try { await html2canvas(element, …); } finally { patch.restore(); }
|
||
*/
|
||
export function prepareForHtml2Canvas(root: Element): { restore: () => void } {
|
||
const oklch = patchDocumentOklch();
|
||
const svg = inlineSvgPresentationAttrs(root);
|
||
return {
|
||
restore() {
|
||
svg.restore();
|
||
oklch.restore();
|
||
},
|
||
};
|
||
}
|