Files
NetMesh/infrastructure/parsers/itermcolorsParser.ts

174 lines
6.2 KiB
TypeScript
Raw Normal View History

import { TerminalTheme } from '../../domain/models';
/**
* Parse an .itermcolors XML plist file into a TerminalTheme.
*
* .itermcolors is Apple Plist XML with color keys like:
* "Ansi 0 Color", "Background Color", "Foreground Color", etc.
* Each color is a <dict> with "Red Component", "Green Component", "Blue Component" as <real> floats (0.01.0).
*/
/** Map .itermcolors key names to TerminalTheme color fields */
const COLOR_KEY_MAP: Record<string, keyof TerminalTheme['colors']> = {
'Ansi 0 Color': 'black',
'Ansi 1 Color': 'red',
'Ansi 2 Color': 'green',
'Ansi 3 Color': 'yellow',
'Ansi 4 Color': 'blue',
'Ansi 5 Color': 'magenta',
'Ansi 6 Color': 'cyan',
'Ansi 7 Color': 'white',
'Ansi 8 Color': 'brightBlack',
'Ansi 9 Color': 'brightRed',
'Ansi 10 Color': 'brightGreen',
'Ansi 11 Color': 'brightYellow',
'Ansi 12 Color': 'brightBlue',
'Ansi 13 Color': 'brightMagenta',
'Ansi 14 Color': 'brightCyan',
'Ansi 15 Color': 'brightWhite',
'Background Color': 'background',
'Foreground Color': 'foreground',
'Cursor Color': 'cursor',
'Selection Color': 'selection',
};
/**
* Convert a float (0.01.0) to a two-digit hex string.
*/
function floatToHex(value: number): string {
const clamped = Math.max(0, Math.min(1, value));
const byte = Math.round(clamped * 255);
return byte.toString(16).padStart(2, '0');
}
/**
* Detect if a background color is dark or light based on relative luminance.
*/
function isDarkBackground(hex: string): boolean {
const r = parseInt(hex.slice(1, 3), 16) / 255;
const g = parseInt(hex.slice(3, 5), 16) / 255;
const b = parseInt(hex.slice(5, 7), 16) / 255;
// Relative luminance formula (ITU-R BT.709)
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance < 0.5;
}
/**
* Parse a single color <dict> element from the plist XML.
* Returns a hex color string like '#rrggbb'.
*/
function parseColorDict(dictElement: Element): string | null {
const children = dictElement.children;
let r = 0, g = 0, b = 0;
let found = 0;
for (let i = 0; i < children.length - 1; i++) {
const child = children[i];
if (child.tagName !== 'key') continue;
const key = child.textContent?.trim();
const valueEl = children[i + 1];
if (!key || !valueEl) continue;
// Accept <real> (float 0.01.0) and <integer> (0255) plist types
const tag = valueEl.tagName;
if (tag !== 'real' && tag !== 'integer') continue;
const raw = parseFloat(valueEl.textContent || '0');
if (isNaN(raw)) continue; // reject non-numeric content
// Normalize: <integer> values are 0-255, <real> values are 0.0-1.0
const value = tag === 'integer' ? raw / 255 : raw;
if (key === 'Red Component') { r = value; found++; }
else if (key === 'Green Component') { g = value; found++; }
else if (key === 'Blue Component') { b = value; found++; }
}
if (found < 3) return null;
return `#${floatToHex(r)}${floatToHex(g)}${floatToHex(b)}`;
}
/**
* Parse an .itermcolors XML string into a TerminalTheme.
*
* @param xml - The raw XML string from the .itermcolors file
* @param name - The theme name (usually derived from the filename)
* @returns - A TerminalTheme, or null if parsing fails
*/
export function parseItermcolors(xml: string, name: string): TerminalTheme | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xml, 'text/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) return null;
// Get the root <dict> inside <plist>
const rootDict = doc.querySelector('plist > dict');
if (!rootDict) return null;
// Parse key-value pairs from the root dict
const colors: Partial<TerminalTheme['colors']> = {};
const children = rootDict.children;
for (let i = 0; i < children.length - 1; i++) {
const child = children[i];
if (child.tagName !== 'key') continue;
const keyName = child.textContent?.trim() || '';
const colorField = COLOR_KEY_MAP[keyName];
if (!colorField) continue;
// The next sibling should be a <dict> with color components
const nextSibling = children[i + 1];
if (!nextSibling || nextSibling.tagName !== 'dict') continue;
const hex = parseColorDict(nextSibling);
if (hex) {
colors[colorField] = hex;
}
}
// Validate we have at least the essential colors
if (!colors.background || !colors.foreground) return null;
// Fill any missing ANSI colors with sensible defaults
const defaults: TerminalTheme['colors'] = {
background: colors.background,
foreground: colors.foreground,
cursor: colors.cursor || colors.foreground,
selection: colors.selection || (isDarkBackground(colors.background) ? '#264f78' : '#add6ff'),
black: colors.black || '#000000',
red: colors.red || '#cc0000',
green: colors.green || '#00cc00',
yellow: colors.yellow || '#cccc00',
blue: colors.blue || '#0000cc',
magenta: colors.magenta || '#cc00cc',
cyan: colors.cyan || '#00cccc',
white: colors.white || '#cccccc',
brightBlack: colors.brightBlack || '#666666',
brightRed: colors.brightRed || '#ff0000',
brightGreen: colors.brightGreen || '#00ff00',
brightYellow: colors.brightYellow || '#ffff00',
brightBlue: colors.brightBlue || '#0000ff',
brightMagenta: colors.brightMagenta || '#ff00ff',
brightCyan: colors.brightCyan || '#00ffff',
brightWhite: colors.brightWhite || '#ffffff',
};
const id = `custom-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}`;
return {
id,
name,
type: isDarkBackground(defaults.background) ? 'dark' : 'light',
isCustom: true,
colors: defaults,
};
} catch {
return null;
}
}