using System.Globalization; using System.Text.RegularExpressions; using AngleSharp.Dom; using Jint; using Jint.Native; using Jint.Native.Object; namespace XFESystem.Browser; public sealed partial class DomBridge { private sealed class CanvasState(BrowserCanvasSurface surface, ObjectInstance context) { public BrowserCanvasSurface Surface = surface; public ObjectInstance Context = context; public double TranslateX, TranslateY; public double ScaleX = 1, ScaleY = 1; public Stack<(double X, double Y, double ScaleX, double ScaleY, string Fill, string Font, double Alpha)> Saved = []; } private readonly DependentIdentityMap canvases = new(); public BrowserCanvasSurface? GetCanvasSurface(INode node) => node is IElement element && canvases.TryGetValue(element, out var state) ? state.Surface : null; public bool TryCanvas(INode node, out BrowserCanvasSurface surface) { var found = GetCanvasSurface(node); surface = found!; return found is not null; } private void InstallCanvas(ObjectInstance wrapper, IElement element) { Property(wrapper, "width", () => CanvasDimension(element, "width", 300), value => ResizeCanvas(element, "width", NumberIndex(value))); Property(wrapper, "height", () => CanvasDimension(element, "height", 150), value => ResizeCanvas(element, "height", NumberIndex(value))); Function(wrapper, "getContext", args => Argument(args, 0) == "2d" ? Canvas(element, wrapper).Context : JsValue.Null); } private CanvasState Canvas(IElement element, ObjectInstance wrapper) { if (canvases.TryGetValue(element, out var old)) return old; var width = CanvasDimension(element, "width", 300); var height = CanvasDimension(element, "height", 150); CheckCanvasBudget(element, width, height); var context = new BrowserHostObject(engine); var state = new CanvasState(new(width, height), context); canvases.Add(element, state); Data(context, "canvas", wrapper); Data(context, "backingStorePixelRatio", 1); Data(context, "fillStyle", "#000000", true); Data(context, "strokeStyle", "#000000", true); Data(context, "globalAlpha", 1, true); Data(context, "font", "10px sans-serif", true); Data(context, "textBaseline", "alphabetic", true); Data(context, "textAlign", "start", true); Function(context, "fillRect", args => { FillCanvas(state, args, false); CanvasChanged(element, state); return JsValue.Undefined; }); Function(context, "clearRect", args => { FillCanvas(state, args, true); CanvasChanged(element, state); return JsValue.Undefined; }); Function(context, "measureText", args => { var text = Argument(args, 0); var font = CanvasFont(context.Get("font")); var measured = host.MeasureText?.Invoke(text, font) ?? new BrowserFallbackTextMetrics().Measure(text, font); var metrics = new BrowserHostObject(engine); Data(metrics, "width", measured); Data(metrics, "actualBoundingBoxLeft", 0); Data(metrics, "actualBoundingBoxRight", measured); Data(metrics, "actualBoundingBoxAscent", font.Size * .8); Data(metrics, "actualBoundingBoxDescent", font.Size * .2); return metrics; }); Function(context, "fillText", args => { FillCanvasText(state, args); CanvasChanged(element, state); return JsValue.Undefined; }); Function(context, "strokeText", _ => throw new NotSupportedException("Canvas text rasterization is not available on this platform.")); Function(context, "getImageData", args => ImageData(state.Surface, NumberIndexSigned(Value(args, 0)), NumberIndexSigned(Value(args, 1)), NumberIndex(Value(args, 2)), NumberIndex(Value(args, 3)))); Function(context, "createImageData", args => ImageData(null, 0, 0, NumberIndex(Value(args, 0)), NumberIndex(Value(args, 1)))); Function(context, "putImageData", args => { PutImageData(state.Surface, Value(args, 0), NumberIndexSigned(Value(args, 1)), NumberIndexSigned(Value(args, 2))); CanvasChanged(element, state); return JsValue.Undefined; }); Function(context, "translate", args => { state.TranslateX += Number(Value(args, 0)); state.TranslateY += Number(Value(args, 1)); return JsValue.Undefined; }); Function(context, "scale", args => { state.ScaleX *= Number(Value(args, 0)); state.ScaleY *= Number(Value(args, 1)); return JsValue.Undefined; }); Function(context, "setTransform", args => { if (Number(Value(args, 1)) != 0 || Number(Value(args, 2)) != 0) throw new NotSupportedException("Rotated/skewed canvas transforms are not supported."); state.ScaleX = Number(Value(args, 0)); state.ScaleY = Number(Value(args, 3)); state.TranslateX = Number(Value(args, 4)); state.TranslateY = Number(Value(args, 5)); return JsValue.Undefined; }); Function(context, "resetTransform", _ => { state.ScaleX = state.ScaleY = 1; state.TranslateX = state.TranslateY = 0; return JsValue.Undefined; }); Function(context, "save", _ => { if (state.Saved.Count >= 64) throw new BrowserLimitException("Canvas state stack limit reached."); state.Saved.Push((state.TranslateX, state.TranslateY, state.ScaleX, state.ScaleY, context.Get("fillStyle").ToString(), context.Get("font").ToString(), Number(context.Get("globalAlpha")))); return JsValue.Undefined; }); Function(context, "restore", _ => { if (state.Saved.TryPop(out var saved)) { state.TranslateX = saved.X; state.TranslateY = saved.Y; state.ScaleX = saved.ScaleX; state.ScaleY = saved.ScaleY; context.Set("fillStyle", saved.Fill, context); context.Set("font", saved.Font, context); context.Set("globalAlpha", saved.Alpha, context); } return JsValue.Undefined; }); return state; } private void FillCanvas(CanvasState state, JsValue[] args, bool clear) { var color = CssValue.Color(state.Context.Get("fillStyle").ToString(), new(0)); var alpha = Math.Clamp(Number(state.Context.Get("globalAlpha")), 0, 1) * color.Alpha; var argb = (uint)Math.Round(alpha * 255) << 24 | color.Rgb; state.Surface.Fill(state.TranslateX + Number(Value(args, 0)) * state.ScaleX, state.TranslateY + Number(Value(args, 1)) * state.ScaleY, Number(Value(args, 2)) * state.ScaleX, Number(Value(args, 3)) * state.ScaleY, argb, clear); } private void FillCanvasText(CanvasState state, JsValue[] args) { if (host.RasterizeText is null) throw new NotSupportedException("Canvas text rasterization is not available on this platform; CSS text rendering remains available."); var text = Argument(args, 0); var font = CanvasFont(state.Context.Get("font")); if (text.Length > 4096) throw new BrowserLimitException("Canvas text exceeds its rasterization budget."); var measured = host.MeasureText?.Invoke(text, font) ?? new BrowserFallbackTextMetrics().Measure(text, font); if (measured > 4096) throw new BrowserLimitException("Canvas text exceeds its rasterization width budget."); var bitmap = host.RasterizeText(text, font); if (bitmap.Width < 0 || bitmap.Height < 0 || (long)bitmap.Width * bitmap.Height > 1_048_576 || bitmap.Alpha.Length != (long)bitmap.Width * bitmap.Height) throw new BrowserLimitException("Canvas font rasterizer returned an invalid or oversized mask."); var x = Number(Value(args, 1)); var y = Number(Value(args, 2)); var align = state.Context.Get("textAlign").ToString(); if (align is "right" or "end") x -= measured; else if (align == "center") x -= measured / 2; var baseline = state.Context.Get("textBaseline").ToString(); if (baseline is "bottom" or "ideographic") y -= bitmap.Height; else if (baseline == "middle") y -= bitmap.Height / 2d; else if (baseline == "alphabetic") y -= font.Size * .8; var color = CssValue.Color(state.Context.Get("fillStyle").ToString(), new(0)); var alpha = Math.Clamp(Number(state.Context.Get("globalAlpha")), 0, 1) * color.Alpha; var sx = state.ScaleX; if (args.Length > 3 && Number(Value(args, 3)) > 0 && measured > Number(Value(args, 3))) sx *= Number(Value(args, 3)) / measured; state.Surface.BlendMask(bitmap.Alpha, bitmap.Width, bitmap.Height, state.TranslateX + x * state.ScaleX, state.TranslateY + y * state.ScaleY, sx, state.ScaleY, color.Rgb, alpha); } private void CanvasChanged(IElement element, CanvasState state) { host.Mutated?.Invoke(); host.CanvasChanged?.Invoke(element, state.Surface); } private void ResizeCanvas(IElement element, string dimension, int value) { var width = dimension == "width" ? value : CanvasDimension(element, "width", 300); var height = dimension == "height" ? value : CanvasDimension(element, "height", 150); CheckCanvasBudget(element, width, height); SetAttribute(element, dimension, value.ToString(CultureInfo.InvariantCulture)); } private void CanvasAttributeChanged(IElement element, string attribute) { if (element.LocalName != "canvas" || attribute is not ("width" or "height")) return; var width = CanvasDimension(element, "width", 300); var height = CanvasDimension(element, "height", 150); CheckCanvasBudget(element, width, height); if (canvases.TryGetValue(element, out var state)) { state.Surface.Resize(width, height); state.TranslateX = state.TranslateY = 0; state.ScaleX = state.ScaleY = 1; state.Saved.Clear(); state.Context.Set("fillStyle", "#000000", state.Context); state.Context.Set("strokeStyle", "#000000", state.Context); state.Context.Set("font", "10px sans-serif", state.Context); state.Context.Set("globalAlpha", 1, state.Context); CanvasChanged(element, state); } } private void CheckCanvasBudget(IElement element, int width, int height) { if (width < 0 || height < 0 || width > 4096 || height > 4096 || (long)width * height > 1_048_576) throw new BrowserLimitException("Canvas dimensions exceed the supported pixel budget."); long pixels = (long)width * height; foreach (var pair in canvases) if (pair.Key != element) pixels += (long)pair.Value.Surface.Width * pair.Value.Surface.Height; if (pixels > Math.Min(4_194_304, limits.MaximumScriptMemoryBytes / 4) || !canvases.ContainsKey(element) && canvases.Count >= 32) throw new BrowserLimitException("Canvas backing stores exceed the page pixel budget."); } private static int CanvasDimension(IElement element, string name, int fallback) => int.TryParse(element.GetAttribute(name), NumberStyles.None, CultureInfo.InvariantCulture, out var value) && value >= 0 ? value : fallback; private JsValue ImageData(BrowserCanvasSurface? surface, int x, int y, int width, int height) { if (width <= 0 || height <= 0 || (long)width * height > 1_048_576) throw new BrowserLimitException("ImageData exceeds its pixel budget."); var data = CallInternal(bootstrap.Get("pixelArray"), [checked(width * height * 4)]).AsObject(); for (var row = 0; row < height; ++row) for (var col = 0; col < width; ++col) { var pixel = surface?.Pixel(x + col, y + row) ?? 0; var index = (row * width + col) * 4; data.Set(index.ToString(CultureInfo.InvariantCulture), (int)(pixel >> 16 & 255), data); data.Set((index + 1).ToString(CultureInfo.InvariantCulture), (int)(pixel >> 8 & 255), data); data.Set((index + 2).ToString(CultureInfo.InvariantCulture), (int)(pixel & 255), data); data.Set((index + 3).ToString(CultureInfo.InvariantCulture), (int)(pixel >> 24), data); } var result = new BrowserHostObject(engine); Data(result, "data", data); Data(result, "width", width); Data(result, "height", height); return result; } private static void PutImageData(BrowserCanvasSurface surface, JsValue value, int x, int y) { if (!value.IsObject()) throw new ArgumentException("Expected ImageData."); var image = value.AsObject(); var width = NumberIndex(image.Get("width")); var height = NumberIndex(image.Get("height")); if ((long)width * height > 1_048_576 || !image.Get("data").IsObject()) throw new BrowserLimitException("ImageData exceeds its pixel budget."); var data = image.Get("data").AsObject(); for (var row = 0; row < height; ++row) for (var col = 0; col < width; ++col) { var index = (row * width + col) * 4; uint r = (uint)Math.Clamp(Number(data.Get(index.ToString(CultureInfo.InvariantCulture))), 0, 255), g = (uint)Math.Clamp(Number(data.Get((index + 1).ToString(CultureInfo.InvariantCulture))), 0, 255); uint b = (uint)Math.Clamp(Number(data.Get((index + 2).ToString(CultureInfo.InvariantCulture))), 0, 255), a = (uint)Math.Clamp(Number(data.Get((index + 3).ToString(CultureInfo.InvariantCulture))), 0, 255); surface.PutPixel(x + col, y + row, a << 24 | r << 16 | g << 8 | b); } surface.Commit(); } private static BrowserFont CanvasFont(JsValue font) { var text = font.IsString() ? font.AsString() : "10px sans-serif"; var match = Regex.Match(text, @"(?\d+(?:\.\d+)?)px\s+(?.+)", RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(50)); return match.Success && double.TryParse(match.Groups["size"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out var size) ? new(Math.Clamp(size, 1, 256), match.Groups["family"].Value, text.Contains("bold", StringComparison.Ordinal) ? 700 : 400, text.Contains("italic", StringComparison.Ordinal)) : new(10, "sans-serif"); } private static double Number(JsValue value) => value.IsNumber() && double.IsFinite(value.AsNumber()) ? value.AsNumber() : 0; private static int NumberIndexSigned(JsValue value) => (int)Math.Clamp(Number(value), int.MinValue, int.MaxValue); }