// ImageColorPicker.cs // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors // https://github.com/gnh1201/welsonjs // using System; using System.Collections.Generic; using System.Drawing; using System.Globalization; using System.IO; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; namespace WelsonJS.Launcher.ResourceTools { /// /// POST image-color-picker/ with a unified JSON body: /// { "image": "", "point": { "x": 10, "y": 20 } } /// /// Response (success): /// { "ok": true, "hex": "#RRGGBB", "name": "white", "rgb": { "r": 255, "g": 255, "b": 255 } } /// /// Response (error): /// { "ok": false, "error": "..." } /// /// JSON handling: /// - Request parse: JsSerializer.Load/ExtractFrom (engine) + tiny local decoders for literals. /// - Response encode: Dictionary → JsSerializer.Serialize(...). /// public class ImageColorPicker : IResourceTool { private readonly ResourceServer Server; private readonly HttpClient _httpClient; private readonly ICompatibleLogger _logger; private const string Prefix = "image-color-picker"; public ImageColorPicker(ResourceServer server, HttpClient httpClient, ICompatibleLogger logger) { Server = server; _httpClient = httpClient; _logger = logger; } public bool CanHandle(string path) { return path.StartsWith(Prefix, StringComparison.OrdinalIgnoreCase); } public async Task HandleAsync(HttpListenerContext context, string path) { context.Response.ContentType = "application/json; charset=utf-8"; try { if (!string.Equals(context.Request.HttpMethod, "POST", StringComparison.OrdinalIgnoreCase)) { context.Response.StatusCode = (int)HttpStatusCode.MethodNotAllowed; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Only POST is supported." }); return; } // ---- Read request body --------------------------------------------------------- string body; using (var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding ?? Encoding.UTF8)) body = await reader.ReadToEndAsync(); if (string.IsNullOrWhiteSpace(body)) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Empty request body." }); return; } // ---- Extract strictly in unified format --------------------------------------- string imageJson, xJson, yJson; using (var ser = new JsSerializer()) { int id = ser.Load(body); // Required: "image" as JSON string imageJson = ser.ExtractFrom(id, "image"); // Required: "point": { "x": , "y": } xJson = ser.ExtractFrom(id, "point", "x"); yJson = ser.ExtractFrom(id, "point", "y"); ser.Unload(id); } if (string.IsNullOrEmpty(imageJson)) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Missing required field: 'image' (base64 string)." }); return; } if (string.IsNullOrEmpty(xJson) || string.IsNullOrEmpty(yJson)) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Missing required field: 'point' with numeric 'x' and 'y'." }); return; } // ---- Convert JSON literals → CLR ---------------------------------------------- string imageB64; if (!TryParseJsonString(imageJson, out imageB64) || string.IsNullOrWhiteSpace(imageB64)) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "'image' must be a non-empty JSON string." }); return; } if (!TryParseJsonInt(xJson, out var x) || !TryParseJsonInt(yJson, out var y)) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "'point.x' and 'point.y' must be JSON numbers." }); return; } // Allow data URL prefix (data:*;base64,....) imageB64 = StripDataUrlPrefix(imageB64); byte[] bytes; try { bytes = Convert.FromBase64String(imageB64); } catch { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Invalid base64 image data." }); return; } // ---- Decode image and sample pixel -------------------------------------------- using (var ms = new MemoryStream(bytes, writable: false)) using (var bmp = new Bitmap(ms)) { if (x < 0 || y < 0 || x >= bmp.Width || y >= bmp.Height) { context.Response.StatusCode = (int)HttpStatusCode.BadRequest; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = $"Point out of bounds. Image size: {bmp.Width}x{bmp.Height}, requested: ({x},{y})." }); return; } var color = bmp.GetPixel(x, y); var hex = $"#{color.R:X2}{color.G:X2}{color.B:X2}"; var name = ToCommonColorName(color); // null if not close to a known color context.Response.StatusCode = (int)HttpStatusCode.OK; await WriteJsonAsync(context, new Dictionary { ["ok"] = true, ["hex"] = hex, ["name"] = name, // may be null → serializer will emit "name": null ["rgb"] = new Dictionary { ["r"] = color.R, ["g"] = color.G, ["b"] = color.B } }); } } catch (Exception ex) { _logger?.Error($"ImageColorPicker error: {ex}"); context.Response.StatusCode = (int)HttpStatusCode.InternalServerError; await WriteJsonAsync(context, new Dictionary { ["ok"] = false, ["error"] = "Internal error while processing image." }); } finally { try { context.Response.OutputStream.Flush(); } catch { } try { context.Response.OutputStream.Close(); } catch { } } } // ---------------------------- Helpers ----------------------------------------------- private static bool TryParseJsonInt(string jsonNumberLiteral, out int value) { // Accept JSON numbers like: 12, -3, 10.0, 1e2, -4E+1 // Strategy: parse as double using invariant culture, then cast if within int range. value = default; if (string.IsNullOrWhiteSpace(jsonNumberLiteral)) return false; var s = jsonNumberLiteral.Trim(); // Reject if wrapped in quotes (should be a number literal, not a string) if (s.Length >= 2 && s[0] == '"' && s[s.Length - 1] == '"') return false; if (double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) { if (d >= int.MinValue && d <= int.MaxValue) { value = (int)d; // truncate toward zero (JSON has no ints; this is reasonable) return true; } } return false; } private static bool TryParseJsonString(string jsonStringLiteral, out string value) { value = null; if (string.IsNullOrWhiteSpace(jsonStringLiteral)) return false; var s = jsonStringLiteral.Trim(); if (s.Length < 2 || s[0] != '"' || s[s.Length - 1] != '"') return false; // Remove surrounding quotes and unescape JSON sequences value = JsonUnescape(s.Substring(1, s.Length - 2)); return true; } private static string JsonUnescape(string s) { var sb = new StringBuilder(s.Length); for (int i = 0; i < s.Length; i++) { char c = s[i]; if (c != '\\') { sb.Append(c); continue; } if (i + 1 >= s.Length) { sb.Append('\\'); break; } char n = s[++i]; switch (n) { case '\"': sb.Append('\"'); break; case '\\': sb.Append('\\'); break; case '/': sb.Append('/'); break; case 'b': sb.Append('\b'); break; case 'f': sb.Append('\f'); break; case 'n': sb.Append('\n'); break; case 'r': sb.Append('\r'); break; case 't': sb.Append('\t'); break; case 'u': if (i + 4 < s.Length) { string hex = s.Substring(i + 1, 4); if (ushort.TryParse(hex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out var cp)) { sb.Append((char)cp); i += 4; } else sb.Append('u'); } else sb.Append('u'); break; default: sb.Append(n); break; } } return sb.ToString(); } private static string StripDataUrlPrefix(string b64) { var idx = b64.IndexOf("base64,", StringComparison.OrdinalIgnoreCase); return idx >= 0 ? b64.Substring(idx + "base64,".Length) : b64; } private static string ToCommonColorName(Color c) { (string name, Color color)[] palette = { ("white", Color.FromArgb(255,255,255)), ("black", Color.FromArgb(0,0,0)), ("red", Color.FromArgb(255,0,0)), ("green", Color.FromArgb(0,128,0)), ("blue", Color.FromArgb(0,0,255)), ("yellow", Color.FromArgb(255,255,0)), ("cyan", Color.FromArgb(0,255,255)), ("magenta", Color.FromArgb(255,0,255)), ("gray", Color.FromArgb(128,128,128)) }; const int tolerance = 20; // distance threshold string best = null; int bestDist = int.MaxValue; foreach (var (name, col) in palette) { int dr = c.R - col.R, dg = c.G - col.G, db = c.B - col.B; int dist = dr * dr + dg * dg + db * db; if (dist < bestDist) { bestDist = dist; best = name; } } return bestDist <= tolerance * tolerance ? best : null; } private async Task WriteJsonAsync(HttpListenerContext ctx, Dictionary doc, int space = 0) { using (var ser = new JsSerializer()) { string json = ser.Serialize(doc, space); byte[] payload = Encoding.UTF8.GetBytes(json); await Server.ServeResource(ctx, payload, "application/json", 200); } } } }