diff --git a/public/text.psd b/public/text.psd deleted file mode 100644 index fa8803d..0000000 Binary files a/public/text.psd and /dev/null differ diff --git a/src/services/psd-import-service.ts b/src/services/psd-import-service.ts index 85e9a86..14a2743 100644 --- a/src/services/psd-import-service.ts +++ b/src/services/psd-import-service.ts @@ -1,7 +1,7 @@ /** * The MIT License (MIT) * - * Igor Zinken 2022-2023 - https://www.igorski.nl + * Igor Zinken 2022-2026 - https://www.igorski.nl * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -24,14 +24,18 @@ import PSD from "psd.js"; import type { Rectangle, Size } from "zcanvas"; import { BlendModes } from "@/definitions/blend-modes"; +import type { RGBA } from "@/definitions/colors"; import type { Document, Layer } from "@/definitions/document"; +import { googleFonts } from "@/definitions/font-types"; +import { LayerTypes } from "@/definitions/layer-types"; import DocumentFactory from "@/factories/document-factory"; import FiltersFactory from "@/factories/filters-factory"; -import LayerFactory from "@/factories/layer-factory"; -import type { LayerProps } from "@/factories/layer-factory"; +import LayerFactory, { type LayerProps } from "@/factories/layer-factory"; +import type { TextProps } from "@/factories/text-factory"; import { inverseMask } from "@/rendering/operations/compositing"; import { createCanvas, base64toCanvas } from "@/utils/canvas-util"; -import { unblockedWait } from "@/utils/debounce-util"; +import { RGBAtoHex } from "@/utils/color-util"; +import { SmartExecutor } from "@/utils/debounce-util"; type PSDLayer = Rectangle & { mask: Rectangle; @@ -48,7 +52,8 @@ type PSDLayer = Rectangle & { maskData: { buffer: ArrayBuffer; } - } + }, + typeTool?: () => PSDTextData; }; type PSDNode = { @@ -62,6 +67,13 @@ type PSDNode = { } }; +type PSDTextData = { + textValue: string; + colors: () => RGBA[]; + fonts: () => string[]; + sizes: () => number[]; +}; + type PSD = { tree: () => Size & PSDNode; image: { @@ -71,9 +83,10 @@ type PSD = { export const importPSD = async ( psdFileReference: File ): Promise => { try { + const smartExec = new SmartExecutor(); // parsing nested PSD content can get heavy on CPU resources const psd = await PSD.fromDroppedFile( psdFileReference ); - await unblockedWait(); - const doc = await psdToBitMapperyDocument( psd, psdFileReference ); + await smartExec.waitWhenBusy(); + const doc = await psdToBitMapperyDocument( smartExec, psd, psdFileReference ); return doc; } catch ( error ) { console.error( error ); @@ -83,33 +96,32 @@ export const importPSD = async ( psdFileReference: File ): Promise => /* internal methods */ -async function psdToBitMapperyDocument( psd: any, psdFileReference: File ): Promise { +async function psdToBitMapperyDocument( smartExec: SmartExecutor, psd: any, psdFileReference: File ): Promise { const psdTree = psd.tree(); const { width, height } = psdTree; // collect layers const layers: Layer[] = []; - const treeLayerObjects = psdTree.children().reverse(); - for ( const layerObj of treeLayerObjects ) { + for ( const layerObj of psdTree.children().reverse() ) { const { layer } = layerObj; - // 1. determine layer bounding box - + // when there are children, this is likely a group layer const children = layer.node?.children() ?? []; if ( children.length ) { - // we are likely looking at a group layer for ( const childLayer of children.reverse() ) { await createLayer( childLayer.layer, layers, childLayer.name ); + await smartExec.waitWhenBusy(); } } else { try { await createLayer( layer, layers, layerObj.name ); } catch ( e: any ) { - console.error( `Error occured importing layer "${layerObj.name}".`, e ); + console.error( `Error occured while importing layer "${layerObj.name}".`, e ); } } + await smartExec.waitWhenBusy(); } // also add the merged layer preview @@ -141,9 +153,11 @@ async function createLayer( layer: PSDLayer, layers: Layer[], name = "" ): Promi return; // likely an adjustment layer, which we don't support } - // 2. determine whether layer uses masking + const layerProps: LayerProps = { + type: LayerTypes.LAYER_GRAPHIC, + }; - const layerProps: LayerProps = {}; + // determine whether layer uses masking if ( layer.image.hasMask && layer.mask.width ) { // note that we position the mask at the 0, 0 coordinate relative to the layer, whereas Photoshop @@ -159,9 +173,23 @@ async function createLayer( layer: PSDLayer, layers: Layer[], name = "" ): Promi layerProps.mask = cvs; } - // 3. retrieve layer source + // retrieve layer contents - const source = layer.image ? await base64toCanvas( layer.image.toBase64(), layer.image.width(), layer.image.height() ) : null; + const isText = typeof layer.typeTool === "function"; + if ( isText ) { + const textData = layer.typeTool!(); + + layerProps.type = LayerTypes.LAYER_TEXT; + layerProps.text = { + value: textData.textValue, + color: RGBAtoHex( textData.colors()?.[ 0 ] ?? [ 0, 0, 0, 0 ]), + font: textData.fonts()?.find( font => googleFonts.includes( font )), + size : textData.sizes()?.[0], + unit: "px", + } as TextProps; + } else { + layerProps.source = layer.image ? await base64toCanvas( layer.image.toBase64(), layer.image.width(), layer.image.height() ) : null; + } layers.push( LayerFactory.create({ visible : layer.visible, @@ -170,16 +198,12 @@ async function createLayer( layer: PSDLayer, layers: Layer[], name = "" ): Promi width : layerWidth, height : layerHeight, name, - source, ...layerProps, filters : FiltersFactory.create({ opacity : ( layer.opacity ?? 255 ) / 255, blendMode : convertBlendMode( layer.blendMode.blendKey ) }), })); - - // layer bitmap parsing can be heavy, unblock CPU on each iteration - await unblockedWait(); } diff --git a/src/utils/color-util.ts b/src/utils/color-util.ts index 9dbfe2f..f575a51 100644 --- a/src/utils/color-util.ts +++ b/src/utils/color-util.ts @@ -22,31 +22,31 @@ */ import type { RGB, RGBA, HSV } from "@/definitions/colors"; -export const rgb2YCbCr = ( r: number, g: number, b: number ): RGB => ({ - r: 0.2990 * r + 0.5870 * g + 0.1140 * b, +export const rgb2YCbCr = (r: number, g: number, b: number): RGB => ({ + r: 0.2990 * r + 0.5870 * g + 0.1140 * b, g: -0.1687 * r - 0.3313 * g + 0.5000 * b, - b: 0.5000 * r - 0.4187 * g - 0.0813 * b + b: 0.5000 * r - 0.4187 * g - 0.0813 * b }); -export const YCbCr2rgb = ( r: number, g: number, b: number ): RGB => ({ +export const YCbCr2rgb = (r: number, g: number, b: number): RGB => ({ r: r + 1.4020 * b, g: r - 0.3441 * g - 0.7141 * b, b: r + 1.7720 * g }); -export const rgb2hsv = ( r: number, g: number, b: number ): HSV => { - const c = rgb2YCbCr( r, g, b ); - const s = Math.sqrt( c.g * c.g + c.b * c.b ); - const h = Math.atan2( c.g, c.b ); +export const rgb2hsv = (r: number, g: number, b: number): HSV => { + const c = rgb2YCbCr(r, g, b); + const s = Math.sqrt(c.g * c.g + c.b * c.b); + const h = Math.atan2(c.g, c.b); return { h, s, v: c.r }; } -export const hsv2rgb = ( h: number, s: number, v: number ): RGB => { - const g = s * Math.sin( h ); - const b = s * Math.cos( h ); - return YCbCr2rgb( v, g, b ); +export const hsv2rgb = (h: number, s: number, v: number): RGB => { + const g = s * Math.sin(h); + const b = s * Math.cos(h); + return YCbCr2rgb(v, g, b); }; export const hexToRGBA = ( hex: string ): RGBA => { @@ -59,8 +59,20 @@ export const hexToRGBA = ( hex: string ): RGBA => { ]; }; +export const RGBAtoHex = ( rgba: RGBA ): string => { + const [r, g, b, a] = rgba; + + const rgbHex = `#${intToHex(r)}${intToHex(g)}${intToHex(b)}`; + + return a === 255 ? rgbHex : `${rgbHex}${intToHex(a)}`; +} + /* internal methods */ function hexToInt( str: string ): number { return parseInt( str, 16 ); } + +function intToHex( value: number ): string { + return value.toString( 16 ).padStart( 2, "0" ).toUpperCase(); +} \ No newline at end of file diff --git a/tests/unit/utils/color-util.spec.ts b/tests/unit/utils/color-util.spec.ts new file mode 100644 index 0000000..02fb7ca --- /dev/null +++ b/tests/unit/utils/color-util.spec.ts @@ -0,0 +1,24 @@ +import { it, describe, expect } from "vitest"; +import { hexToRGBA, RGBAtoHex } from "@/utils/color-util"; + +describe( "Shape utilities", () => { + describe( "when converting hex to RGBA", () => { + it( "should convert hex a hex value without transparency to be fully opaque", () => { + expect( hexToRGBA( "#FF0000" )).toEqual([ 255, 0, 0, 255 ]); + }); + + it( "should correctly convert a hexa value with transparency to RGBA", () => { + expect( hexToRGBA( "#FF990077" )).toEqual([ 255, 153, 0, 119 ]); + }); + }); + + describe( "when converting RGBA to hex", () => { + it( "should convert a fully opaque RGBA value to a six character hex value", () => { + expect( RGBAtoHex([ 255, 0, 0, 255 ])).toEqual( "#FF0000" ); + }); + + it( "should correctly convert a semi transparent RGBA value to hexa", () => { + expect( RGBAtoHex([ 255, 153, 0, 119 ])).toEqual( "#FF990077" ); + }); + }); +}); \ No newline at end of file