diff --git a/packages/psd/src/classes/Group.ts b/packages/psd/src/classes/Group.ts index 8e6eaa7..4259e15 100644 --- a/packages/psd/src/classes/Group.ts +++ b/packages/psd/src/classes/Group.ts @@ -16,15 +16,15 @@ export class Group implements NodeBase { /** @internal */ constructor( - private layerFrame: GroupFrame, + private layerFrame: GroupFrame | undefined, public readonly parent: NodeParent ) {} get name(): string { - return this.layerFrame.layerProperties.name; + return this.layerFrame?.layerProperties.name ?? ""; } get opacity(): number { - return this.layerFrame.layerProperties.opacity; + return this.layerFrame?.layerProperties.opacity ?? 0; } get composedOpacity(): number { return this.parent.composedOpacity * (this.opacity / 255); diff --git a/packages/psd/src/classes/Layer.ts b/packages/psd/src/classes/Layer.ts index 2c49a04..8c22174 100644 --- a/packages/psd/src/classes/Layer.ts +++ b/packages/psd/src/classes/Layer.ts @@ -3,7 +3,9 @@ // MIT License import {EngineData, ImageData} from "../interfaces"; -import {LayerFrame} from "../sections"; +import {decodeGrayscale} from "../methods"; +import {LayerFrame, MaskData} from "../sections"; +import {area} from "../utils"; import {NodeParent} from "./Node"; import {NodeBase} from "./NodeBase"; import {Synthesizable} from "./Synthesizable"; @@ -48,6 +50,26 @@ export class Layer get composedOpacity(): number { return this.parent.composedOpacity * (this.opacity / 255); } + get maskData(): MaskData { + return this.layerFrame.layerProperties.maskData; + } + + async userMask(): Promise { + const userMask = this.layerFrame.userMask; + if (!userMask) { + return undefined; + } + return decodeGrayscale(area(this.maskData), userMask); + } + + async realUserMask(): Promise { + const maskData = this.maskData.realData; + const userMask = this.layerFrame.realUserMask; + if (!maskData || !userMask) { + return undefined; + } + return decodeGrayscale(area(maskData), userMask); + } get isHidden(): boolean { return this.layerFrame.layerProperties.hidden; diff --git a/packages/psd/src/sections/LayerAndMaskInformation/classes.ts b/packages/psd/src/sections/LayerAndMaskInformation/classes.ts index 076e734..7f3bb9c 100644 --- a/packages/psd/src/sections/LayerAndMaskInformation/classes.ts +++ b/packages/psd/src/sections/LayerAndMaskInformation/classes.ts @@ -48,6 +48,12 @@ export class LayerFrame { get alpha(): ChannelBytes | undefined { return this.channels.get(ChannelKind.TransparencyMask); } + get userMask(): ChannelBytes | undefined { + return this.channels.get(ChannelKind.UserSuppliedLayerMask); + } + get realUserMask(): ChannelBytes | undefined { + return this.channels.get(ChannelKind.RealUserSuppliedLayerMask); + } get width(): number { const {right, left} = this.layerProperties; diff --git a/packages/psd/src/sections/LayerAndMaskInformation/interfaces.ts b/packages/psd/src/sections/LayerAndMaskInformation/interfaces.ts index 3d9ae5f..b18cc4f 100644 --- a/packages/psd/src/sections/LayerAndMaskInformation/interfaces.ts +++ b/packages/psd/src/sections/LayerAndMaskInformation/interfaces.ts @@ -33,6 +33,7 @@ export interface LayerRecord { layerText?: string; /** If defined, containts extra text properties */ engineData?: EngineData; + maskData: MaskData; } export type LayerChannels = Map; @@ -60,6 +61,7 @@ export interface LayerProperties { text?: string; /** Text properties */ textProperties?: EngineData; + maskData: MaskData; } export const createLayerProperties = ( @@ -79,6 +81,7 @@ export const createLayerProperties = ( blendMode, layerText, engineData, + maskData, } = layerRecord; return { @@ -95,5 +98,53 @@ export const createLayerProperties = ( groupId, text: layerText, textProperties: engineData, + maskData, }; }; + +export interface MaskFlags { + // bit 0 = position relative to layer + positionRelativeToLayer: boolean; + // bit 1 = layer mask disabled + layerMaskDisabled: boolean; + // bit 2 = invert layer mask when blending (Obsolete) + invertMaskWhenBlending: boolean; + // bit 3 = indicates that the user mask actually came from rendering other data + userMaskFromRenderingOtherData: boolean; + // bit 4 = indicates that the user and/or vector masks have parameters applied to them + masksHaveParametersApplied: boolean; +} + +export interface MaskParameters { + // bit 0 = user mask density, 1 byte + userMaskDensity?: number; + // bit 1 = user mask feather, 8 byte, double + userMaskFeather?: number; + // bit 2 = vector mask density, 1 byte + vectorMaskDensity?: number; + // bit 3 = vector mask feather, 8 bytes, double + vectorMaskFeather?: number; +} + +// The spec is confusing at best... what "real" means? +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#50577409_pgfId-1031423 +export interface RealMaskData { + flags: MaskFlags; + backgroundColor: number; + top: number; + left: number; + bottom: number; + right: number; +} + +export interface MaskData { + top: number; + left: number; + bottom: number; + right: number; + backgroundColor: number; + flags: MaskFlags; + parameters?: MaskParameters; + // only present if size != 20 + realData?: RealMaskData; +} diff --git a/packages/psd/src/sections/LayerAndMaskInformation/readLayerRecordsAndChannels.ts b/packages/psd/src/sections/LayerAndMaskInformation/readLayerRecordsAndChannels.ts index 91f3cc4..d98e226 100644 --- a/packages/psd/src/sections/LayerAndMaskInformation/readLayerRecordsAndChannels.ts +++ b/packages/psd/src/sections/LayerAndMaskInformation/readLayerRecordsAndChannels.ts @@ -15,12 +15,23 @@ import { matchBlendMode, matchChannelCompression, matchClipping, - RawDataDescriptorValue, } from "../../interfaces"; import {parseEngineData} from "../../methods"; -import {Cursor, InvalidBlendingModeSignature} from "../../utils"; +import { + Cursor, + height, + InvalidBlendingModeSignature, + ReadType, +} from "../../utils"; import {readAdditionalLayerInfo} from "./AdditionalLayerInfo"; -import {LayerChannels, LayerRecord} from "./interfaces"; +import { + LayerChannels, + LayerRecord, + MaskData, + MaskFlags, + MaskParameters, + RealMaskData, +} from "./interfaces"; const EXPECTED_BLENDING_MODE_SIGNATURE = "8BIM"; @@ -39,12 +50,11 @@ export function readLayerRecordsAndChannels( // Read layer channels const result = layerRecords .map((layerRecord): [LayerRecord, LayerChannels] => { - const layerHeight = calcLayerHeight(layerRecord); // The channels for each layer are stored in the same order as the layers const channels = readLayerChannels( cursor, layerRecord.channelInformation, - layerHeight, + layerRecord, fileVersionSpec ); @@ -111,9 +121,7 @@ function readLayerRecord( const layerExtraDataSize = cursor.read("u32"); const layerExtraDataBegin = cursor.position; - // Skip the Layer Mask info segment, which we don't need for now - // Read the length of the segment and skip it - cursor.pass(cursor.read("u32")); + const maskData = readMaskData(cursor); // Skip the Blending Range segment, which we don't need for now // Read the length of the segment and skip it @@ -185,6 +193,7 @@ function readLayerRecord( dividerType, layerText, engineData, + maskData, }; } @@ -223,14 +232,32 @@ function readLayerFlags(cursor: Cursor): { }; } -function calcLayerHeight(layerRecord: LayerRecord): number { - return layerRecord.bottom - layerRecord.top + 1; +function realMask(layerRecord: LayerRecord): MaskData { + const maskData = layerRecord.maskData.realData; + if (!maskData) { + throw new Error("missing real mask data"); + } + return maskData; +} + +function calcLayerHeight( + layerRecord: LayerRecord, + channelId: ChannelKind +): number { + switch (channelId) { + case ChannelKind.UserSuppliedLayerMask: + return height(layerRecord.maskData); + case ChannelKind.RealUserSuppliedLayerMask: + return height(realMask(layerRecord)); + default: + return height(layerRecord) + 1; + } } function readLayerChannels( cursor: Cursor, channelInformation: [ChannelKind, number][], - scanLines: number, + layerRecord: LayerRecord, fileVersionSpec: FileVersionSpec ): LayerChannels { const channels = new Map(); @@ -244,30 +271,25 @@ function readLayerChannels( // This is different from the PSD Image Data section, which uses a single // compression method for all channels. const compression = matchChannelCompression(cursor.read("u16")); - const channelData = cursor.take(channelDataLength); - switch (compression) { case ChannelCompression.RawData: { - channels.set(channelKind, {compression, data: channelData}); + const data = cursor.take(channelDataLength); + channels.set(channelKind, {compression, data}); break; } case ChannelCompression.RleCompressed: { - // We're skipping over the bytes that describe the length of each scanline since - // we don't currently use them. We might re-think this in the future when we implement - // serialization of a Psd back into bytes.. But not a concern at the moment. - // Compressed bytes per scanline are encoded at the beginning as 2 bytes per scanline - - const bytesPerScanline = fileVersionSpec.rleScanlineLengthFieldSize; - // Do not attempt to skip more than the length of the channel data. - // This is needed because some layers (e.g. gradient fill layers) may - // have empty channel data (channelDataLength === 0). - const skip = Math.min(channelDataLength, scanLines * bytesPerScanline); - const data = new Uint8Array( - channelData.buffer, - channelData.byteOffset + skip, - channelData.byteLength - skip + const data = cursor.take( + // Do not attempt to take more than the length of the channel data. + // This is needed because some layers (e.g. gradient fill layers) may + // have empty channel data (channelDataLength === 0). + channelDataLength > 0 + ? rleCompressedSize( + cursor, + calcLayerHeight(layerRecord, channelKind), + fileVersionSpec.rleScanlineLengthFieldReadType + ) + : channelDataLength ); - channels.set(channelKind, {compression, data}); break; } @@ -276,3 +298,123 @@ function readLayerChannels( return channels; } + +function rleCompressedSize( + cursor: Cursor, + scanLines: number, + readType: ReadType +): number { + const sizes = Array.from(Array(scanLines), () => cursor.read(readType)); + return sizes.reduce((a, b) => a + b); +} + +function readMaskData(cursor: Cursor): MaskData { + const length = cursor.read("u32"); + const startsAt = cursor.position; + const [top, left, bottom, right] = readBounds(cursor); + const backgroundColor = cursor.read("u8"); + const flags = readFlags(cursor); + const realData = length >= 36 ? readRealData(cursor) : undefined; + const parameters = flags.masksHaveParametersApplied + ? readParameters(cursor) + : undefined; + + const remainingBytes = length - (cursor.position - startsAt); + cursor.pass(remainingBytes); + + return { + top, + left, + bottom, + right, + backgroundColor, + flags, + parameters, + realData, + }; +} + +function readBounds(cursor: Cursor): [number, number, number, number] { + return Array.from(Array(4), () => cursor.read("i32")) as [ + number, + number, + number, + number + ]; +} + +enum MaskFlagsBitmask { + PositionRelativeToLayer = 1 << 0, + LayerMaskDisabled = 1 << 1, + InvertMaskWhenBlending = 1 << 2, + UserMaskFromRenderingOtherData = 1 << 3, + MasksHaveParametersApplied = 1 << 4, +} + +function readFlags(cursor: Cursor): MaskFlags { + const flags = cursor.read("u8"); + return { + // bit 0 = position relative to layer + positionRelativeToLayer: Boolean( + flags & MaskFlagsBitmask.PositionRelativeToLayer + ), + // bit 1 = layer mask disabled + layerMaskDisabled: Boolean(flags & MaskFlagsBitmask.LayerMaskDisabled), + // bit 2 = invert layer mask when blending (Obsolete) + invertMaskWhenBlending: Boolean( + flags & MaskFlagsBitmask.InvertMaskWhenBlending + ), + // bit 3 = indicates that the user mask actually came from rendering other data + userMaskFromRenderingOtherData: Boolean( + flags & MaskFlagsBitmask.UserMaskFromRenderingOtherData + ), + // bit 4 = indicates that the user and/or vector masks have parameters applied to them + masksHaveParametersApplied: Boolean( + flags & MaskFlagsBitmask.MasksHaveParametersApplied + ), + }; +} + +enum MaskParameterBitmask { + // bit 0 = user mask density + UserMaskDensity = 1 << 0, + // bit 1 = user mask feather + UserMaskFeather = 1 << 1, + // bit 2 = vector mask density + VectorMaskDensity = 1 << 2, + // bit 3 = vector mask feather + VectorMaskFeather = 1 << 3, +} + +function readParameters(cursor: Cursor): MaskParameters { + const parameters = cursor.read("u8"); + return { + // bit 0 = user mask density, 1 byte + userMaskDensity: + parameters & MaskParameterBitmask.UserMaskDensity + ? cursor.read("u8") + : undefined, + // bit 1 = user mask feather, 8 byte, double + userMaskFeather: + parameters & MaskParameterBitmask.UserMaskFeather + ? cursor.read("f64") + : undefined, + // bit 2 = vector mask density, 1 byte + vectorMaskDensity: + parameters & MaskParameterBitmask.VectorMaskDensity + ? cursor.read("u8") + : undefined, + // bit 3 = vector mask feather, 8 bytes, double + vectorMaskFeather: + parameters & MaskParameterBitmask.VectorMaskFeather + ? cursor.read("f64") + : undefined, + }; +} + +function readRealData(cursor: Cursor): RealMaskData { + const flags = readFlags(cursor); + const backgroundColor = cursor.read("u8"); + const [top, left, bottom, right] = readBounds(cursor); + return {top, left, bottom, right, flags, backgroundColor}; +} diff --git a/packages/psd/src/utils/boundingBox.ts b/packages/psd/src/utils/boundingBox.ts new file mode 100644 index 0000000..fa605dd --- /dev/null +++ b/packages/psd/src/utils/boundingBox.ts @@ -0,0 +1,29 @@ +// @webtoon/psd +// Copyright 2021-present NAVER WEBTOON +// MIT License + +interface BoundingBox { + top: number; + left: number; + bottom: number; + right: number; +} + +export function height(boundingBox: BoundingBox): number { + return boundingBox.bottom - boundingBox.top; +} + +export function width(boundingBox: BoundingBox): number { + return boundingBox.right - boundingBox.left; +} + +export function dimensions(boundingBox: BoundingBox): { + height: number; + width: number; +} { + return {width: width(boundingBox), height: height(boundingBox)}; +} + +export function area(boundingBox: BoundingBox): number { + return width(boundingBox) * height(boundingBox); +} diff --git a/packages/psd/src/utils/index.ts b/packages/psd/src/utils/index.ts index 5bb7136..523cd74 100644 --- a/packages/psd/src/utils/index.ts +++ b/packages/psd/src/utils/index.ts @@ -6,3 +6,4 @@ export * from "./array"; export * from "./bytes"; export * from "./error"; export * from "./number"; +export * from "./boundingBox"; diff --git a/packages/psd/tests/integration/fixtures/mask.psd b/packages/psd/tests/integration/fixtures/mask.psd new file mode 100644 index 0000000..35a357f Binary files /dev/null and b/packages/psd/tests/integration/fixtures/mask.psd differ diff --git a/packages/psd/tests/integration/userMask.test.ts b/packages/psd/tests/integration/userMask.test.ts new file mode 100644 index 0000000..d128336 --- /dev/null +++ b/packages/psd/tests/integration/userMask.test.ts @@ -0,0 +1,115 @@ +// @webtoon/psd +// Copyright 2021-present NAVER WEBTOON +// MIT License + +import * as fs from "fs"; +import * as path from "path"; +import * as crypto from "crypto"; +import {beforeAll, describe, expect, it} from "vitest"; + +import type Psd from "../../src/index"; +import PSD, {Layer} from "../../src/index"; + +const FIXTURE_DIR = path.join(__dirname, "fixtures"); + +describe(`@webtoon/psd reads user masks`, () => { + let psd: Psd; + beforeAll(() => { + const data = fs.readFileSync(path.resolve(FIXTURE_DIR, "mask.psd")).buffer; + psd = PSD.parse(data); + }); + + describe(`layer without real user mask`, () => { + let maskedLayer: Layer; + beforeAll(() => { + maskedLayer = psd.layers[0]; + }); + + it(`should not parse the mask real flags if they are missing`, () => { + expect(maskedLayer.maskData).toStrictEqual({ + backgroundColor: 0, + top: 39, + bottom: 59, + left: 579, + right: 600, + flags: { + invertMaskWhenBlending: false, + layerMaskDisabled: false, + masksHaveParametersApplied: false, + positionRelativeToLayer: false, + userMaskFromRenderingOtherData: true, + }, + parameters: undefined, + realData: undefined, + }); + }); + + it(`should extract mask pixels`, async () => { + const mask = await maskedLayer.userMask(); + // NOTE: maybe we should introduce decoding into some grayscale format instead? + // 21 (width) * 20 (height) * 4 (since we decompress into RGBA format) + expect(mask).toHaveLength(1680); + expect(await maskedLayer.realUserMask()).toBeUndefined(); + + const hash = crypto.createHash("sha256").update(mask).digest("hex"); + // NOTE: when changing the hash, please make sure the result is coherent :) + // Either using https://www.npmjs.com/package/canvas or browser build (see README) + // This is a fat arrow pointing right + expect(hash).toEqual( + "b2a77f8a194fcacbad5e5918b34086b1dc518c6d1294a4acdc411c3394ea22cc" + ); + }); + }); + + describe(`layer with real user mask`, () => { + let maskedLayer: Layer; + beforeAll(() => { + maskedLayer = psd.layers[3]; + }); + + it(`should parse the mask properties successfully`, () => { + expect(maskedLayer.maskData).toStrictEqual({ + backgroundColor: 0, + top: -31, + bottom: 102, + left: -107, + right: 704, + flags: { + invertMaskWhenBlending: false, + layerMaskDisabled: false, + masksHaveParametersApplied: false, + positionRelativeToLayer: false, + userMaskFromRenderingOtherData: true, + }, + parameters: undefined, + realData: { + backgroundColor: 255, + bottom: 5600, + left: 0, + right: 640, + top: 0, + flags: { + invertMaskWhenBlending: false, + layerMaskDisabled: true, + masksHaveParametersApplied: false, + positionRelativeToLayer: false, + userMaskFromRenderingOtherData: false, + }, + }, + }); + }); + + it(`should extract real mask pixels`, async () => { + const mask = await maskedLayer.realUserMask(); + expect(mask).toHaveLength(14_336_000); + + const hash = crypto.createHash("sha256").update(mask).digest("hex"); + // NOTE: when changing the hash, please make sure the result is coherent :) + // Either using https://www.npmjs.com/package/canvas or browser build (see README) + // This is a vertical black bar with diffusion on top + expect(hash).toEqual( + "2901bf3b6e114c440ecc7d371a592e028bc8538f6c3cb8b823a7a7c74b7c80cd" + ); + }); + }); +});