import type { TextStyle, LayerTextData, ParagraphStyle, Font, AntiAlias, TextGridInfo, Justification, Color, TextPath } from './psd'; interface Adjustments { Axis: number[]; XY: number[]; } interface TypeValues { Type: number; Values: number[]; } interface ParagraphProperties { Justification?: number; FirstLineIndent?: number; StartIndent?: number; EndIndent?: number; SpaceBefore?: number; SpaceAfter?: number; AutoHyphenate?: boolean; HyphenatedWordSize?: number; PreHyphen?: number; PostHyphen?: number; ConsecutiveHyphens?: number; Zone?: number; WordSpacing?: number[]; LetterSpacing?: number[]; GlyphSpacing?: number[]; AutoLeading?: number; LeadingType?: number; Hanging?: boolean; Burasagari?: boolean; KinsokuOrder?: number; EveryLineComposer?: boolean; } interface ParagraphSheet { Name?: string; DefaultStyleSheet: number; Properties: ParagraphProperties; } interface StyleSheetData { Font?: number; FontSize?: number; FauxBold?: boolean; FauxItalic?: boolean; AutoLeading?: boolean; Leading?: number; HorizontalScale?: number; VerticalScale?: number; Tracking?: number; AutoKerning?: boolean; Kerning?: number; BaselineShift?: number; FontCaps?: number; FontBaseline?: number; Underline?: boolean; Strikethrough?: boolean; Ligatures?: boolean; DLigatures?: boolean; BaselineDirection?: number; Tsume?: number; StyleRunAlignment?: number; Language?: number; NoBreak?: boolean; FillColor?: TypeValues; StrokeColor?: TypeValues; FillFlag?: boolean; StrokeFlag?: boolean; FillFirst?: boolean; YUnderline?: number; OutlineWidth?: number; CharacterDirection?: number; HindiNumbers?: boolean; Kashida?: number; DiacriticPos?: number; } interface FontSet { Name: string; Script: number; FontType: number; Synthetic: number; } interface ResourceDict { KinsokuSet: any[]; MojiKumiSet: any[]; TheNormalStyleSheet: number; TheNormalParagraphSheet: number; ParagraphSheetSet: ParagraphSheet[]; StyleSheetSet: { Name: string; StyleSheetData: StyleSheetData; }[]; FontSet: FontSet[]; SuperscriptSize: number; SuperscriptPosition: number; SubscriptSize: number; SubscriptPosition: number; SmallCapSize: number; } interface ParagraphRun { ParagraphSheet: ParagraphSheet; Adjustments: Adjustments; } interface StyleRun { StyleSheet: { StyleSheetData: StyleSheetData; }; } interface PhotoshopNode { ShapeType?: number; PointBase?: number[]; BoxBounds?: number[]; Base?: { ShapeType: number; TransformPoint0: number[]; TransformPoint1: number[]; TransformPoint2: number[]; }; } export interface EngineData { EngineDict: { Editor: { Text: string; }; ParagraphRun: { DefaultRunData: ParagraphRun; RunArray: ParagraphRun[]; RunLengthArray: number[]; IsJoinable: number; }; StyleRun: { DefaultRunData: StyleRun; RunArray: StyleRun[]; RunLengthArray: number[]; IsJoinable: number; }; GridInfo: { GridIsOn: boolean; ShowGrid: boolean; GridSize: number; GridLeading: number; GridColor: TypeValues; GridLeadingFillColor: TypeValues; AlignLineHeightToGridFlags: boolean; }; AntiAlias: number; UseFractionalGlyphWidths: boolean; Rendered?: { Version: number; Shapes?: { WritingDirection: number; Children?: { ShapeType?: number; Procession: number; Lines: { WritingDirection: number; Children: any[]; }; Cookie?: { Photoshop?: PhotoshopNode; }; }[]; }; }; }; ResourceDict: ResourceDict; DocumentResources: ResourceDict; } export interface GlobalEngineData { ResourceDict: { FontSet: any[]; // TODO StyleSheetSet: any[]; // TODO ParagraphSheetSet: any[]; // TODO TextFrameSet?: { path: TextPath; }[]; }; EngineDict: { // TODO }; } const defaultFont: Font = { name: 'MyriadPro-Regular', script: 0, type: 0, synthetic: 0, }; const defaultParagraphStyle: ParagraphStyle = { justification: 'left', firstLineIndent: 0, startIndent: 0, endIndent: 0, spaceBefore: 0, spaceAfter: 0, autoHyphenate: true, hyphenatedWordSize: 6, preHyphen: 2, postHyphen: 2, consecutiveHyphens: 8, zone: 36, wordSpacing: [0.8, 1, 1.33], letterSpacing: [0, 0, 0], glyphSpacing: [1, 1, 1], autoLeading: 1.2, leadingType: 0, hanging: false, burasagari: false, kinsokuOrder: 0, everyLineComposer: false, }; const defaultStyle: TextStyle = { font: defaultFont, fontSize: 12, fauxBold: false, fauxItalic: false, autoLeading: true, leading: 0, horizontalScale: 1, verticalScale: 1, tracking: 0, autoKerning: true, kerning: 0, baselineShift: 0, fontCaps: 0, fontBaseline: 0, underline: false, strikethrough: false, ligatures: true, dLigatures: false, baselineDirection: 2, tsume: 0, styleRunAlignment: 2, language: 0, noBreak: false, fillColor: { r: 0, g: 0, b: 0 }, strokeColor: { r: 0, g: 0, b: 0 }, fillFlag: true, strokeFlag: false, fillFirst: true, yUnderline: 1, outlineWidth: 1, characterDirection: 0, hindiNumbers: false, kashida: 1, diacriticPos: 2, }; const defaultGridInfo: TextGridInfo = { isOn: false, show: false, size: 18, leading: 22, color: { r: 0, g: 0, b: 255 }, leadingFillColor: { r: 0, g: 0, b: 255 }, alignLineHeightToGridFlags: false, }; const paragraphStyleKeys: (keyof ParagraphStyle)[] = [ 'justification', 'firstLineIndent', 'startIndent', 'endIndent', 'spaceBefore', 'spaceAfter', 'autoHyphenate', 'hyphenatedWordSize', 'preHyphen', 'postHyphen', 'consecutiveHyphens', 'zone', 'wordSpacing', 'letterSpacing', 'glyphSpacing', 'autoLeading', 'leadingType', 'hanging', 'burasagari', 'kinsokuOrder', 'everyLineComposer', ]; const styleKeys: (keyof TextStyle)[] = [ 'font', 'fontSize', 'fauxBold', 'fauxItalic', 'autoLeading', 'leading', 'horizontalScale', 'verticalScale', 'tracking', 'autoKerning', 'kerning', 'baselineShift', 'fontCaps', 'fontBaseline', 'underline', 'strikethrough', 'ligatures', 'dLigatures', 'baselineDirection', 'tsume', 'styleRunAlignment', 'language', 'noBreak', 'fillColor', 'strokeColor', 'fillFlag', 'strokeFlag', 'fillFirst', 'yUnderline', 'outlineWidth', 'characterDirection', 'hindiNumbers', 'kashida', 'diacriticPos', ]; const antialias: AntiAlias[] = ['none', 'crisp', 'strong', 'smooth', 'sharp']; const justification: Justification[] = [ 'left', // 0 'right', // 1 'center', // 2 'justify-left', // 3 'justify-right', // 4 'justify-center', // 5 'justify-all', // 6 ]; function upperFirst(value: string) { return value.substring(0, 1).toUpperCase() + value.substring(1); } function decodeColor(color: TypeValues): Color { const c = color.Values; switch (color.Type) { case 0: return { k: c[1] * 255 }; // grayscale (alpha?) case 1: return c[0] === 1 ? { r: c[1] * 255, g: c[2] * 255, b: c[3] * 255 } : // rgb { r: c[1] * 255, g: c[2] * 255, b: c[3] * 255, a: c[0] * 255 }; // rgba case 2: return { c: c[1] * 255, m: c[2] * 255, y: c[3] * 255, k: c[4] * 255 }; // cmyk (alpha?) default: throw new Error('Unknown color type in text layer'); } } function encodeColor(color: Color | undefined): TypeValues { if (!color) { return { Type: 1, Values: [0, 0, 0, 0] }; } else if ('r' in color) { return { Type: 1, Values: ['a' in color ? color.a / 255 : 1, color.r / 255, color.g / 255, color.b / 255] }; } else if ('c' in color) { return { Type: 2, Values: [1, color.c / 255, color.m / 255, color.y / 255, color.k / 255] }; } else if ('k' in color) { return { Type: 0, Values: [1, color.k / 255] }; } else { throw new Error('Invalid color type in text layer'); } } function arraysEqual(a: any[], b: any[]) { if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; return true; } function objectsEqual(a: any, b: any) { if (!a || !b) return false; for (const key of Object.keys(a)) if (a[key] !== b[key]) return false; for (const key of Object.keys(b)) if (a[key] !== b[key]) return false; return true; } function findOrAddFont(fonts: Font[], font: Font) { for (let i = 0; i < fonts.length; i++) { if (fonts[i].name === font.name) return i; } fonts.push(font); return fonts.length - 1; } function decodeObject(obj: any, keys: string[], fonts: Font[]) { const result: any = {}; for (const key of keys) { const Key = upperFirst(key); if (obj[Key] === undefined) continue; if (key === 'justification') { result[key] = justification[obj[Key]]; } else if (key === 'font') { result[key] = fonts[obj[Key]]; } else if (key === 'fillColor' || key === 'strokeColor') { result[key] = decodeColor(obj[Key]); } else { result[key] = obj[Key]; } } return result; } function encodeObject(obj: any, keys: string[], fonts: Font[]) { const result: any = {}; for (const key of keys) { const Key = upperFirst(key); if (obj[key] === undefined) continue; if (key === 'justification') { result[Key] = justification.indexOf(obj[key] ?? 'left'); } else if (key === 'font') { result[Key] = findOrAddFont(fonts, obj[key]); } else if (key === 'fillColor' || key === 'strokeColor') { result[Key] = encodeColor(obj[key]); } else { result[Key] = obj[key]; } } return result; } function decodeParagraphStyle(obj: ParagraphProperties, fonts: Font[]): ParagraphStyle { return decodeObject(obj, paragraphStyleKeys, fonts); } function decodeStyle(obj: StyleSheetData, fonts: Font[]): TextStyle { return decodeObject(obj, styleKeys, fonts); } function encodeParagraphStyle(obj: ParagraphStyle, fonts: Font[]): ParagraphProperties { return encodeObject(obj, paragraphStyleKeys, fonts); } function encodeStyle(obj: TextStyle, fonts: Font[]): StyleSheetData { return encodeObject(obj, styleKeys, fonts); } function deduplicateValues(base: T, runs: { style: T; }[], keys: (keyof T)[]) { if (!runs.length) return; for (const key of keys) { const value = runs[0].style[key]; if (value !== undefined) { let identical = false; if (Array.isArray(value)) { identical = runs.every(r => arraysEqual(r.style[key] as any, value)); } else if (typeof value === 'object') { identical = runs.every(r => objectsEqual(r.style[key] as any, value)); } else { identical = runs.every(r => r.style[key] === value); } if (identical) { base[key] = value as any; } } const styleValue = base[key]; if (styleValue !== undefined) { for (const r of runs) { let same = false; if (Array.isArray(value)) { same = arraysEqual(r.style[key] as any, value); } else if (typeof value === 'object') { same = objectsEqual(r.style[key] as any, value); } else { same = r.style[key] === value; } if (same) delete r.style[key]; } } } if (runs.every(x => Object.keys(x.style as any).length === 0)) { runs.length = 0; } } export function decodeEngineData(engineData: EngineData) { // console.log('engineData', require('util').inspect(engineData, false, 99, true)); const engineDict = engineData.EngineDict; const resourceDict = engineData.ResourceDict; const fonts = resourceDict.FontSet.map(f => ({ name: f.Name, script: f.Script, type: f.FontType, synthetic: f.Synthetic, })); let text = engineDict.Editor.Text.replace(/\r/g, '\n'); let removedCharacters = 0; while (/\n$/.test(text)) { text = text.substring(0, text.length - 1); removedCharacters++; } const result: LayerTextData = { text, antiAlias: antialias[engineDict.AntiAlias] ?? 'smooth', useFractionalGlyphWidths: !!engineDict.UseFractionalGlyphWidths, superscriptSize: resourceDict.SuperscriptSize, superscriptPosition: resourceDict.SuperscriptPosition, subscriptSize: resourceDict.SubscriptSize, subscriptPosition: resourceDict.SubscriptPosition, smallCapSize: resourceDict.SmallCapSize, }; // shape const photoshop = engineDict.Rendered?.Shapes?.Children?.[0]?.Cookie?.Photoshop; if (photoshop) { result.shapeType = photoshop.ShapeType === 1 ? 'box' : 'point'; if (photoshop.PointBase) result.pointBase = photoshop.PointBase; if (photoshop.BoxBounds) result.boxBounds = photoshop.BoxBounds; } // paragraph style // const theNormalParagraphSheet = resourceDict.TheNormalParagraphSheet; // const paragraphSheetSet = resourceDict.ParagraphSheetSet; // const paragraphProperties = paragraphSheetSet[theNormalParagraphSheet].Properties; const paragraphRun = engineDict.ParagraphRun; result.paragraphStyle = {}; // decodeParagraphStyle(paragraphProperties, fonts); result.paragraphStyleRuns = []; for (let i = 0; i < paragraphRun.RunArray.length; i++) { const run = paragraphRun.RunArray[i]; const length = paragraphRun.RunLengthArray[i]; const style = decodeParagraphStyle(run.ParagraphSheet.Properties, fonts); // const adjustments = { // axis: run.Adjustments.Axis, // xy: run.Adjustments.XY, // }; result.paragraphStyleRuns.push({ length, style/*, adjustments*/ }); } for (let counter = removedCharacters; result.paragraphStyleRuns.length && counter > 0; counter--) { if (--result.paragraphStyleRuns[result.paragraphStyleRuns.length - 1].length === 0) { result.paragraphStyleRuns.pop(); } } deduplicateValues(result.paragraphStyle, result.paragraphStyleRuns, paragraphStyleKeys); if (!result.paragraphStyleRuns.length) delete result.paragraphStyleRuns; // style // const theNormalStyleSheet = resourceDict.TheNormalStyleSheet; // const styleSheetSet = resourceDict.StyleSheetSet; // const styleSheetData = styleSheetSet[theNormalStyleSheet].StyleSheetData; const styleRun = engineDict.StyleRun; result.style = {}; // decodeStyle(styleSheetData, fonts); result.styleRuns = []; for (let i = 0; i < styleRun.RunArray.length; i++) { const length = styleRun.RunLengthArray[i]; const style = decodeStyle(styleRun.RunArray[i].StyleSheet.StyleSheetData, fonts); if (!style.font) style.font = fonts[0]; result.styleRuns.push({ length, style }); } for (let counter = removedCharacters; result.styleRuns.length && counter > 0; counter--) { if (--result.styleRuns[result.styleRuns.length - 1].length === 0) { result.styleRuns.pop(); } } deduplicateValues(result.style, result.styleRuns, styleKeys); if (!result.styleRuns.length) delete result.styleRuns; return result; } export function encodeEngineData(data: LayerTextData) { const text = `${(data.text || '').replace(/\r?\n/g, '\r')}\r`; const fonts: Font[] = [ { name: 'AdobeInvisFont', script: 0, type: 0, synthetic: 0 }, ]; const defFont = data.style?.font || data.styleRuns?.find(s => s.style.font)?.style.font || defaultFont; const paragraphRunArray: ParagraphRun[] = []; const paragraphRunLengthArray: number[] = []; const paragraphRuns = data.paragraphStyleRuns; if (paragraphRuns && paragraphRuns.length) { let leftLength = text.length; for (const run of paragraphRuns) { let runLength = Math.min(run.length, leftLength); leftLength -= runLength; if (!runLength) continue; // ignore 0 size runs // extend last run if it's only for trailing \r if (leftLength === 1 && run === paragraphRuns[paragraphRuns.length - 1]) { runLength++; leftLength--; } paragraphRunLengthArray.push(runLength); paragraphRunArray.push({ ParagraphSheet: { DefaultStyleSheet: 0, Properties: encodeParagraphStyle({ ...defaultParagraphStyle, ...data.paragraphStyle, ...run.style }, fonts), }, Adjustments: { Axis: [1, 0, 1], XY: [0, 0] }, }); } if (leftLength) { paragraphRunLengthArray.push(leftLength); paragraphRunArray.push({ ParagraphSheet: { DefaultStyleSheet: 0, Properties: encodeParagraphStyle({ ...defaultParagraphStyle, ...data.paragraphStyle }, fonts), }, Adjustments: { Axis: [1, 0, 1], XY: [0, 0] }, }); } } else { for (let i = 0, last = 0; i < text.length; i++) { if (text.charCodeAt(i) === 13) { // \r paragraphRunLengthArray.push(i - last + 1); paragraphRunArray.push({ ParagraphSheet: { DefaultStyleSheet: 0, Properties: encodeParagraphStyle({ ...defaultParagraphStyle, ...data.paragraphStyle }, fonts), }, Adjustments: { Axis: [1, 0, 1], XY: [0, 0] }, }); last = i + 1; } } } const styleSheetData = encodeStyle({ ...defaultStyle, font: defFont }, fonts); const styleRuns = data.styleRuns || [{ length: text.length, style: data.style || {} }]; const styleRunArray: StyleRun[] = []; const styleRunLengthArray: number[] = []; let leftLength = text.length; for (const run of styleRuns) { let runLength = Math.min(run.length, leftLength); leftLength -= runLength; if (!runLength) continue; // ignore 0 size runs // extend last run if it's only for trailing \r if (leftLength === 1 && run === styleRuns[styleRuns.length - 1]) { runLength++; leftLength--; } styleRunLengthArray.push(runLength); styleRunArray.push({ StyleSheet: { StyleSheetData: encodeStyle({ kerning: 0, autoKerning: true, fillColor: { r: 0, g: 0, b: 0 }, ...data.style, ...run.style, }, fonts), }, }); } // add extra run to the end if existing ones didn't fill it up if (leftLength && styleRuns.length) { styleRunLengthArray.push(leftLength); styleRunArray.push({ StyleSheet: { StyleSheetData: encodeStyle({ kerning: 0, autoKerning: true, fillColor: { r: 0, g: 0, b: 0 }, ...data.style, }, fonts), }, }); } const gridInfo = { ...defaultGridInfo, ...data.gridInfo }; const WritingDirection = data.orientation === 'vertical' ? 2 : 0; const Procession = data.orientation === 'vertical' ? 1 : 0; const ShapeType = data.shapeType === 'box' ? 1 : 0; const Photoshop: PhotoshopNode = { ShapeType, }; if (ShapeType === 0) { Photoshop.PointBase = data.pointBase || [0, 0]; } else { Photoshop.BoxBounds = data.boxBounds || [0, 0, 0, 0]; } // needed for correct order of properties Photoshop.Base = { ShapeType, TransformPoint0: [1, 0], TransformPoint1: [0, 1], TransformPoint2: [0, 0], }; const defaultResources = { KinsokuSet: [ { Name: 'PhotoshopKinsokuHard', NoStart: '、。,.・:;?!ー―’”)〕]}〉》」』】ヽヾゝゞ々ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮヵヶ゛゜?!)]},.:;℃℉¢%‰', NoEnd: '‘“(〔[{〈《「『【([{¥$£@§〒#', Keep: '―‥', Hanging: '、。.,', }, { Name: 'PhotoshopKinsokuSoft', NoStart: '、。,.・:;?!’”)〕]}〉》」』】ヽヾゝゞ々', NoEnd: '‘“(〔[{〈《「『【', Keep: '―‥', Hanging: '、。.,', }, ], MojiKumiSet: [ { InternalName: 'Photoshop6MojiKumiSet1' }, { InternalName: 'Photoshop6MojiKumiSet2' }, { InternalName: 'Photoshop6MojiKumiSet3' }, { InternalName: 'Photoshop6MojiKumiSet4' }, ], TheNormalStyleSheet: 0, TheNormalParagraphSheet: 0, ParagraphSheetSet: [ { Name: 'Normal RGB', DefaultStyleSheet: 0, Properties: encodeParagraphStyle({ ...defaultParagraphStyle, ...data.paragraphStyle }, fonts), }, ], StyleSheetSet: [ { Name: 'Normal RGB', StyleSheetData: styleSheetData, }, ], FontSet: fonts.map(f => ({ Name: f.name, Script: f.script || 0, FontType: f.type || 0, Synthetic: f.synthetic || 0, })), SuperscriptSize: data.superscriptSize ?? 0.583, SuperscriptPosition: data.superscriptPosition ?? 0.333, SubscriptSize: data.subscriptSize ?? 0.583, SubscriptPosition: data.subscriptPosition ?? 0.333, SmallCapSize: data.smallCapSize ?? 0.7, }; const engineData: EngineData = { EngineDict: { Editor: { Text: text }, ParagraphRun: { DefaultRunData: { ParagraphSheet: { DefaultStyleSheet: 0, Properties: {} }, Adjustments: { Axis: [1, 0, 1], XY: [0, 0] }, }, RunArray: paragraphRunArray, RunLengthArray: paragraphRunLengthArray, IsJoinable: 1, }, StyleRun: { DefaultRunData: { StyleSheet: { StyleSheetData: {} } }, RunArray: styleRunArray, RunLengthArray: styleRunLengthArray, IsJoinable: 2, }, GridInfo: { GridIsOn: !!gridInfo.isOn, ShowGrid: !!gridInfo.show, GridSize: gridInfo.size ?? 18, GridLeading: gridInfo.leading ?? 22, GridColor: encodeColor(gridInfo.color), GridLeadingFillColor: encodeColor(gridInfo.color), AlignLineHeightToGridFlags: !!gridInfo.alignLineHeightToGridFlags, }, AntiAlias: antialias.indexOf(data.antiAlias ?? 'sharp'), UseFractionalGlyphWidths: data.useFractionalGlyphWidths ?? true, Rendered: { Version: 1, Shapes: { WritingDirection, Children: [ { ShapeType, Procession, Lines: { WritingDirection, Children: [] }, Cookie: { Photoshop }, }, ], }, }, }, ResourceDict: { ...defaultResources }, DocumentResources: { ...defaultResources }, }; // console.log('encodeEngineData', require('util').inspect(engineData, false, 99, true)); return engineData; }