Add document color space support

This commit is contained in:
Danila Poyarkov 2026-03-29 18:46:46 +03:00
parent 04c4c208e7
commit 7f001059ba
21 changed files with 431 additions and 36 deletions

View file

@ -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')
]

View file

@ -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
}

View file

@ -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<SceneNode> | 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
}
}

View file

@ -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) =>

View file

@ -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,

View file

@ -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)

View file

@ -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)

View file

@ -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(

View file

@ -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')) {

View file

@ -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[] = []

View file

@ -23,6 +23,7 @@ function cloneIntoGraph(source: SceneGraph, ids: Set<string>): 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

View file

@ -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 {

View file

@ -359,6 +359,7 @@ export interface NodeChange {
variableResolvedType?: string
variableDataValues?: { entries?: VariableDataValuesEntry[] }
variableScopes?: string[]
documentColorProfile?: 'SRGB' | 'DISPLAY_P3'
pluginData?: PluginData[]
pluginRelaunchData?: PluginRelaunchData[]
}

View file

@ -270,12 +270,18 @@ function remapComponentIds(graph: SceneGraph, guidToNodeId: Map<string, string>)
}
}
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<string, Uint8Array>
): SceneGraph {
const graph = new SceneGraph()
graph.documentColorSpace = parseDocumentColorSpace(nodeChanges)
if (images) {
for (const [hash, data] of images) {

View file

@ -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<string, unknown> {
export function makeDocumentNodeChange(
guid: GUID,
documentColorSpace: 'srgb' | 'display-p3' = 'display-p3'
): NodeChange & Record<string, unknown> {
return {
guid,
type: 'DOCUMENT',
@ -492,7 +495,7 @@ export function makeDocumentNodeChange(guid: GUID): NodeChange & Record<string,
strokeWeight: DEFAULT_STROKE_WEIGHT,
strokeAlign: 'CENTER',
strokeJoin: 'MITER',
documentColorProfile: 'SRGB'
documentColorProfile: documentColorSpace === 'display-p3' ? 'DISPLAY_P3' : 'SRGB'
}
}

View file

@ -67,7 +67,7 @@ export function applyFill(
}
if (fill.type.startsWith('GRADIENT') && fill.gradientStops && fill.gradientTransform) {
r.applyGradientFill(fill, node)
r.applyGradientFill(fill, node, graph)
return true
}
@ -78,11 +78,31 @@ export function applyFill(
return false
}
export function applyGradientFill(r: SkiaRenderer, fill: Fill, node: SceneNode): void {
export function applyGradientFill(
r: SkiaRenderer,
fill: Fill,
node: SceneNode,
graph: SceneGraph
): void {
const stops = fill.gradientStops
const t = fill.gradientTransform
if (!stops || !t) return
const colors = stops.map((s) => 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

View file

@ -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

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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<string, string>()
rootId: string
figKiwiVersion: number | null = null
documentColorSpace: DocumentColorSpace = 'display-p3'
readonly emitter: Emitter<SceneGraphEvents> = createNanoEvents()
private absPosCache = new Map<string, Vector>()
instanceIndex = new Map<string, Set<string>>()

View file

@ -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) {