ag-psd
Version:
Library for reading and writing PSD files
783 lines (687 loc) • 21.8 kB
text/typescript
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<T>(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<Font>(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<FontSet>(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;
}