#!/usr/bin/env python3 """Convert DragonX ui.json / ui-dark.json / ui-light.json to TOML format. Usage: python3 scripts/json2toml.py res/themes/ui.json res/themes/ui.toml python3 scripts/json2toml.py res/themes/ui-dark.json res/themes/ui-dark.toml python3 scripts/json2toml.py res/themes/ui-light.json res/themes/ui-light.toml python3 scripts/json2toml.py --all # converts all three """ import json import sys import os import re from collections import OrderedDict # Keys that need quoting in TOML because they contain special chars def needs_quoting(key): # TOML bare keys: [A-Za-z0-9_-]+ return not re.match(r'^[A-Za-z0-9_-]+$', key) def quote_key(key): if needs_quoting(key): return f'"{key}"' return key def format_value(val): """Format a Python value as a TOML value string.""" if isinstance(val, bool): return "true" if val else "false" elif isinstance(val, int): return str(val) elif isinstance(val, float): # Ensure floats always have a decimal point s = repr(val) if '.' not in s and 'e' not in s and 'E' not in s: s += '.0' return s elif isinstance(val, str): # Escape backslashes and quotes escaped = val.replace('\\', '\\\\').replace('"', '\\"') return f'"{escaped}"' elif isinstance(val, list): parts = [format_value(v) for v in val] return f'[{", ".join(parts)}]' else: return repr(val) def is_simple_leaf(obj): """Check if an object is a simple leaf that can be an inline table. Simple leafs: {"size": X}, {"color": "..."}, {"height": X}, or small objects with only primitive values and no nested objects.""" if not isinstance(obj, dict): return False for v in obj.values(): if isinstance(v, (dict, list)): return False # Keep objects with many keys as sections (threshold: 6 keys) return len(obj) <= 6 def is_array_of_objects(val): """Check if val is an array of objects (needs [[array.of.tables]]).""" return isinstance(val, list) and all(isinstance(v, dict) for v in val) and len(val) > 0 def write_inline_table(obj): """Write a dict as a TOML inline table.""" parts = [] for k, v in obj.items(): parts.append(f'{quote_key(k)} = {format_value(v)}') return '{ ' + ', '.join(parts) + ' }' def emit_toml(data, lines, prefix='', depth=0): """Recursively emit TOML from a parsed JSON dict.""" # Separate keys into: scalars/arrays, simple-leaf objects, complex objects, array-of-tables scalars = [] inline_leaves = [] complex_tables = [] array_tables = [] for key, val in data.items(): # Skip _comment keys (we'll handle them differently) if key.startswith('_comment'): continue if isinstance(val, dict): if is_simple_leaf(val): inline_leaves.append((key, val)) else: complex_tables.append((key, val)) elif is_array_of_objects(val): array_tables.append((key, val)) else: scalars.append((key, val)) # Emit scalars first for key, val in scalars: lines.append(f'{quote_key(key)} = {format_value(val)}') # Emit inline leaf objects (like { size = 42.0 }) for key, val in inline_leaves: lines.append(f'{quote_key(key)} = {write_inline_table(val)}') # Emit complex sub-tables with [section] headers for key, val in complex_tables: subprefix = f'{prefix}.{key}' if prefix else key lines.append('') lines.append(f'[{subprefix}]') emit_toml(val, lines, subprefix, depth + 1) # Emit array-of-tables with [[section]] headers for key, val in array_tables: subprefix = f'{prefix}.{key}' if prefix else key for item in val: lines.append('') lines.append(f'[[{subprefix}]]') if isinstance(item, dict): for ik, iv in item.items(): if isinstance(iv, dict): # Nested object inside array item — inline table lines.append(f'{quote_key(ik)} = {write_inline_table(iv)}') else: lines.append(f'{quote_key(ik)} = {format_value(iv)}') def convert_file(input_path, output_path): """Convert a JSON theme file to TOML.""" with open(input_path) as f: data = json.load(f, object_pairs_hook=OrderedDict) lines = [] # Add header comments (converted from _comment_* keys) for key, val in data.items(): if key.startswith('_comment') and isinstance(val, str): if val: lines.append(f'# {val}') else: lines.append('') if lines: lines.append('') # Emit all non-comment content emit_toml(data, lines) # Clean up extra blank lines output = '\n'.join(lines).strip() + '\n' # Collapse 3+ consecutive newlines to 2 while '\n\n\n' in output: output = output.replace('\n\n\n', '\n\n') with open(output_path, 'w') as f: f.write(output) print(f'Converted: {input_path} -> {output_path}') print(f' JSON: {os.path.getsize(input_path):,} bytes') print(f' TOML: {os.path.getsize(output_path):,} bytes') print(f' Reduction: {100 - 100*os.path.getsize(output_path)/os.path.getsize(input_path):.0f}%') def main(): if len(sys.argv) == 2 and sys.argv[1] == '--all': base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) themes = os.path.join(base, 'res', 'themes') for name in ['ui', 'ui-dark', 'ui-light']: inp = os.path.join(themes, f'{name}.json') out = os.path.join(themes, f'{name}.toml') if os.path.exists(inp): convert_file(inp, out) else: print(f'Skipping (not found): {inp}') elif len(sys.argv) == 3: convert_file(sys.argv[1], sys.argv[2]) else: print(__doc__) sys.exit(1) if __name__ == '__main__': main()