@nwpr/pass-js
Version:
Apple Wallet Pass generating and pushing updates from Node.js
179 lines • 8.77 kB
JavaScript
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/**
* Base PassImages class to add image filePath manipulation
*/
import { createReadStream, promises as fs } from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import imagesize from 'imagesize';
import { DENSITIES, IMAGES } from '../constants.js';
import { normalizeLocale } from './normalize-locale.js';
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const imageSize = promisify(imagesize);
const IMAGES_TYPES = new Set(Object.keys(IMAGES));
export const IMAGE_FILENAME_REGEX = new RegExp(`(^|/)((?<lang>[-A-Z_a-z]+).lproj/)?(?<imageType>${Object.keys(IMAGES).join('|')}+)(@(?<density>[23]x))?.png$`);
export class PassImages extends Map {
constructor(images) {
super(images instanceof PassImages ? [...images] : undefined);
}
async toArray() {
return Promise.all([...this].map(async ([filepath, pathOrBuffer]) => ({
path: filepath,
data: typeof pathOrBuffer === 'string'
? await fs.readFile(pathOrBuffer)
: pathOrBuffer,
})));
}
/**
* Checks that all required images is set or throws elsewhere
*/
validate() {
const keys = [...this.keys()];
// Check for required images
for (const requiredImage of ['icon', 'logo'])
if (!keys.some(img => img.endsWith(`${requiredImage}.png`)))
throw new SyntaxError(`Missing required image ${requiredImage}.png`);
}
/**
* Load all images from the specified directory. Only supported images are
* loaded, nothing bad happens if directory contains other files.
*
* @param {string} dirPath - path to a directory with images
* @memberof PassImages
*/
async load(dirPath) {
// Check if the path is accessible directory actually
const entries = await fs.readdir(dirPath, { withFileTypes: true });
// checking rest of files
const entriesLoader = [];
for (const entry of entries) {
if (entry.isDirectory()) {
// check if it's a localization folder
const test = /(?<lang>[-A-Z_a-z]+)\.lproj/.exec(entry.name);
if (!test?.groups?.lang)
continue;
const { lang } = test.groups;
// reading this directory
const currentPath = path.join(dirPath, entry.name);
const localizations = await fs.readdir(currentPath, {
withFileTypes: true,
});
// check if we have any localized images
for (const f of localizations) {
const img = this.parseFilename(f.name);
if (img)
entriesLoader.push(this.add(img.imageType, path.join(currentPath, f.name), img.density, lang));
}
}
else {
// check it it's an image
const img = this.parseFilename(entry.name);
if (img)
entriesLoader.push(this.add(img.imageType, path.join(dirPath, entry.name), img.density));
}
}
await Promise.all(entriesLoader);
return this;
}
async add(imageType, pathOrBuffer, density, lang) {
if (!IMAGES_TYPES.has(imageType))
throw new TypeError(`Unknown image type ${imageType}`);
if (density && !DENSITIES.has(density))
throw new TypeError(`Invalid density ${density} for ${imageType}`);
// check data
let sizeRes;
if (typeof pathOrBuffer === 'string') {
// PNG size is in first 24 bytes
const rs = createReadStream(pathOrBuffer, { highWaterMark: 30 });
sizeRes = await imageSize(rs);
// see https://github.com/nodejs/node/issues/25335#issuecomment-451945106
rs.once('readable', () => rs.destroy());
}
else {
if (!Buffer.isBuffer(pathOrBuffer))
throw new TypeError(`Image data for ${imageType} must be either file path or buffer`);
const { Parser } = imagesize;
const parser = Parser();
const res = parser.parse(pathOrBuffer);
if (res !== Parser.DONE)
throw new TypeError(`Supplied buffer doesn't contain valid PNG image for ${imageType}`);
sizeRes = parser.getResult();
}
this.checkImage(imageType, sizeRes, density);
super.set(this.getImageFilename(imageType, density, lang), pathOrBuffer);
}
parseFilename(fileName) {
const test = IMAGE_FILENAME_REGEX.exec(fileName);
if (!test?.groups)
return undefined;
const res = { imageType: test.groups.imageType };
if (test.groups.density)
res.density = test.groups.density;
if (test.groups.lang)
res.lang = normalizeLocale(test.groups.lang);
return res;
}
// eslint-disable-next-line complexity
checkImage(imageType, sizeResult, density) {
const densityMulti = density ? parseInt(density.charAt(0), 10) : 1;
const { format, width, height } = sizeResult;
if (format !== 'png')
throw new TypeError(`Image for "${imageType}" is not a PNG file!`);
if (!Number.isInteger(width) || width <= 0)
throw new TypeError(`Image ${imageType} has invalid width: ${width}`);
if (!Number.isInteger(height) || height <= 0)
throw new TypeError(`Image ${imageType} has invalid height: ${height}`);
/**
* @see {@link https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Creating.html}
*/
switch (imageType) {
case 'icon':
if (width < 29 * densityMulti)
throw new TypeError(`icon image must have width ${29 * densityMulti}px for ${densityMulti}x density`);
if (height < 29 * densityMulti)
throw new TypeError(`icon image must have height ${29 * densityMulti}px for ${densityMulti}x density`);
break;
case 'logo':
if (width > 160 * densityMulti)
throw new TypeError(`logo image must have width no large than ${160 * densityMulti}px for ${densityMulti}x density`);
// if (height > 50 * densityMulti)
// throw new TypeError(
// `logo image must have height ${50 *
// densityMulti}px for ${densityMulti}x density, received ${height}`,
// );
break;
case 'background':
if (width > 180 * densityMulti)
throw new TypeError(`background image must have width ${180 * densityMulti}px for ${densityMulti}x density`);
if (height > 220 * densityMulti)
throw new TypeError(`background image must have height ${220 * densityMulti}px for ${densityMulti}x density`);
break;
case 'footer':
if (width > 286 * densityMulti)
throw new TypeError(`footer image must have width ${286 * densityMulti}px for ${densityMulti}x density`);
if (height > 15 * densityMulti)
throw new TypeError(`footer image must have height ${15 * densityMulti}px for ${densityMulti}x density`);
break;
case 'strip':
// if (width > 375 * densityMulti)
// throw new TypeError(
// `strip image must have width ${375 *
// densityMulti}px for ${densityMulti}x density, received ${width}`,
// );
if (height > 144 * densityMulti)
throw new TypeError(`strip image must have height ${144 * densityMulti}px for ${densityMulti}x density`);
break;
case 'thumbnail':
if (width > 120 * densityMulti)
throw new TypeError(`thumbnail image must have width no large than ${90 * densityMulti}px for ${densityMulti}x density, received ${width}`);
if (height > 150 * densityMulti)
throw new TypeError(`thumbnail image must have height ${90 * densityMulti}px for ${densityMulti}x density, received ${height}`);
break;
}
}
getImageFilename(imageType, density, lang) {
return `${lang ? `${normalizeLocale(lang)}.lproj/` : ''}${imageType}${/^[23]x$/.test(density || '') ? `@${density}` : ''}.png`;
}
}
//# sourceMappingURL=images.js.map