mirror of
https://github.com/igorski/bitmappery.git
synced 2026-07-29 10:17:42 +02:00
added text conversion
This commit is contained in:
BIN
public/text.psd
BIN
public/text.psd
Binary file not shown.
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
24
tests/unit/utils/color-util.spec.ts
Normal file
24
tests/unit/utils/color-util.spec.ts
Normal 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" );
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user