From 7f001059ba0f9d9ef656cf28aca55ee721fd7029 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 29 Mar 2026 18:46:46 +0300 Subject: [PATCH] Add document color space support --- packages/core/src/clipboard.ts | 2 +- packages/core/src/color-management.ts | 170 ++++++++++++++++++ packages/core/src/editor/color-space.ts | 97 ++++++++++ packages/core/src/editor/create.ts | 4 + packages/core/src/index.ts | 15 +- packages/core/src/io/formats.ts | 9 +- packages/core/src/io/formats/fig/export.ts | 2 +- packages/core/src/io/formats/raster/render.ts | 2 + packages/core/src/io/formats/svg/defs.ts | 25 ++- packages/core/src/io/formats/svg/export.ts | 6 +- packages/core/src/io/subgraph.ts | 1 + packages/core/src/io/types.ts | 4 + packages/core/src/kiwi/codec.ts | 1 + packages/core/src/kiwi/fig-import.ts | 6 + packages/core/src/kiwi/serialize.ts | 7 +- packages/core/src/renderer/fills.ts | 26 ++- packages/core/src/renderer/labels.ts | 9 +- packages/core/src/renderer/renderer.ts | 61 +++++-- packages/core/src/renderer/text.ts | 3 +- packages/core/src/scene-graph.ts | 3 + packages/vue/src/shared/useCanvas.ts | 14 +- 21 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/color-management.ts create mode 100644 packages/core/src/editor/color-space.ts diff --git a/packages/core/src/clipboard.ts b/packages/core/src/clipboard.ts index 026e33493..1e5aec126 100644 --- a/packages/core/src/clipboard.ts +++ b/packages/core/src/clipboard.ts @@ -294,7 +294,7 @@ export function buildFigmaClipboardHTML(nodes: SceneNode[], graph: SceneGraph): const localIdCounter = { value: 100 } const nodeChanges: KiwiNodeChange[] = [ - makeDocumentNodeChange(docGuid), + makeDocumentNodeChange(docGuid, graph.documentColorSpace), makeCanvasNodeChange(canvasGuid, docGuid, '!', 'Page 1') ] diff --git a/packages/core/src/color-management.ts b/packages/core/src/color-management.ts new file mode 100644 index 000000000..af0aaff63 --- /dev/null +++ b/packages/core/src/color-management.ts @@ -0,0 +1,170 @@ +import { converter, formatCss, formatRgb, inGamut, toGamut } from 'culori' + +import { normalizeColor } from './color' +import { getFillOkHCL, getStrokeOkHCL, type OkHCLColor } from './okhcl' + +import type { DocumentColorSpace, Fill, SceneNode, Stroke } from './scene-graph' +import type { Color } from './types' + +export type RenderColorSpace = 'srgb' | 'display-p3' +export type ColorIntentSpace = 'oklch' | 'srgb' + +export interface ColorPreviewOptions { + colorSpace?: RenderColorSpace + documentColorSpace?: DocumentColorSpace +} + +export interface ResolvedRenderColor { + color: Color + cssColor: string + sourceSpace: ColorIntentSpace + targetSpace: RenderColorSpace + clipped: boolean +} + +const DEFAULT_COLOR_SPACE: RenderColorSpace = 'display-p3' + +const toRgb = converter('rgb') +const toP3 = converter('p3') +const toDisplayableRgb = toGamut('rgb', 'oklch') +const toDisplayableP3 = toGamut('p3', 'oklch') +const isDisplayableRgb = inGamut('rgb') +const isDisplayableP3 = inGamut('p3') + +function normalizeOkLCH(color: OkHCLColor) { + const hue = color.h % 360 + return { + mode: 'oklch' as const, + l: Math.max(0, Math.min(1, color.l)), + c: Math.max(0, color.c), + h: hue < 0 ? hue + 360 : hue, + alpha: Math.max(0, Math.min(1, color.a ?? 1)) + } +} + +function resolveTargetSpace(options?: ColorPreviewOptions): RenderColorSpace { + return options?.colorSpace ?? options?.documentColorSpace ?? DEFAULT_COLOR_SPACE +} + +function formatCssForTarget(color: Color, targetSpace: RenderColorSpace): string { + if (targetSpace === 'display-p3') { + const p3 = toP3({ mode: 'rgb', r: color.r, g: color.g, b: color.b, alpha: color.a }) + return formatCss({ + mode: 'p3', + r: p3.r, + g: p3.g, + b: p3.b, + alpha: p3.alpha ?? color.a + }) + } + + return formatRgb({ + mode: 'rgb', + r: color.r, + g: color.g, + b: color.b, + alpha: color.a + }) +} + +export function resolveOkHCLForPreview( + color: OkHCLColor, + options?: ColorPreviewOptions +): ResolvedRenderColor { + const oklch = normalizeOkLCH(color) + const targetSpace = resolveTargetSpace(options) + + if (targetSpace === 'display-p3') { + const clipped = !isDisplayableP3(oklch) + const p3 = toP3(toDisplayableP3(oklch)) + const rgb = toRgb({ mode: 'p3', r: p3.r, g: p3.g, b: p3.b, alpha: p3.alpha ?? oklch.alpha }) + const resolved = normalizeColor({ + r: rgb.r, + g: rgb.g, + b: rgb.b, + a: rgb.alpha ?? oklch.alpha + }) + + return { + color: resolved, + cssColor: formatCss({ + mode: 'p3', + r: p3.r, + g: p3.g, + b: p3.b, + alpha: p3.alpha ?? oklch.alpha + }), + sourceSpace: 'oklch', + targetSpace, + clipped + } + } + + const clipped = !isDisplayableRgb(oklch) + const rgb = toRgb(toDisplayableRgb(oklch)) + const resolved = normalizeColor({ + r: rgb.r, + g: rgb.g, + b: rgb.b, + a: rgb.alpha ?? oklch.alpha + }) + + return { + color: resolved, + cssColor: formatRgb({ + mode: 'rgb', + r: resolved.r, + g: resolved.g, + b: resolved.b, + alpha: resolved.a + }), + sourceSpace: 'oklch', + targetSpace, + clipped + } +} + +export function resolveRGBAForPreview( + color: Color, + options?: ColorPreviewOptions +): ResolvedRenderColor { + const resolved = normalizeColor(color) + const targetSpace = resolveTargetSpace(options) + return { + color: resolved, + cssColor: formatCssForTarget(resolved, targetSpace), + sourceSpace: 'srgb', + targetSpace, + clipped: false + } +} + +export function resolveNodeFillColor( + fill: Fill, + fillIndex: number, + node: SceneNode, + options?: ColorPreviewOptions +): ResolvedRenderColor { + const okhcl = getFillOkHCL(node, fillIndex)?.color + if (okhcl) return resolveOkHCLForPreview(okhcl, options) + return resolveRGBAForPreview(fill.color, options) +} + +export function resolveNodeStrokeColor( + stroke: Stroke, + strokeIndex: number, + node: SceneNode, + options?: ColorPreviewOptions +): ResolvedRenderColor { + const okhcl = getStrokeOkHCL(node, strokeIndex)?.color + if (okhcl) return resolveOkHCLForPreview(okhcl, options) + return resolveRGBAForPreview(stroke.color, options) +} + +export function colorToDisplayCss(color: Color, options?: ColorPreviewOptions): string { + return resolveRGBAForPreview(color, options).cssColor +} + +export function getDefaultRenderColorSpace(): RenderColorSpace { + return DEFAULT_COLOR_SPACE +} diff --git a/packages/core/src/editor/color-space.ts b/packages/core/src/editor/color-space.ts new file mode 100644 index 000000000..f3734f3ab --- /dev/null +++ b/packages/core/src/editor/color-space.ts @@ -0,0 +1,97 @@ +import { copyEffects, copyFill, copyStyleRuns, copyStroke } from '../copy' +import { resolveOkHCLForPreview } from '../color-management' +import { rgbaToOkHCL } from '../okhcl' + +import type { DocumentColorSpace, SceneNode } from '../scene-graph' +import type { EditorContext } from './types' + +export type DocumentColorProfileMode = 'assign' | 'convert' + +function remapNodeColors( + node: SceneNode, + target: DocumentColorSpace, + mode: DocumentColorProfileMode +): Partial | null { + if (mode === 'assign') return null + + const fills = node.fills.map((fill) => { + const next = copyFill(fill) + if (fill.type === 'SOLID') { + const resolved = resolveOkHCLForPreview(rgbaToOkHCL(fill.color), { + documentColorSpace: target + }).color + next.color = resolved + next.opacity = resolved.a + return next + } + if (fill.gradientStops) { + next.gradientStops = fill.gradientStops.map((stop) => { + const resolved = resolveOkHCLForPreview(rgbaToOkHCL(stop.color), { + documentColorSpace: target + }).color + return { ...stop, color: resolved } + }) + } + return next + }) + + const strokes = node.strokes.map((stroke) => { + const next = copyStroke(stroke) + const resolved = resolveOkHCLForPreview(rgbaToOkHCL(stroke.color), { + documentColorSpace: target + }).color + next.color = resolved + next.opacity = resolved.a + return next + }) + + const effects = copyEffects(node.effects).map((effect) => { + const resolved = resolveOkHCLForPreview(rgbaToOkHCL(effect.color), { + documentColorSpace: target + }).color + return { ...effect, color: resolved } + }) + + const styleRuns = copyStyleRuns(node.styleRuns).map((run) => ({ + ...run, + style: { + ...run.style, + fills: run.style.fills?.map((fill) => { + const next = copyFill(fill) + if (fill.type === 'SOLID') { + const resolved = resolveOkHCLForPreview(rgbaToOkHCL(fill.color), { + documentColorSpace: target + }).color + next.color = resolved + next.opacity = resolved.a + } + return next + }) + } + })) + + return { fills, strokes, effects, styleRuns } +} + +export function createColorSpaceActions(ctx: EditorContext) { + function setDocumentColorSpace( + colorSpace: DocumentColorSpace, + mode: DocumentColorProfileMode = 'assign' + ) { + if (ctx.graph.documentColorSpace === colorSpace) return + + if (mode === 'convert') { + for (const node of ctx.graph.getAllNodes()) { + const changes = remapNodeColors(node, colorSpace, mode) + if (changes) ctx.graph.updateNode(node.id, changes) + } + } + + ctx.graph.documentColorSpace = colorSpace + ctx.requestRender() + } + + return { + setDocumentColorSpace + } +} diff --git a/packages/core/src/editor/create.ts b/packages/core/src/editor/create.ts index b26de891a..174611a7f 100644 --- a/packages/core/src/editor/create.ts +++ b/packages/core/src/editor/create.ts @@ -7,6 +7,7 @@ import { TextEditor } from '../text-editor' import { UndoManager } from '../undo' import { createAlignmentActions } from './alignment' import { createClipboardActions } from './clipboard' +import { createColorSpaceActions } from './color-space' import { createComponentActions } from './components' import { createNodeActions } from './nodes' import { createPageActions } from './pages' @@ -188,6 +189,7 @@ export function createEditor(options?: EditorOptions) { const structure = createStructureActions(ctx) const components = createComponentActions(ctx) const clipboard = createClipboardActions(ctx) + const colorSpace = createColorSpaceActions(ctx) const undoActions = createUndoActions(ctx) const text = createTextActions(ctx) const nodes = createNodeActions(ctx) @@ -276,6 +278,8 @@ export function createEditor(options?: EditorOptions) { restorePageFromSnapshot: undoActions.restorePageFromSnapshot, pushUndoEntry: undoActions.pushUndoEntry, + setDocumentColorSpace: colorSpace.setDocumentColorSpace, + // Clipboard — bridge functions that need selectedNodes duplicateSelected: () => clipboard.duplicateSelected(selection.getSelectedNodes()), writeCopyData: (data: DataTransfer) => diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c0d59a040..ad74a97b4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -68,7 +68,8 @@ export { type VariableCollectionMode, type CharacterStyleOverride, type StyleRun, - type SceneGraphEvents + type SceneGraphEvents, + type DocumentColorSpace } from './scene-graph' export { FigmaAPI, FigmaNodeProxy, computeImageHash, type FigmaFontName } from './figma-api' @@ -184,6 +185,18 @@ export { colorToFill, colorDistance } from './color' +export { + resolveOkHCLForPreview, + resolveRGBAForPreview, + resolveNodeFillColor, + resolveNodeStrokeColor, + colorToDisplayCss, + getDefaultRenderColorSpace, + type RenderColorSpace, + type ColorIntentSpace, + type ColorPreviewOptions, + type ResolvedRenderColor +} from './color-management' export { vectorNetworkToPath, geometryBlobToPath, diff --git a/packages/core/src/io/formats.ts b/packages/core/src/io/formats.ts index 905895785..c9e28cf34 100644 --- a/packages/core/src/io/formats.ts +++ b/packages/core/src/io/formats.ts @@ -113,7 +113,8 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter { }, exportOptions: { scale: true, - quality: format !== 'PNG' + quality: format !== 'PNG', + colorSpace: true }, async exportContent(request, options?: RasterExportOptions, context?: IOContext) { const data = await renderRaster( @@ -121,7 +122,8 @@ function rasterFormat(format: RasterExportFormat): IOFormatAdapter { { format, scale: options?.scale, - quality: options?.quality + quality: options?.quality, + colorSpace: options?.colorSpace }, context ) @@ -233,7 +235,8 @@ export const svgFormat: IOFormatAdapter = { }, exportOptions: { scale: false, - quality: false + quality: false, + colorSpace: true }, async exportContent(request, options?: SVGExportOptions) { const target = resolveExportNodes(request) diff --git a/packages/core/src/io/formats/fig/export.ts b/packages/core/src/io/formats/fig/export.ts index fa58a1bf7..4151170f7 100644 --- a/packages/core/src/io/formats/fig/export.ts +++ b/packages/core/src/io/formats/fig/export.ts @@ -84,7 +84,7 @@ export async function exportFigFile( const docGuid = { sessionID: 0, localID: 0 } const localIdCounter = { value: 2 } - const nodeChanges: KiwiNodeChange[] = [makeDocumentNodeChange(docGuid)] + const nodeChanges: KiwiNodeChange[] = [makeDocumentNodeChange(docGuid, graph.documentColorSpace)] const blobs: Uint8Array[] = [] const pages = graph.getPages(true) diff --git a/packages/core/src/io/formats/raster/render.ts b/packages/core/src/io/formats/raster/render.ts index 13d550af9..b65e9678b 100644 --- a/packages/core/src/io/formats/raster/render.ts +++ b/packages/core/src/io/formats/raster/render.ts @@ -1,3 +1,4 @@ +import type { RenderColorSpace } from '@open-pencil/core/color-management' import type { SkiaRenderer } from '@open-pencil/core/renderer' import type { SceneGraph } from '@open-pencil/core/scene-graph' import type { CanvasKit, Canvas } from 'canvaskit-wasm' @@ -9,6 +10,7 @@ interface RenderOptions { scale: number format: ExportFormat quality?: number + colorSpace?: RenderColorSpace } export function computeContentBounds( diff --git a/packages/core/src/io/formats/svg/defs.ts b/packages/core/src/io/formats/svg/defs.ts index 6ade7031b..2679afc76 100644 --- a/packages/core/src/io/formats/svg/defs.ts +++ b/packages/core/src/io/formats/svg/defs.ts @@ -1,8 +1,10 @@ -import { colorToHex, colorToHex8 } from '@open-pencil/core/color' +import { colorToHex } from '@open-pencil/core/color' +import { colorToDisplayCss, getDefaultRenderColorSpace } from '@open-pencil/core/color-management' import { svg, type SVGNode } from './node' import { round } from './paths' +import type { RenderColorSpace } from '@open-pencil/core/color-management' import type { Effect, Fill, SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' import type { Color } from '@open-pencil/core/types' @@ -10,14 +12,23 @@ export interface SVGExportContext { defs: SVGNode[] defIdCounter: number graph: SceneGraph + colorSpace: RenderColorSpace } export function nextDefId(ctx: SVGExportContext, prefix: string): string { return `${prefix}${ctx.defIdCounter++}` } -export function formatColor(color: Color, opacity = 1): string { - return colorToHex8(color, opacity) +export function formatColor( + color: Color, + opacity = 1, + colorSpace: RenderColorSpace = getDefaultRenderColorSpace() +): string { + const alphaColor = { ...color, a: color.a * opacity } + if (colorSpace === 'display-p3') { + return colorToDisplayCss(alphaColor, { colorSpace }) + } + return colorToHex(alphaColor) } function createGradientDef( @@ -32,7 +43,7 @@ function createGradientDef( const stopNodes = stops.map((s) => svg('stop', { offset: `${round(s.position * 100)}%`, - 'stop-color': colorToHex(s.color), + 'stop-color': formatColor(s.color, 1, ctx.colorSpace), 'stop-opacity': s.color.a < 1 ? round(s.color.a) : undefined }) ) @@ -146,7 +157,7 @@ export function createFilterDef( dx: round(effect.offset.x), dy: round(effect.offset.y), stdDeviation: stdDev, - 'flood-color': colorToHex(effect.color), + 'flood-color': formatColor(effect.color, 1, ctx.colorSpace), 'flood-opacity': round(effect.color.a) }) ) @@ -167,7 +178,7 @@ export function createFilterDef( result: `${sid}_inv` }), svg('feFlood', { - 'flood-color': colorToHex(effect.color), + 'flood-color': formatColor(effect.color, 1, ctx.colorSpace), 'flood-opacity': round(effect.color.a) }), svg('feComposite', { in2: `${sid}_inv`, operator: 'in', result: `${sid}_shadow` }), @@ -195,7 +206,7 @@ export function resolveFill(fill: Fill, node: SceneNode, ctx: SVGExportContext): if (!fill.visible) return null if (fill.type === 'SOLID') { - return formatColor(fill.color, fill.opacity) + return formatColor(fill.color, fill.opacity, ctx.colorSpace) } if (fill.type.startsWith('GRADIENT')) { diff --git a/packages/core/src/io/formats/svg/export.ts b/packages/core/src/io/formats/svg/export.ts index 60d0f9020..e11c9070c 100644 --- a/packages/core/src/io/formats/svg/export.ts +++ b/packages/core/src/io/formats/svg/export.ts @@ -1,3 +1,4 @@ +import { getDefaultRenderColorSpace } from '@open-pencil/core/color-management' import { computeContentBounds } from '@open-pencil/core/io/formats/raster' import { @@ -378,6 +379,8 @@ function isGroupLike(node: SceneNode): boolean { export interface SVGExportOptions { /** Include XML declaration (default: true) */ xmlDeclaration?: boolean + /** Target export color space (default: display-p3) */ + colorSpace?: 'srgb' | 'display-p3' } export function renderNodesToSVG( @@ -396,7 +399,8 @@ export function renderNodesToSVG( const ctx: SVGExportContext = { defs: [], defIdCounter: 0, - graph + graph, + colorSpace: options.colorSpace ?? getDefaultRenderColorSpace() } const contentNodes: SVGNode[] = [] diff --git a/packages/core/src/io/subgraph.ts b/packages/core/src/io/subgraph.ts index 9b06367ad..71c339781 100644 --- a/packages/core/src/io/subgraph.ts +++ b/packages/core/src/io/subgraph.ts @@ -23,6 +23,7 @@ function cloneIntoGraph(source: SceneGraph, ids: Set): SceneGraph { graph.variableCollections = new Map() graph.activeMode = new Map(source.activeMode) graph.figKiwiVersion = source.figKiwiVersion + graph.documentColorSpace = source.documentColorSpace const sortedIds = [...ids].sort((a, b) => { if (a === source.rootId) return -1 diff --git a/packages/core/src/io/types.ts b/packages/core/src/io/types.ts index 2c40c1d6e..f074d5be6 100644 --- a/packages/core/src/io/types.ts +++ b/packages/core/src/io/types.ts @@ -1,3 +1,4 @@ +import type { RenderColorSpace } from '../color-management' import type { JSXFormat } from '../render' import type { SkiaRenderer } from '../renderer' import type { SceneGraph } from '../scene-graph' @@ -68,11 +69,13 @@ export interface FigWriteOptions { export interface RasterExportOptions { scale?: number quality?: number + colorSpace?: RenderColorSpace format: RasterExportFormat } export interface SVGExportOptions { xmlDeclaration?: boolean + colorSpace?: RenderColorSpace } export interface JSXExportOptions { @@ -99,6 +102,7 @@ export interface IOFormatSupport { export interface IOFormatExportOptions { scale?: boolean quality?: boolean + colorSpace?: boolean } export interface IOFormatAdapter { diff --git a/packages/core/src/kiwi/codec.ts b/packages/core/src/kiwi/codec.ts index 8a83fd97e..e1f27e344 100644 --- a/packages/core/src/kiwi/codec.ts +++ b/packages/core/src/kiwi/codec.ts @@ -359,6 +359,7 @@ export interface NodeChange { variableResolvedType?: string variableDataValues?: { entries?: VariableDataValuesEntry[] } variableScopes?: string[] + documentColorProfile?: 'SRGB' | 'DISPLAY_P3' pluginData?: PluginData[] pluginRelaunchData?: PluginRelaunchData[] } diff --git a/packages/core/src/kiwi/fig-import.ts b/packages/core/src/kiwi/fig-import.ts index 64e754030..4058cf5ef 100644 --- a/packages/core/src/kiwi/fig-import.ts +++ b/packages/core/src/kiwi/fig-import.ts @@ -270,12 +270,18 @@ function remapComponentIds(graph: SceneGraph, guidToNodeId: Map) } } +function parseDocumentColorSpace(nodeChanges: NodeChange[]): 'srgb' | 'display-p3' { + const documentNode = nodeChanges.find((nc) => nc.type === 'DOCUMENT') + return documentNode?.documentColorProfile === 'DISPLAY_P3' ? 'display-p3' : 'srgb' +} + export function importNodeChanges( nodeChanges: NodeChange[], blobs: Uint8Array[] = [], images?: Map ): SceneGraph { const graph = new SceneGraph() + graph.documentColorSpace = parseDocumentColorSpace(nodeChanges) if (images) { for (const [hash, data] of images) { diff --git a/packages/core/src/kiwi/serialize.ts b/packages/core/src/kiwi/serialize.ts index 01549fef0..618813b21 100644 --- a/packages/core/src/kiwi/serialize.ts +++ b/packages/core/src/kiwi/serialize.ts @@ -480,7 +480,10 @@ export function sceneNodeToKiwi( const IDENTITY_TRANSFORM = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } const DEFAULT_STROKE_WEIGHT = 1 -export function makeDocumentNodeChange(guid: GUID): NodeChange & Record { +export function makeDocumentNodeChange( + guid: GUID, + documentColorSpace: 'srgb' | 'display-p3' = 'display-p3' +): NodeChange & Record { return { guid, type: 'DOCUMENT', @@ -492,7 +495,7 @@ export function makeDocumentNodeChange(guid: GUID): NodeChange & Record r.ck.Color4f(s.color.r, s.color.g, s.color.b, s.color.a)) + const colors = stops.map((s, index) => { + const resolved = r.resolveFillColorInfo( + { + ...fill, + type: 'SOLID', + color: s.color, + opacity: s.color.a, + visible: true + }, + index, + node, + graph + ) + const c = resolved.color + return r.ck.Color4f(c.r, c.g, c.b, c.a) + }) const positions = stops.map((s) => s.position) const w = node.width diff --git a/packages/core/src/renderer/labels.ts b/packages/core/src/renderer/labels.ts index d4231d80c..b8cb9355e 100644 --- a/packages/core/src/renderer/labels.ts +++ b/packages/core/src/renderer/labels.ts @@ -25,7 +25,7 @@ export function drawSectionTitles(r: SkiaRenderer, canvas: Canvas, graph: SceneG const ellipsisWidth = font.getGlyphWidths(ellipsisGlyphs)[0] for (const { node, absX, absY, nested } of sections) { - drawSectionTitle(r, canvas, font, node, absX, absY, nested, ellipsis, ellipsisWidth) + drawSectionTitle(r, canvas, font, node, graph, absX, absY, nested, ellipsis, ellipsisWidth) } } @@ -34,6 +34,7 @@ function drawSectionTitle( canvas: Canvas, font: Font, node: SceneNode, + graph: SceneGraph, absX: number, absY: number, nested: boolean, @@ -76,7 +77,7 @@ function drawSectionTitle( const pillY = nested ? screenY + SECTION_TITLE_GAP : screenY - pillH - SECTION_TITLE_GAP if (node.fills.length > 0 && node.fills[0].visible) { - const c = node.fills[0].color + const c = r.resolveFillColor(node.fills[0], 0, node, graph) r.auxFill.setColor(r.ck.Color4f(c.r, c.g, c.b, node.fills[0].opacity)) } else { r.auxFill.setColor(r.ck.Color4f(0.37, 0.37, 0.37, 1)) @@ -86,8 +87,8 @@ function drawSectionTitle( const pillColor = node.fills.length > 0 && node.fills[0].visible - ? node.fills[0].color - : { r: 0.37, g: 0.37, b: 0.37 } + ? r.resolveFillColor(node.fills[0], 0, node, graph) + : { r: 0.37, g: 0.37, b: 0.37, a: 1 } const lum = 0.299 * pillColor.r + 0.587 * pillColor.g + 0.114 * pillColor.b r.auxFill.setColor(lum > 0.5 ? r.ck.BLACK : r.ck.WHITE) const textY = pillY + pillH * 0.7 diff --git a/packages/core/src/renderer/renderer.ts b/packages/core/src/renderer/renderer.ts index cc8ce8c63..bd390c3a8 100644 --- a/packages/core/src/renderer/renderer.ts +++ b/packages/core/src/renderer/renderer.ts @@ -30,6 +30,7 @@ import { } from '../constants' import { computeAbsoluteBounds } from '../geometry' import { RenderProfiler } from '../profiler' +import { resolveNodeFillColor, resolveNodeStrokeColor, type ResolvedRenderColor } from '../color-management' import { drawAiOverlays as drawAiOverlaysFn } from './ai-overlays' import { getCachedDropShadow as getCachedDropShadowFn, @@ -941,13 +942,56 @@ export class SkiaRenderer { return buildParagraphFn(this, node, color, opts) } - resolveFillColor(fill: Fill, fillIndex: number, node: SceneNode, graph: SceneGraph): Color { + resolveFillColorInfo( + fill: Fill, + fillIndex: number, + node: SceneNode, + graph: SceneGraph + ): ResolvedRenderColor { const varId = node.boundVariables[`fills/${fillIndex}/color`] if (varId) { const resolved = graph.resolveColorVariable(varId) - if (resolved) return resolved + if (resolved) { + return { + color: resolved, + cssColor: '', + sourceSpace: 'srgb', + targetSpace: graph.documentColorSpace, + clipped: false + } + } } - return fill.color + return resolveNodeFillColor(fill, fillIndex, node, { + documentColorSpace: graph.documentColorSpace + }) + } + + resolveFillColor(fill: Fill, fillIndex: number, node: SceneNode, graph: SceneGraph): Color { + return this.resolveFillColorInfo(fill, fillIndex, node, graph).color + } + + resolveStrokeColorInfo( + stroke: Stroke, + strokeIndex: number, + node: SceneNode, + graph: SceneGraph + ): ResolvedRenderColor { + const varId = node.boundVariables[`strokes/${strokeIndex}/color`] + if (varId) { + const resolved = graph.resolveColorVariable(varId) + if (resolved) { + return { + color: resolved, + cssColor: '', + sourceSpace: 'srgb', + targetSpace: graph.documentColorSpace, + clipped: false + } + } + } + return resolveNodeStrokeColor(stroke, strokeIndex, node, { + documentColorSpace: graph.documentColorSpace + }) } resolveStrokeColor( @@ -956,12 +1000,7 @@ export class SkiaRenderer { node: SceneNode, graph: SceneGraph ): Color { - const varId = node.boundVariables[`strokes/${strokeIndex}/color`] - if (varId) { - const resolved = graph.resolveColorVariable(varId) - if (resolved) return resolved - } - return stroke.color + return this.resolveStrokeColorInfo(stroke, strokeIndex, node, graph).color } screenToCanvas(sx: number, sy: number): Vector { @@ -1182,8 +1221,8 @@ export class SkiaRenderer { return applyFillFn(this, fill, node, graph, fillIndex) } - applyGradientFill(fill: Fill, node: SceneNode): void { - applyGradientFillFn(this, fill, node) + applyGradientFill(fill: Fill, node: SceneNode, graph: SceneGraph): void { + applyGradientFillFn(this, fill, node, graph) } applyImageFill(fill: Fill, node: SceneNode, graph: SceneGraph): boolean { diff --git a/packages/core/src/renderer/text.ts b/packages/core/src/renderer/text.ts index 361dc786c..e7c0380c9 100644 --- a/packages/core/src/renderer/text.ts +++ b/packages/core/src/renderer/text.ts @@ -1,5 +1,6 @@ import { DEFAULT_FONT_SIZE, DEFAULT_FONT_FAMILY } from '../constants' import { isFontLoaded, getCJKFallbackFamilies } from '../fonts' +import { resolveRGBAForPreview } from '../color-management' import type { SceneNode } from '../scene-graph' import type { CanvasKit, FontWeight, Paragraph, TypefaceFontProvider } from 'canvaskit-wasm' @@ -127,7 +128,7 @@ function addStyledRuns( if (s.fills) { const visibleFill = s.fills.find((f) => f.visible && f.type === 'SOLID') if (visibleFill) { - const c = visibleFill.color + const c = resolveRGBAForPreview(visibleFill.color).color runColor = ck.Color4f(c.r, c.g, c.b, c.a * visibleFill.opacity) } } diff --git a/packages/core/src/scene-graph.ts b/packages/core/src/scene-graph.ts index 7d858f8a3..e1aa242e8 100644 --- a/packages/core/src/scene-graph.ts +++ b/packages/core/src/scene-graph.ts @@ -28,6 +28,8 @@ export interface SceneGraphEvents { 'node:reordered': (nodeId: string, parentId: string, index: number) => void } +export type DocumentColorSpace = 'srgb' | 'display-p3' + export type HandleMirroring = 'NONE' | 'ANGLE' | 'ANGLE_AND_LENGTH' export type WindingRule = 'NONZERO' | 'EVENODD' @@ -523,6 +525,7 @@ export class SceneGraph { activeMode = new Map() rootId: string figKiwiVersion: number | null = null + documentColorSpace: DocumentColorSpace = 'display-p3' readonly emitter: Emitter = createNanoEvents() private absPosCache = new Map() instanceIndex = new Map>() diff --git a/packages/vue/src/shared/useCanvas.ts b/packages/vue/src/shared/useCanvas.ts index b0847fc78..2819bf767 100644 --- a/packages/vue/src/shared/useCanvas.ts +++ b/packages/vue/src/shared/useCanvas.ts @@ -79,7 +79,19 @@ export function useCanvas( glContext = ck.MakeGrContext(handle) } if (!glContext) return null - return ck.MakeOnScreenGLSurface(glContext, canvas.width, canvas.height, ck.ColorSpace.SRGB) + + const preferredSpace = editor.graph.documentColorSpace + const colorSpaces = + preferredSpace === 'display-p3' + ? [ck.ColorSpace.DISPLAY_P3, ck.ColorSpace.SRGB] + : [ck.ColorSpace.SRGB] + + for (const colorSpace of colorSpaces) { + const surface = ck.MakeOnScreenGLSurface(glContext, canvas.width, canvas.height, colorSpace) + if (surface) return surface + } + + return null } function createSurface(canvas: HTMLCanvasElement) {