Created
October 9, 2023 09:00
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)}`); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"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