diff --git a/.github/workflows/Blazor.yml b/.github/workflows/Blazor.yml
new file mode 100644
index 00000000..e863beb4
--- /dev/null
+++ b/.github/workflows/Blazor.yml
@@ -0,0 +1,49 @@
+permissions:
+ contents: read
+name: Blazor example
+on:
+ push:
+ branches: [master]
+ pull_request:
+ paths:
+ - CSharpMath.Blazor.Example/**
+ - CSharpMath.Blazor.Example.Tests/**
+ - CSharpMath/**
+ - CSharpMath.SkiaSharp/**
+ - CSharpMath.Rendering/**
+ - .github/workflows/Blazor.yml
+ - CSharpMath.slnx
+ - Directory.Build.props
+ - Directory.Build.targets
+ - global.json
+ - Typography/**
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
+ with: { submodules: recursive }
+ - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
+ with: { dotnet-version: 10.0.x }
+ - run: dotnet workload install wasm-tools --skip-manifest-update
+ - run: dotnet test CSharpMath.Blazor.Example.Tests/CSharpMath.Blazor.Example.Tests.csproj --configuration Release
+ - run: dotnet publish CSharpMath.Blazor.Example/CSharpMath.Blazor.Example.csproj --configuration Release --output "$RUNNER_TEMP/csharpmath-blazor"
+ - name: Install pinned browser smoke dependencies
+ working-directory: browser-smoke
+ run: npm ci --ignore-scripts
+ - name: Install Chromium
+ working-directory: browser-smoke
+ run: npx playwright install --with-deps chromium
+ - name: Serve and run browser smoke
+ shell: bash
+ run: |
+ python3 -m http.server 4173 --directory "$RUNNER_TEMP/csharpmath-blazor/wwwroot" >"$RUNNER_TEMP/blazor-server.log" 2>&1 &
+ server=$!
+ trap "kill $server" EXIT
+ npm --prefix browser-smoke run smoke
+ - name: Verify browser assets
+ shell: pwsh
+ run: |
+ $root = Join-Path "$env:RUNNER_TEMP/csharpmath-blazor" "wwwroot"
+ $required = @('_framework/blazor.webassembly.js', '_framework/dotnet.native*.wasm', '_framework/*SkiaSharp*.wasm', '_content/SkiaSharp.Views.Blazor/SKHtmlCanvas.js')
+ foreach ($pattern in $required) { if (-not (Get-ChildItem (Join-Path $root $pattern) -ErrorAction SilentlyContinue)) { throw "Missing published asset: $pattern" } }
\ No newline at end of file
diff --git a/CSharpMath.Blazor.Example.Tests/CSharpMath.Blazor.Example.Tests.csproj b/CSharpMath.Blazor.Example.Tests/CSharpMath.Blazor.Example.Tests.csproj
new file mode 100644
index 00000000..0e8af703
--- /dev/null
+++ b/CSharpMath.Blazor.Example.Tests/CSharpMath.Blazor.Example.Tests.csproj
@@ -0,0 +1,4 @@
+
+ net10.0Exeenableenable
+
+
\ No newline at end of file
diff --git a/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs b/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs
new file mode 100644
index 00000000..323be9a1
--- /dev/null
+++ b/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs
@@ -0,0 +1,23 @@
+using CSharpMath.Blazor.Example;
+using Xunit;
+
+public class FormulaEditorStateTests {
+ [Fact]
+ public void Default_is_multiline_and_reset_restores_it() {
+ var state = new FormulaEditorState();
+ Assert.Contains("\\\\", state.Latex);
+ var initialRevision = state.Revision;
+ state.SetLatex("x");
+ state.Reset();
+ Assert.Equal(FormulaEditorState.DefaultLatex, state.Latex);
+ Assert.Equal(initialRevision + 2, state.Revision);
+ }
+
+ [Fact]
+ public void SetLatex_preserves_invalid_input_for_renderer_error_display() {
+ var state = new FormulaEditorState();
+ state.SetLatex(@"\\notacommand{");
+ Assert.Equal(@"\\notacommand{", state.Latex);
+ Assert.Equal(1, state.Revision);
+ }
+}
diff --git a/CSharpMath.Blazor.Example/App.razor b/CSharpMath.Blazor.Example/App.razor
new file mode 100644
index 00000000..37ad341c
--- /dev/null
+++ b/CSharpMath.Blazor.Example/App.razor
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+ Not found
+ Sorry, there is nothing at this address.
+
+
diff --git a/CSharpMath.Blazor.Example/CSharpMath.Blazor.Example.csproj b/CSharpMath.Blazor.Example/CSharpMath.Blazor.Example.csproj
new file mode 100644
index 00000000..78ba59f2
--- /dev/null
+++ b/CSharpMath.Blazor.Example/CSharpMath.Blazor.Example.csproj
@@ -0,0 +1,19 @@
+
+
+
+ net10.0
+ browser-wasm
+ CSharpMath.Blazor.Example
+ CSharpMath.Blazor.Example
+ enable
+ enable
+ true
+
+ true
+
+
+
+
+
+
+
diff --git a/CSharpMath.Blazor.Example/FormulaEditorState.cs b/CSharpMath.Blazor.Example/FormulaEditorState.cs
new file mode 100644
index 00000000..561d09b9
--- /dev/null
+++ b/CSharpMath.Blazor.Example/FormulaEditorState.cs
@@ -0,0 +1,11 @@
+namespace CSharpMath.Blazor.Example;
+
+/// Small, renderer-independent state holder used by the sample and smoke tests.
+public sealed class FormulaEditorState {
+ public const string DefaultLatex = @"f(x) = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}\\
+\int_0^\infty e^{-x^2}\,dx = \frac{\sqrt{\pi}}{2}";
+ public string Latex { get; private set; } = DefaultLatex;
+ public int Revision { get; private set; }
+ public void SetLatex(string value) { Latex = value; Revision++; }
+ public void Reset() { SetLatex(DefaultLatex); }
+}
\ No newline at end of file
diff --git a/CSharpMath.Blazor.Example/Layout/MainLayout.razor b/CSharpMath.Blazor.Example/Layout/MainLayout.razor
new file mode 100644
index 00000000..09f2a012
--- /dev/null
+++ b/CSharpMath.Blazor.Example/Layout/MainLayout.razor
@@ -0,0 +1,4 @@
+@inherits LayoutComponentBase
+
diff --git a/CSharpMath.Blazor.Example/Layout/MainLayout.razor.css b/CSharpMath.Blazor.Example/Layout/MainLayout.razor.css
new file mode 100644
index 00000000..8fbff1a9
--- /dev/null
+++ b/CSharpMath.Blazor.Example/Layout/MainLayout.razor.css
@@ -0,0 +1,2 @@
+.page { min-height: 100vh; }
+main { max-width: 1100px; margin: 0 auto; }
diff --git a/CSharpMath.Blazor.Example/Pages/Home.razor b/CSharpMath.Blazor.Example/Pages/Home.razor
new file mode 100644
index 00000000..162efed0
--- /dev/null
+++ b/CSharpMath.Blazor.Example/Pages/Home.razor
@@ -0,0 +1,36 @@
+@page "/"
+@using CSharpMath.SkiaSharp
+@using global::SkiaSharp.Views.Blazor
+CSharpMath Blazor example
+
+CSharpMath in Blazor
+Edit the LaTeX below. The canvas resizes with its container and redraws after every change.
+
+
+
+
+
+
+
+
LaTeX formula: @latex
+
+@if (!string.IsNullOrWhiteSpace(error)) { @error
}
+
+@code {
+ private readonly FormulaEditorState state = new();
+ private string latex { get => state.Latex; set => state.SetLatex(value); }
+ private string? error;
+ private SKCanvasView? canvas;
+ private MathPainter painter = new() { FontSize = 24, LineStyle = CSharpMath.Atom.LineStyle.Display };
+
+ private void PaintSurface(global::SkiaSharp.Views.Blazor.SKPaintSurfaceEventArgs args) {
+ var surface = args.Surface;
+ surface.Canvas.Clear(global::SkiaSharp.SKColors.White);
+ painter.LaTeX = latex;
+ error = painter.ErrorMessage;
+ painter.Draw(surface.Canvas, CSharpMath.Rendering.FrontEnd.TextAlignment.Center);
+ }
+
+ private void Reset() { state.Reset(); latex = state.Latex; error = null; canvas?.Invalidate(); }
+ private void Invalidate() { painter.LaTeX = latex; error = painter.ErrorMessage; canvas?.Invalidate(); }
+}
diff --git a/CSharpMath.Blazor.Example/Pages/Home.razor.css b/CSharpMath.Blazor.Example/Pages/Home.razor.css
new file mode 100644
index 00000000..397d7277
--- /dev/null
+++ b/CSharpMath.Blazor.Example/Pages/Home.razor.css
@@ -0,0 +1,6 @@
+h1 { color: #512bd4; }
+.editor-grid { display: grid; gap: .75rem; }
+textarea { font: 1rem ui-monospace, monospace; width: 100%; box-sizing: border-box; }
+.math-canvas { min-height: 180px; width: 100%; border: 1px solid #d5d5d5; background: white; }
+.error { color: #b00020; white-space: pre-wrap; }
+.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
diff --git a/CSharpMath.Blazor.Example/Program.cs b/CSharpMath.Blazor.Example/Program.cs
new file mode 100644
index 00000000..ea95b0a6
--- /dev/null
+++ b/CSharpMath.Blazor.Example/Program.cs
@@ -0,0 +1,8 @@
+using CSharpMath.Blazor.Example;
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+builder.RootComponents.Add("#app");
+builder.RootComponents.Add("head::after");
+await builder.Build().RunAsync();
diff --git a/CSharpMath.Blazor.Example/_Imports.razor b/CSharpMath.Blazor.Example/_Imports.razor
new file mode 100644
index 00000000..398f4def
--- /dev/null
+++ b/CSharpMath.Blazor.Example/_Imports.razor
@@ -0,0 +1,9 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.JSInterop
+@using CSharpMath.Blazor.Example
+@using CSharpMath.Blazor.Example.Layout
diff --git a/CSharpMath.Blazor.Example/wwwroot/css/app.css b/CSharpMath.Blazor.Example/wwwroot/css/app.css
new file mode 100644
index 00000000..dfe7f0a0
--- /dev/null
+++ b/CSharpMath.Blazor.Example/wwwroot/css/app.css
@@ -0,0 +1,4 @@
+html, body { font-family: system-ui, sans-serif; margin: 0; color: #202124; }
+/* This must be global: CSS isolation cannot target the canvas rendered inside SKCanvasView. */
+.formula-canvas { display: block; width: 100%; height: 180px; }
+#blazor-error-ui { display: none; position: fixed; bottom: 0; width: 100%; padding: .75rem; background: #fee; }
diff --git a/CSharpMath.Blazor.Example/wwwroot/favicon.svg b/CSharpMath.Blazor.Example/wwwroot/favicon.svg
new file mode 100644
index 00000000..d7ba264d
--- /dev/null
+++ b/CSharpMath.Blazor.Example/wwwroot/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/CSharpMath.Blazor.Example/wwwroot/index.html b/CSharpMath.Blazor.Example/wwwroot/index.html
new file mode 100644
index 00000000..f8f7dbb1
--- /dev/null
+++ b/CSharpMath.Blazor.Example/wwwroot/index.html
@@ -0,0 +1,5 @@
+
+
+CSharpMath Blazor
+An unexpected error occurred.
Reload
+
diff --git a/CSharpMath.slnx b/CSharpMath.slnx
index 305791b5..72415a10 100644
--- a/CSharpMath.slnx
+++ b/CSharpMath.slnx
@@ -79,6 +79,12 @@
+
+
+
+
+
+
diff --git a/ReadMe.md b/ReadMe.md
index 10db33eb..a76f003d 100644
--- a/ReadMe.md
+++ b/ReadMe.md
@@ -390,3 +390,18 @@ Shhh... Don't tell anybody!
0.4.0: Math evaluation??
0.5.0: Handwritten math recognition???
-->
+
+## Blazor WebAssembly
+
+`CSharpMath.Blazor.Example` is a standalone browser-WASM sample. It owns the canvas lifecycle while `MathPainter` owns parsing, measuring, and drawing:
+
+```xml
+
+
+```
+
+Use `Microsoft.NET.Sdk.BlazorWebAssembly`, target `net10.0`, and set `RuntimeIdentifier` to `browser-wasm`. The sample uses the package's `SKCanvasView` size/DPI watchers and invalidates it after editor changes. Invalid LaTeX remains visible as an error message, and the default demonstrates a multiline formula.
+
+The normal build uses the interpreter, avoiding the download and build cost of AOT. `dotnet publish -p:RunAOTCompilation=true` can be used when faster steady-state execution is worth a substantially larger download and longer publish; AOT is a publish-time choice, not required for development. The generated `wwwroot` can be served by any static host with SPA fallback to `index.html` (including correct MIME types for `.wasm`).
+
+This sample is specifically standalone Blazor WebAssembly. Blazor Server is not tested or packaged here: it may be possible to host the component in a Server app, but browser-WASM Skia assets, canvas initialization, latency, and server-side rendering constraints require a separate integration test before claiming support.
diff --git a/browser-smoke/package-lock.json b/browser-smoke/package-lock.json
new file mode 100644
index 00000000..d87b7c86
--- /dev/null
+++ b/browser-smoke/package-lock.json
@@ -0,0 +1,75 @@
+{
+ "name": "browser-smoke",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "devDependencies": {
+ "@playwright/test": "1.52.0"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
+ "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.52.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
+ "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.52.0"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
+ "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ }
+ }
+}
diff --git a/browser-smoke/package.json b/browser-smoke/package.json
new file mode 100644
index 00000000..52a572be
--- /dev/null
+++ b/browser-smoke/package.json
@@ -0,0 +1,5 @@
+{
+ "private": true,
+ "scripts": { "smoke": "node smoke.mjs" },
+ "devDependencies": { "@playwright/test": "1.52.0" }
+}
\ No newline at end of file
diff --git a/browser-smoke/smoke.mjs b/browser-smoke/smoke.mjs
new file mode 100644
index 00000000..09c5bf9d
--- /dev/null
+++ b/browser-smoke/smoke.mjs
@@ -0,0 +1,81 @@
+import { chromium } from '@playwright/test';
+const base = process.env.BLAZOR_BASE_URL ?? 'http://127.0.0.1:4173';
+const launchOptions = process.env.BROWSER_EXECUTABLE_PATH ? { executablePath: process.env.BROWSER_EXECUTABLE_PATH, headless: true } : { channel: 'chromium', headless: true };
+const browser = await chromium.launch(launchOptions);
+const page = await browser.newPage({ deviceScaleFactor: 2, viewport: { width: 1100, height: 800 } });
+const errors = []; page.on('pageerror', e => errors.push(String(e))); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+await page.goto(base, { waitUntil: 'networkidle' });
+const canvas = page.locator('canvas'); await canvas.waitFor({ state: 'visible' });
+await page.waitForFunction(() => { const c = document.querySelector('canvas'); return c && c.width > 0 && c.height > 0; });
+const readCanvas = () => canvas.evaluate(c => {
+ const ctx = c.getContext('2d');
+ if (!ctx) return { width: c.width, height: c.height, css: [c.clientWidth, c.clientHeight], dpr: window.devicePixelRatio, nonWhite: false, signature: '' };
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ let nonWhite = false, dark = 0, signature = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) nonWhite = true, dark++;
+ if (i % 16 === 0) signature = (signature * 31 + data[i] + data[i + 1] * 3 + data[i + 2] * 7) >>> 0;
+ }
+ return { width: c.width, height: c.height, css: [c.clientWidth, c.clientHeight], dpr: window.devicePixelRatio, nonWhite, dark, signature };
+});
+await page.waitForFunction(() => {
+ const c = document.querySelector('canvas'), dpr = window.devicePixelRatio || 1, ctx = c?.getContext('2d');
+ if (!ctx || Math.abs(c.width - c.clientWidth * dpr) > 1 || Math.abs(c.height - c.clientHeight * dpr) > 1) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 0; i < data.length; i += 4)
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) return true;
+ return false;
+}, null, { timeout: 30000 });
+const first = await readCanvas();
+console.log(JSON.stringify({first, errors}));
+const hasScaledBackingStore = sample => Math.abs(sample.width - sample.css[0] * sample.dpr) <= 1 && Math.abs(sample.height - sample.css[1] * sample.dpr) <= 1;
+if (!first.nonWhite || !first.width || !first.height || !hasScaledBackingStore(first)) throw new Error('initial canvas was blank, uninitialized, or did not use a DPR-scaled backing store');
+await page.locator('#latex').fill('\\notacommand{'); await page.locator('#formula-error').waitFor({ state: 'visible' });
+await page.locator('#latex').fill('x^2 + y^2 = z^2'); await page.waitForTimeout(300);
+await page.waitForFunction(previous => {
+ const c = document.querySelector('canvas'), ctx = c?.getContext('2d'); if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data; let nonWhite = false, signature = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) nonWhite = true;
+ if (i % 16 === 0) signature = (signature * 31 + data[i] + data[i + 1] * 3 + data[i + 2] * 7) >>> 0;
+ }
+ return nonWhite && signature !== previous;
+}, first.signature, { timeout: 10000 });
+const valid = await readCanvas();
+if (!valid.nonWhite || valid.signature === first.signature) throw new Error('valid input did not change rendered pixels');
+const before = valid.width;
+await page.setViewportSize({ width: 700, height: 800 });
+await page.waitForFunction(previousCssWidth => {
+ const c = document.querySelector('canvas'), dpr = window.devicePixelRatio || 1, ctx = c?.getContext('2d');
+ if (!ctx || c.clientWidth === previousCssWidth || Math.abs(c.width - c.clientWidth * dpr) > 1 || Math.abs(c.height - c.clientHeight * dpr) > 1) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 0; i < data.length; i += 4)
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) return true;
+ return false;
+}, valid.css[0], { timeout: 10000 });
+const resized = await readCanvas(); const after = resized.width;
+if (!resized.nonWhite || before === after || !hasScaledBackingStore(resized)) throw new Error(`canvas did not respond to resize, was blank, or did not use a DPR-scaled backing store (${before} -> ${after})`);
+await page.locator('#latex').fill('\\frac{1}{2}\\\\\\int_0^1 x dx');
+await page.waitForFunction(previous => {
+ const c = document.querySelector('canvas'), ctx = c?.getContext('2d'); if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data; let nonWhite = false, signature = 0;
+ for (let i = 0; i < data.length; i += 4) {
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) nonWhite = true;
+ if (i % 16 === 0) signature = (signature * 31 + data[i] + data[i + 1] * 3 + data[i + 2] * 7) >>> 0;
+ }
+ return nonWhite && signature !== previous;
+}, resized.signature, { timeout: 10000 });
+const multiline = await readCanvas();
+if (!multiline.nonWhite || multiline.signature === resized.signature) throw new Error('multiline input did not change rendered pixels after resize');
+await page.goto('about:blank');
+await page.goto(base, { waitUntil: 'networkidle' });
+await page.locator('canvas').waitFor({ state: 'visible' });
+await page.waitForFunction(() => {
+ const c = document.querySelector('canvas'), ctx = c?.getContext('2d'); if (!ctx) return false;
+ const data = ctx.getImageData(0, 0, c.width, c.height).data;
+ for (let i = 0; i < data.length; i += 4)
+ if (data[i + 3] > 0 && (data[i] < 245 || data[i + 1] < 245 || data[i + 2] < 245)) return true;
+ return false;
+}, null, { timeout: 30000 });
+if (errors.length) throw new Error(`browser errors: ${errors.join('; ')}`);
+console.log(JSON.stringify({ dpr: 2, first, valid, multiline, resized: [before, after], status: 'pass' })); await browser.close();