diff --git a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx index 726f6672660..a2a9254512f 100644 --- a/mobile/src/components/pr-sidebar/MermaidDiagram.tsx +++ b/mobile/src/components/pr-sidebar/MermaidDiagram.tsx @@ -1,6 +1,7 @@ import { memo, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { ScrollView, StyleSheet, Text, View } from 'react-native' import { colors, radii, spacing, typography } from '../../theme/mobile-theme' +import { readableMermaidFlowchartSvg } from '../../../../src/shared/mermaid-flowchart-contrast' import { MERMAID_DIAGRAM_CONFIG } from './mermaid-diagram-config' import { loadPageMermaid } from './mermaid-page-engine' @@ -37,7 +38,7 @@ export const MermaidDiagram = memo(function MermaidDiagram({ source, base }: Mer return } // Only mermaid sanitizes its serialization; strict mode already made returned SVG safe to parse. - host.innerHTML = svg + host.innerHTML = readableMermaidFlowchartSvg(svg, colors.textPrimary, colors.bgRaised) } catch { if (!disposed) { setFailedSource(source) diff --git a/src/renderer/src/components/editor/MermaidBlock.tsx b/src/renderer/src/components/editor/MermaidBlock.tsx index 9015dfefb7a..4315bbe10a1 100644 --- a/src/renderer/src/components/editor/MermaidBlock.tsx +++ b/src/renderer/src/components/editor/MermaidBlock.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useId, useRef, useState } from 'react' import { Maximize2 } from 'lucide-react' import type mermaidNamespace from 'mermaid' import DOMPurify from 'dompurify' +import { readableMermaidFlowchartSvg } from '../../../../shared/mermaid-flowchart-contrast' import { getMermaidConfig } from './mermaid-config' import { readMermaidThemeColors } from './mermaid-theme-colors' import { translate } from '@/i18n/i18n' @@ -82,10 +83,13 @@ export default function MermaidBlock({ const { svg } = await mermaid.render(`mermaid-${id}`, content) if (!cancelled) { // Sanitize explicitly against XSS even if Mermaid’s internal sanitization changes. + const sanitized = DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }) setRendered({ content, result: { - svg: DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true } }), + svg: colors + ? readableMermaidFlowchartSvg(sanitized, colors.foreground, colors.background) + : sanitized, background: colors?.background } }) diff --git a/src/shared/mermaid-flowchart-contrast.test.ts b/src/shared/mermaid-flowchart-contrast.test.ts new file mode 100644 index 00000000000..6f10748cab6 --- /dev/null +++ b/src/shared/mermaid-flowchart-contrast.test.ts @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { readableMermaidFlowchartSvg } from './mermaid-flowchart-contrast' + +function flowchart(fill: string, label: string): string { + return ( + '' + + `` + + `Node` + + '' + ) +} + +function labelFill(svg: string): string { + const host = document.createElement('div') + host.innerHTML = svg + return host.querySelector('text')!.style.getPropertyValue('fill') +} + +describe('readableMermaidFlowchartSvg', () => { + it('recolours a light theme label on a pale custom fill', () => { + const svg = readableMermaidFlowchartSvg(flowchart('#fde68a', '#e0e0e0'), '#e0e0e0', '#111111') + expect(labelFill(svg)).toBe('#111111') + }) + + it('reads the painted path when the label container is a group', () => { + const stadium = flowchart('#1e3a8a', '#e0e0e0').replace( + '', + // The group inherits the diagram's text colour; only its path is painted. + '' + ) + expect(labelFill(readableMermaidFlowchartSvg(stadium, '#e0e0e0', '#111111'))).toBe('#e0e0e0') + }) + + it('leaves a label that already reads on its fill', () => { + const svg = readableMermaidFlowchartSvg(flowchart('#1e3a8a', '#e0e0e0'), '#e0e0e0', '#111111') + expect(labelFill(svg)).toBe('#e0e0e0') + }) +}) diff --git a/src/shared/mermaid-flowchart-contrast.ts b/src/shared/mermaid-flowchart-contrast.ts new file mode 100644 index 00000000000..3b2662d2c5a --- /dev/null +++ b/src/shared/mermaid-flowchart-contrast.ts @@ -0,0 +1,99 @@ +const READABLE_CONTRAST = 4.5 + +/** WCAG relative luminance of a `#rrggbb` or opaque `rgb()` colour; null for anything else. */ +function luminance(color: string): number | null { + const hex = /^#([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(color) + const rgb = /^rgba?\(([^)]+)\)$/i.exec(color) + const channels = hex + ? hex.slice(1).map((channel) => Number.parseInt(channel, 16)) + : rgb + ? rgb[1].split(/[\s,/]+/).map(Number) + : null + if (!channels || (channels.length === 4 && channels.pop() !== 1) || channels.length !== 3) { + return null + } + const [r, g, b] = channels.map((channel) => { + const value = channel / 255 + return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4 + }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b +} + +function contrast(a: number, b: number): number { + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +/** + * Mermaid draws every flowchart label in the theme's text colour, so a custom fill from `style` + * or `classDef` can hide it (light text on a pale node in dark mode). Recolours those labels to + * whichever of `foreground` and `background` reads better on the fill. Browser-only: the fill + * comes from computed styles, so `svg` is parsed into the document and must already be safe + * (sanitized, or from mermaid's strict mode). + */ +export function readableMermaidFlowchartSvg( + svg: string, + foreground: string, + background: string +): string { + const fgLum = luminance(foreground) + const bgLum = luminance(background) + if (!svg.includes('flowchart') || fgLum === null || bgLum === null) { + return svg + } + const host = document.createElement('div') + host.style.display = 'none' + host.innerHTML = svg + document.body.append(host) + try { + const nodes = host.querySelectorAll( + 'svg[aria-roledescription^="flowchart"] .node, svg[aria-roledescription^="flowchart"] .cluster' + ) + for (const node of nodes) { + // Only a node's label container sits behind its label (a brace draws beside it); stadium and + // hand-drawn shapes make it a group around the painted shapes. A subgraph is a plain rect. + const shape = node.classList.contains('cluster') + ? node.querySelector('rect') + : node.querySelector('.label-container:not(g), .label-container :not(g)') + if (!shape) { + continue + } + const shapeStyle = getComputedStyle(shape) + const fill = luminance(shapeStyle.fill) + // A translucent fill blends with whatever is behind it, so its colour is unknown. + if ( + fill === null || + Number.parseFloat(shapeStyle.fillOpacity) < 1 || + Number.parseFloat(shapeStyle.opacity) < 1 + ) { + continue + } + const fgRatio = contrast(fgLum, fill) + const bgRatio = contrast(bgLum, fill) + const best = + fgRatio >= bgRatio + ? { color: foreground, ratio: fgRatio } + : { color: background, ratio: bgRatio } + for (const label of node.querySelectorAll( + 'text, tspan, .nodeLabel, .nodeLabel *' + )) { + if (label.closest('.node, .cluster') !== node) { + continue + } + const svgText = label.localName === 'text' || label.localName === 'tspan' + const labelStyle = getComputedStyle(label) + // HTML that paints its own background (``) was styled against that, not the fill. + if (!svgText && luminance(labelStyle.backgroundColor) !== null) { + continue + } + const current = luminance(svgText ? labelStyle.fill : labelStyle.color) + const ratio = current === null ? 0 : contrast(current, fill) + if (ratio < READABLE_CONTRAST && best.ratio > ratio) { + label.style.setProperty(svgText ? 'fill' : 'color', best.color, 'important') + } + } + } + return host.innerHTML + } finally { + host.remove() + } +}