Skip to content

Instantly share code, notes, and snippets.

@benvium
Created October 9, 2023 09:00
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benvium/80bc94a2b6c6f53328db9802c497e998 to your computer and use it in GitHub Desktop.
Save benvium/80bc94a2b6c6f53328db9802c497e998 to your computer and use it in GitHub Desktop.
Xcode .xcassets color parser. Exports xcassets-format colors into a single hex format, with separate light and dark mode values.
import fs from 'fs';
import * as path from 'path';
import * as z from 'zod';
//----------------------------------------------------------------
//
// Outputs all xcassets-format colors into hex format, with separate light and dark mode values.
// IMPORTANT: this only covers a couple of the color formats Xcode uses, and may not properly handle color spaces etc.
//
// Usage:
// ts-node ios-xcassets-parser <path-to-xcassets-file> (e.g. /Resources/ColorCatalog.xcassets)
//
// Outputs:
// gray01 LIGHT #000000, DARK #FFFFFF
// gray02 LIGHT #2A2A2A, DARK #EEEEEE
// gray03 LIGHT #656565, DARK #DDDDDD
// gray04 LIGHT #888888, DARK #CCCCCC
//----------------------------------------------------------------
// the 1st param to this cli command should be a path to an xcassets file
if (process.argv.length < 3) {
console.log('Usage: ts-node ios-xcassets-parser <path-to-xcassets-file>');
process.exit(1);
}
//check the file exists
const INPUT_XCASSETS_FILE = process.argv[2];
if (!fs.existsSync(INPUT_XCASSETS_FILE)) {
console.log(`File ${INPUT_XCASSETS_FILE} does not exist`);
process.exit(1);
}
//----------------------------------------------------------------
// Zod Scheme for the JSON files
//----------------------------------------------------------------
const ColorItemZ = z.object({
color: z.object({
'color-space': z.string(), // display-p3
components: z.union([
z.object({
alpha: z.string(),
blue: z.string(),
green: z.string(),
red: z.string(),
}),
z.object({
white: z.string(),
alpha: z.string(),
}),
]),
}),
appearances: z
.array(
z.object({
appearance: z.string(), // luminosity
value: z.string(), // dark
})
)
.optional(),
idiom: z.string(),
});
type ColorItemT = z.infer<typeof ColorItemZ>;
const XCAssetColorZ = z.object({
colors: z.array(ColorItemZ),
info: z.object({
author: z.string(),
version: z.number(),
}),
});
const findJsonFiles = (dir: string, fileList: string[] = []) => {
const files = fs.readdirSync(dir);
files.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat.isDirectory()) {
findJsonFiles(filePath, fileList);
} else if (filePath.endsWith('.json')) {
fileList.push(filePath);
}
});
return fileList;
};
const jsonFiles = findJsonFiles(INPUT_XCASSETS_FILE);
function numberToHex(number: number) {
// convert 0-255 to two-digit hex
const hex = Math.floor(Math.min(255, number)).toString(16).padStart(2, '0');
return hex.toUpperCase();
}
function numberStringToHex(numberString: string) {
// if has decimal places, then it's in 0-1 format
if (numberString.includes('.')) {
return numberToHex(Number(numberString) * 256);
} else if (numberString.includes('x')) {
return numberString.replace('0x', '');
} else {
// assume a number 0-255
return numberToHex(Number(numberString));
}
}
function colorToDescription(item: ColorItemT) {
const appearances = item.appearances?.map(a => `${a.value.toUpperCase()}`).join(', ');
const prefix = appearances ?? 'LIGHT';
// if these are hex...
const {components} = item.color;
// support rgb format
if ('red' in components) {
const ar = [components.red, components.green, components.blue];
let asHex = ar.map(numberStringToHex).join('');
if (Number(components.alpha) < 1) {
asHex += ` (alpha ${Number(components.alpha).toFixed(2)})`;
}
return `${prefix} #${asHex}`;
}
// support white and alpha formt
if ('white' in components) {
return `${prefix} ${components.white} white ${Number(components.alpha).toFixed(2)} alpha`;
}
throw new Error('Unknown color format ' + JSON.stringify(item, null, 2));
}
for (const file of jsonFiles) {
// regex out the name of the colorset folder (e.g. 'mainBackgroundColor.colorset/Contents.json', -> mainBackgroundColor)
const colorSetName = file.match(/\/([^\/]+)\.colorset/)?.[1];
if (!colorSetName) {
// there are some 'index' files in the xcassets folder, ignore them
continue;
}
const json = JSON.parse(fs.readFileSync(file, 'utf8'));
try {
const colorData = XCAssetColorZ.parse(json);
const colors = colorData.colors.map(c => colorToDescription(c)).join(', ');
console.log(`${colorSetName} ${colors}`);
} catch (e) {
console.log(`Error parsing ${colorSetName}: ${(e as any).message} ${JSON.stringify(json, null, 2)}`);
}
}
{
"dependencies": {
"typescript": "4.9.3",
"zod": "3.20.0"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment