added thumbs generation for performation and also added a new deck format for registration
This commit is contained in:
@@ -8,6 +8,15 @@ const ignoredFolderNames = new Set(["template", "templates", "example", "example
|
||||
const tarotSuits = ["wands", "cups", "swords", "disks"];
|
||||
const majorTrumpNumbers = Array.from({ length: 22 }, (_, index) => index);
|
||||
const expectedMinorCardCount = 56;
|
||||
const defaultPipRankOrder = ["Ace", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
|
||||
const defaultThumbnailConfig = {
|
||||
root: "thumbs",
|
||||
width: 240,
|
||||
height: 360,
|
||||
fit: "inside",
|
||||
quality: 82
|
||||
};
|
||||
const supportedThumbnailFits = new Set(["contain", "cover", "fill", "inside", "outside"]);
|
||||
const cardBackCandidateExtensions = ["webp", "png", "jpg", "jpeg", "avif", "gif"];
|
||||
|
||||
function isPlainObject(value) {
|
||||
@@ -61,6 +70,71 @@ function readManifestJson(manifestPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeThumbnailConfig(thumbnails) {
|
||||
if (thumbnails == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (thumbnails === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isPlainObject(thumbnails)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
root: asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root,
|
||||
width: Number.isInteger(Number(thumbnails.width)) && Number(thumbnails.width) > 0
|
||||
? Number(thumbnails.width)
|
||||
: defaultThumbnailConfig.width,
|
||||
height: Number.isInteger(Number(thumbnails.height)) && Number(thumbnails.height) > 0
|
||||
? Number(thumbnails.height)
|
||||
: defaultThumbnailConfig.height,
|
||||
fit: asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit,
|
||||
quality: Number.isInteger(Number(thumbnails.quality)) && Number(thumbnails.quality) >= 1 && Number(thumbnails.quality) <= 100
|
||||
? Number(thumbnails.quality)
|
||||
: defaultThumbnailConfig.quality
|
||||
};
|
||||
}
|
||||
|
||||
function validateThumbnailConfig(thumbnails) {
|
||||
if (thumbnails == null || thumbnails === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlainObject(thumbnails)) {
|
||||
return "thumbnails must be an object or false when provided";
|
||||
}
|
||||
|
||||
const root = asNonEmptyString(thumbnails.root) || defaultThumbnailConfig.root;
|
||||
if (!root || root.startsWith("/") || isRemoteAssetPath(root)) {
|
||||
return "thumbnails.root must be a relative folder name";
|
||||
}
|
||||
|
||||
if (thumbnails.width != null && (!Number.isInteger(Number(thumbnails.width)) || Number(thumbnails.width) <= 0)) {
|
||||
return "thumbnails.width must be a positive integer when provided";
|
||||
}
|
||||
|
||||
if (thumbnails.height != null && (!Number.isInteger(Number(thumbnails.height)) || Number(thumbnails.height) <= 0)) {
|
||||
return "thumbnails.height must be a positive integer when provided";
|
||||
}
|
||||
|
||||
if (thumbnails.quality != null) {
|
||||
const quality = Number(thumbnails.quality);
|
||||
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
|
||||
return "thumbnails.quality must be an integer between 1 and 100 when provided";
|
||||
}
|
||||
}
|
||||
|
||||
const fit = asNonEmptyString(thumbnails.fit) || defaultThumbnailConfig.fit;
|
||||
if (!supportedThumbnailFits.has(fit)) {
|
||||
return `thumbnails.fit must be one of ${Array.from(supportedThumbnailFits).join(", ")}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasNonEmptyStringMapEntries(value) {
|
||||
if (!isPlainObject(value)) {
|
||||
return false;
|
||||
@@ -69,12 +143,20 @@ function hasNonEmptyStringMapEntries(value) {
|
||||
return Object.values(value).some((entryValue) => String(entryValue || "").trim());
|
||||
}
|
||||
|
||||
function hasRankResolver(minorRule) {
|
||||
function getNormalizedRankOrder(rankSource, fallbackRankOrder = []) {
|
||||
const explicitRankOrder = Array.isArray(rankSource?.rankOrder) ? rankSource.rankOrder : [];
|
||||
const rankOrderSource = explicitRankOrder.length ? explicitRankOrder : fallbackRankOrder;
|
||||
return rankOrderSource
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function hasRankResolver(minorRule, fallbackRankOrder = []) {
|
||||
if (!isPlainObject(minorRule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder.filter((entry) => String(entry || "").trim()) : [];
|
||||
const rankOrder = getNormalizedRankOrder(minorRule, fallbackRankOrder);
|
||||
if (rankOrder.length) {
|
||||
return true;
|
||||
}
|
||||
@@ -166,6 +248,27 @@ function validateMinorsRule(minors) {
|
||||
return "minors.mode is required";
|
||||
}
|
||||
|
||||
if (mode === "split-number-template") {
|
||||
const courtsError = validateMinorNumberTemplateGroup(minors.courts, {
|
||||
label: "minors.courts",
|
||||
expectedRankCount: 4
|
||||
});
|
||||
if (courtsError) {
|
||||
return courtsError;
|
||||
}
|
||||
|
||||
const smallsError = validateMinorNumberTemplateGroup(minors.smalls, {
|
||||
label: "minors.smalls",
|
||||
expectedRankCount: defaultPipRankOrder.length,
|
||||
fallbackRankOrder: defaultPipRankOrder
|
||||
});
|
||||
if (smallsError) {
|
||||
return smallsError;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!hasRankResolver(minors)) {
|
||||
return "minors must define rankOrder or rankIndexByKey";
|
||||
}
|
||||
@@ -205,6 +308,38 @@ function validateMinorsRule(minors) {
|
||||
return `unsupported minors.mode '${mode}'`;
|
||||
}
|
||||
|
||||
function validateMinorNumberTemplateGroup(groupRule, options = {}) {
|
||||
const label = String(options.label || "minor group");
|
||||
const expectedRankCount = Number(options.expectedRankCount);
|
||||
const fallbackRankOrder = Array.isArray(options.fallbackRankOrder) ? options.fallbackRankOrder : [];
|
||||
|
||||
if (!isPlainObject(groupRule)) {
|
||||
return `${label} must be an object`;
|
||||
}
|
||||
|
||||
if (!hasRankResolver(groupRule, fallbackRankOrder)) {
|
||||
return `${label} must define rankOrder or rankIndexByKey`;
|
||||
}
|
||||
|
||||
if (getRankEntries(groupRule, fallbackRankOrder).length !== expectedRankCount) {
|
||||
return `${label} must define exactly ${expectedRankCount} ranks`;
|
||||
}
|
||||
|
||||
if (!hasSuitNumberMap(groupRule.suitBase)) {
|
||||
return `${label}.suitBase must define numeric bases for wands, cups, swords, and disks`;
|
||||
}
|
||||
|
||||
if (!asNonEmptyString(groupRule.template)) {
|
||||
return `${label}.template is required`;
|
||||
}
|
||||
|
||||
if (groupRule.numberPad != null && !Number.isInteger(Number(groupRule.numberPad))) {
|
||||
return `${label}.numberPad must be an integer when provided`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateDeckManifest(manifest) {
|
||||
const errors = [];
|
||||
|
||||
@@ -250,15 +385,16 @@ function validateDeckManifest(manifest) {
|
||||
errors.push("cardBack must be a non-empty string when provided");
|
||||
}
|
||||
|
||||
const thumbnailsError = validateThumbnailConfig(manifest.thumbnails);
|
||||
if (thumbnailsError) {
|
||||
errors.push(thumbnailsError);
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function getRankEntries(minors) {
|
||||
const rankOrder = Array.isArray(minors?.rankOrder)
|
||||
? minors.rankOrder
|
||||
.map((entry) => String(entry || "").trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
function getRankEntries(minors, fallbackRankOrder = []) {
|
||||
const rankOrder = getNormalizedRankOrder(minors, fallbackRankOrder);
|
||||
|
||||
if (rankOrder.length) {
|
||||
return rankOrder.map((rankWord, rankIndex) => ({
|
||||
@@ -311,6 +447,13 @@ function getReferencedMinorFiles(manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (mode === "split-number-template") {
|
||||
return [
|
||||
...getReferencedSplitMinorGroupFiles(minors.courts),
|
||||
...getReferencedSplitMinorGroupFiles(minors.smalls, defaultPipRankOrder)
|
||||
];
|
||||
}
|
||||
|
||||
const rankEntries = getRankEntries(minors);
|
||||
if (!rankEntries.length) {
|
||||
return [];
|
||||
@@ -367,6 +510,27 @@ function getReferencedMinorFiles(manifest) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function getReferencedSplitMinorGroupFiles(groupRule, fallbackRankOrder = []) {
|
||||
const rankEntries = getRankEntries(groupRule, fallbackRankOrder);
|
||||
if (!rankEntries.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = String(groupRule?.template || "{number}.png");
|
||||
const numberPad = Number.isInteger(Number(groupRule?.numberPad)) ? Number(groupRule.numberPad) : 2;
|
||||
|
||||
return tarotSuits.flatMap((suitId) => {
|
||||
const suitBase = Number(groupRule?.suitBase?.[suitId]);
|
||||
return rankEntries.map((entry) => applyTemplate(template, {
|
||||
number: String(suitBase + entry.rankIndex).padStart(numberPad, "0"),
|
||||
rank: entry.rankWord,
|
||||
rankKey: entry.rankKey,
|
||||
suitId,
|
||||
index: entry.rankIndex
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function getReferencedCardBackFiles(manifest) {
|
||||
const cardBack = asNonEmptyString(manifest?.cardBack);
|
||||
if (!cardBack || isRemoteAssetPath(cardBack)) {
|
||||
@@ -491,6 +655,7 @@ function compileDeckRegistry() {
|
||||
const label = labelFromManifest || folderName;
|
||||
const basePath = `asset/tarot deck/${folderName}`;
|
||||
const cardBackPath = detectDeckCardBackRelativePath(folderName, manifest);
|
||||
const thumbnailConfig = normalizeThumbnailConfig(manifest.thumbnails);
|
||||
|
||||
if (seenIds.has(id)) {
|
||||
warnings.push(`Skipped '${folderName}': duplicate deck id '${id}'`);
|
||||
@@ -504,6 +669,7 @@ function compileDeckRegistry() {
|
||||
label,
|
||||
basePath,
|
||||
manifestPath: `${basePath}/deck.json`,
|
||||
...(thumbnailConfig && thumbnailConfig !== false ? { thumbnailRoot: thumbnailConfig.root } : {}),
|
||||
...(cardBackPath ? { cardBackPath } : {})
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user