using System.Globalization; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; // Extracts the cockpit label cells from the GIMP source art (riojoy.xcf) into a // regions.json in OverlayTemplate shape: each named text layer becomes a region // (Name = layer name, geometry = layer box, font/size from the gimp-text-layer // parasite). Replaces eyeballing coordinates out of GIMP by hand. // // dotnet run --project tools/XcfRegionExtract [-- ] // // Defaults assume it is run from the repo root. XCF is big-endian; pointers are // 32-bit for versions < 11. Only layer metadata is read (no tile/pixel data), so // tile compression does not matter. string inPath = args.Length > 0 ? args[0] : "docs/reference/customBackground/riojoy.xcf"; string outPath = args.Length > 1 ? args[1] : "docs/reference/customBackground/regions.json"; // Base cockpit image (the wallpaper background), a sibling of regions.json. string baseImage = args.Length > 2 ? args[2] : "riojoy.png"; byte[] d = File.ReadAllBytes(inPath); int pos = 0; uint U32() { uint v = (uint)((d[pos] << 24) | (d[pos + 1] << 16) | (d[pos + 2] << 8) | d[pos + 3]); pos += 4; return v; } int I32() => (int)U32(); ulong U64() { ulong v = 0; for (int i = 0; i < 8; i++) v = (v << 8) | d[pos++]; return v; } string magic = Encoding.ASCII.GetString(d, 0, 9); // "gimp xcf " string ver = Encoding.ASCII.GetString(d, 9, 4); // "file" (=v0) or "vNNN" if (magic != "gimp xcf ") { Console.Error.WriteLine($"Not an XCF file: '{magic}'"); return 1; } bool ptr64 = ver[0] == 'v' && int.TryParse(ver.AsSpan(1, 3), out int vn) && vn >= 11; long Ptr() => ptr64 ? (long)U64() : U32(); pos = 14; int width = (int)U32(), height = (int)U32(); _ = U32(); // base type Console.WriteLine($"xcf ver='{ver}' canvas={width}x{height}"); string XcfString() { uint len = U32(); if (len == 0) return ""; string s = Encoding.UTF8.GetString(d, pos, (int)len - 1); // length includes trailing NUL pos += (int)len; return s; } void SkipProps() { while (true) { uint t = U32(); uint l = U32(); if (t == 0) break; pos += (int)l; } } pos = 26; // header(14) + width(4) + height(4) + base(4) SkipProps(); // image property list var layerPtrs = new List(); while (true) { long p = Ptr(); if (p == 0) break; layerPtrs.Add(p); } var fontRx = new Regex("\\(font \"([^\"]*)\"\\)"); var sizeRx = new Regex("\\(font-size ([0-9.]+)"); var colorRx = new Regex("\\(color \\(color-rgb ([0-9.]+) ([0-9.]+) ([0-9.]+)\\)\\)"); static string? ToHex(Match m) { if (!m.Success) return null; int Byte(int g) => (int)Math.Round(double.Parse(m.Groups[g].Value, CultureInfo.InvariantCulture) * 255); return $"#{Byte(1):X2}{Byte(2):X2}{Byte(3):X2}"; } // Cockpit-specific orientation: these cells' labels read 90° counter-clockwise // (the vertical button column b-10..b-1F and the center title a-00). GIMP doesn't // record a simple text-layer rotation, so it is applied here by name. var rotateCcw90 = new HashSet(StringComparer.OrdinalIgnoreCase) { "a-00" }; for (int a = 0x10; a <= 0x1F; a++) rotateCcw90.Add($"b-{a:X2}"); var regions = new List(); foreach (long lp in layerPtrs) { pos = (int)lp; uint lw = U32(), lh = U32(); _ = U32(); // width, height, type string name = XcfString(); int dx = 0, dy = 0; string? parasite = null; while (true) { uint t = U32(); uint l = U32(); if (t == 0) break; int next = pos + (int)l; if (t == 15) { dx = I32(); dy = I32(); } // PROP_OFFSETS else if (t == 21) // PROP_PARASITES { while (pos < next) { string pname = XcfString(); _ = U32(); // flags uint psize = U32(); string pdata = Encoding.UTF8.GetString(d, pos, (int)psize); pos += (int)psize; if (pname == "gimp-text-layer") parasite = pdata; } } pos = next; } if (parasite is null) continue; // only text layers are label cells string font = fontRx.Match(parasite) is { Success: true } fm ? fm.Groups[1].Value : "Sans"; string sizeStr = sizeRx.Match(parasite) is { Success: true } sm ? sm.Groups[1].Value : "12"; double sizePx = double.TryParse(sizeStr, NumberStyles.Float, CultureInfo.InvariantCulture, out double sp) ? sp : 12; string? colorHex = ToHex(colorRx.Match(parasite)); regions.Add(new { Name = name, X = dx, Y = dy, Width = (int)lw, Height = (int)lh, FontFamily = string.IsNullOrEmpty(font) ? "Sans" : font, FontSizePx = sizePx, ColorHex = colorHex, RotationDegrees = rotateCcw90.Contains(name) ? 90.0 : (double?)null, }); } var template = new { BaseImagePath = baseImage, Width = width, Height = height, Regions = regions }; var opts = new JsonSerializerOptions { WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, }; File.WriteAllText(outPath, JsonSerializer.Serialize(template, opts)); Console.WriteLine($"Wrote {regions.Count} regions -> {outPath}"); return 0;