@walletpass/pass-js
Version:
Apple Wallet Pass generating and pushing updates from Node.js
191 lines • 9.64 kB
JavaScript
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2017-2026 Konstantin Vyatkin <tino@vtkn.io>
/**
* Base PassImages class to add image filePath manipulation
*/
import * as path from 'path';
import { promises as fs } from 'fs';
import { IMAGES, DENSITIES } from '../constants.js';
import { normalizeLocale } from './normalize-locale.js';
import { readPngDimensions, readPngDimensionsFromFile, } from './png-size.js';
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, disableImageCheck) {
// 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, disableImageCheck));
}
}
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, undefined, disableImageCheck));
}
}
await Promise.all(entriesLoader);
return this;
}
async add(imageType, pathOrBuffer, density, lang, disableImageCheck) {
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}`);
// Verify PNG dimensions unless the user opted out. The reader only
// touches the first 24 bytes, so large files from disk aren't loaded
// into memory just to run the dimension check.
if (!disableImageCheck) {
let dims;
if (typeof pathOrBuffer === 'string') {
try {
dims = await readPngDimensionsFromFile(pathOrBuffer);
}
catch (err) {
throw new TypeError(`Image for "${imageType}" at ${pathOrBuffer} is not a valid PNG: ${err.message}`, { cause: err });
}
}
else if (Buffer.isBuffer(pathOrBuffer)) {
try {
dims = readPngDimensions(pathOrBuffer);
}
catch (err) {
throw new TypeError(`Supplied buffer for "${imageType}" is not a valid PNG: ${err.message}`, { cause: err });
}
}
else {
throw new TypeError(`Image data for ${imageType} must be either file path or buffer`);
}
this.checkImage(imageType, dims, density);
}
else if (typeof pathOrBuffer !== 'string' &&
!Buffer.isBuffer(pathOrBuffer)) {
// Shape check still applies even when dimension validation is off.
throw new TypeError(`Image data for ${imageType} must be either file path or buffer`);
}
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, dims, density) {
const densityMulti = density ? parseInt(density.charAt(0), 10) : 1;
const { width, height } = dims;
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 larger 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 larger than ${120 * densityMulti}px for ${densityMulti}x density, received ${width}`);
if (height > 150 * densityMulti)
throw new TypeError(`thumbnail image must have height no larger than ${150 * densityMulti}px for ${densityMulti}x density, received ${height}`);
break;
case 'personalizationLogo':
if (width > 150 * densityMulti)
throw new TypeError(`personalizationLogo image must have width no larger than ${150 * densityMulti}px for ${densityMulti}x density, received ${width}`);
if (height > 40 * densityMulti)
throw new TypeError(`personalizationLogo image must have height no larger than ${40 * 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