added text conversion

This commit is contained in:
Igor Zinken
2026-01-31 16:11:21 +01:00
parent b23f99cb66
commit 79e957195a
4 changed files with 94 additions and 34 deletions

Binary file not shown.

View File

@@ -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<Document> => {
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<Document> =>
/* internal methods */
async function psdToBitMapperyDocument( psd: any, psdFileReference: File ): Promise<Document> {
async function psdToBitMapperyDocument( smartExec: SmartExecutor, psd: any, psdFileReference: File ): Promise<Document> {
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();
}

View File

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

View File

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