Add document color space support
This commit is contained in:
parent
04c4c208e7
commit
7f001059ba
|
|
@ -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')
|
||||
]
|
||||
|
||||
|
|
|
|||
170
packages/core/src/color-management.ts
Normal file
170
packages/core/src/color-management.ts
Normal 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
|
||||
}
|
||||
97
packages/core/src/editor/color-space.ts
Normal file
97
packages/core/src/editor/color-space.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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[] = []
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -359,6 +359,7 @@ export interface NodeChange {
|
|||
variableResolvedType?: string
|
||||
variableDataValues?: { entries?: VariableDataValuesEntry[] }
|
||||
variableScopes?: string[]
|
||||
documentColorProfile?: 'SRGB' | 'DISPLAY_P3'
|
||||
pluginData?: PluginData[]
|
||||
pluginRelaunchData?: PluginRelaunchData[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>>()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue