UNPKG

@walletpass/pass-js

Version:

Apple Wallet Pass generating and pushing updates from Node.js

191 lines 9.64 kB
// 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