From d884c770fa3bf2c10770824a774355a3cda6a4d9 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:16:13 +0800 Subject: [PATCH 1/7] feat: add Blazor WebAssembly example --- .github/workflows/Blazor.yml | 49 ++++++++++++ .../CSharpMath.Blazor.Example.Tests.csproj | 4 + .../FormulaEditorStateTests.cs | 17 +++++ CSharpMath.Blazor.Example/App.razor | 10 +++ .../CSharpMath.Blazor.Example.csproj | 19 +++++ .../FormulaEditorState.cs | 11 +++ .../Layout/MainLayout.razor | 4 + .../Layout/MainLayout.razor.css | 2 + CSharpMath.Blazor.Example/Pages/Home.razor | 36 +++++++++ .../Pages/Home.razor.css | 8 ++ CSharpMath.Blazor.Example/Program.cs | 8 ++ CSharpMath.Blazor.Example/_Imports.razor | 9 +++ CSharpMath.Blazor.Example/wwwroot/css/app.css | 2 + CSharpMath.Blazor.Example/wwwroot/favicon.svg | 1 + CSharpMath.Blazor.Example/wwwroot/index.html | 5 ++ CSharpMath.slnx | 6 ++ ReadMe.md | 15 ++++ browser-smoke/package-lock.json | 75 +++++++++++++++++++ browser-smoke/package.json | 5 ++ browser-smoke/smoke.mjs | 46 ++++++++++++ 20 files changed, 332 insertions(+) create mode 100644 .github/workflows/Blazor.yml create mode 100644 CSharpMath.Blazor.Example.Tests/CSharpMath.Blazor.Example.Tests.csproj create mode 100644 CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs create mode 100644 CSharpMath.Blazor.Example/App.razor create mode 100644 CSharpMath.Blazor.Example/CSharpMath.Blazor.Example.csproj create mode 100644 CSharpMath.Blazor.Example/FormulaEditorState.cs create mode 100644 CSharpMath.Blazor.Example/Layout/MainLayout.razor create mode 100644 CSharpMath.Blazor.Example/Layout/MainLayout.razor.css create mode 100644 CSharpMath.Blazor.Example/Pages/Home.razor create mode 100644 CSharpMath.Blazor.Example/Pages/Home.razor.css create mode 100644 CSharpMath.Blazor.Example/Program.cs create mode 100644 CSharpMath.Blazor.Example/_Imports.razor create mode 100644 CSharpMath.Blazor.Example/wwwroot/css/app.css create mode 100644 CSharpMath.Blazor.Example/wwwroot/favicon.svg create mode 100644 CSharpMath.Blazor.Example/wwwroot/index.html create mode 100644 browser-smoke/package-lock.json create mode 100644 browser-smoke/package.json create mode 100644 browser-smoke/smoke.mjs diff --git a/.github/workflows/Blazor.yml b/.github/workflows/Blazor.yml new file mode 100644 index 00000000..6df37bb9 --- /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@d4c94342e560b34958eacfc5d055d21461ed1c5c # v4 + 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..ac887d47 --- /dev/null +++ b/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs @@ -0,0 +1,17 @@ +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); + } +} \ No newline at end of file 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 +
+
@Body
+
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..d3671bab --- /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)) { } + +@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; + if (painter.Display is not null) 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..08af28ca --- /dev/null +++ b/CSharpMath.Blazor.Example/Pages/Home.razor.css @@ -0,0 +1,8 @@ +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; } +/* SKCanvasView renders its canvas as the component's element; this class is deliberate. */ +.formula-canvas { display: block; width: 100%; height: 180px; } +.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; } \ No newline at end of file diff --git a/CSharpMath.Blazor.Example/Program.cs b/CSharpMath.Blazor.Example/Program.cs new file mode 100644 index 00000000..1ed48275 --- /dev/null +++ b/CSharpMath.Blazor.Example/Program.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.Components.WebAssembly.Hosting; +using CSharpMath.Blazor.Example; + +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..e5cb319b --- /dev/null +++ b/CSharpMath.Blazor.Example/wwwroot/css/app.css @@ -0,0 +1,2 @@ +html, body { font-family: system-ui, sans-serif; margin: 0; color: #202124; } +#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 +

Loading…

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..20baa5d2 --- /dev/null +++ b/browser-smoke/smoke.mjs @@ -0,0 +1,46 @@ +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], 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], nonWhite, dark, signature }; +}); +await page.waitForFunction(() => { const c = document.querySelector('canvas'); return c && c.width === c.clientWidth && c.height === c.clientHeight; }, { timeout: 10000 }); +const first = await readCanvas(); +console.log(JSON.stringify({first, errors})); +if (!first.nonWhite || !first.width || !first.height || first.width !== first.css[0] || first.height !== first.css[1]) throw new Error('initial canvas was blank, uninitialized, or dimensions did not match CSS'); +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 d = ctx.getImageData(0, 0, c.width, c.height).data; return d.some((v, i) => i % 4 === 3 && v > 0) && c.width > 0; }, first); +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.waitForTimeout(500); const resized = await readCanvas(); const after = resized.width; +if (!resized.nonWhite || before === after || resized.width !== resized.css[0] || resized.height !== resized.css[1]) throw new Error(`canvas did not respond to resize, was blank, or dimensions mismatched (${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(`${base}/missing`); await page.goto(base); await page.locator('canvas').waitFor({ state: 'visible' }); +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(); From ea0c8aaa801af32bf8aa738229d8052211e0e515 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:20:41 +0800 Subject: [PATCH 2/7] ci: update setup-dotnet action pin --- .github/workflows/Blazor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/Blazor.yml b/.github/workflows/Blazor.yml index 6df37bb9..e863beb4 100644 --- a/.github/workflows/Blazor.yml +++ b/.github/workflows/Blazor.yml @@ -23,7 +23,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 with: { submodules: recursive } - - uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5c # v4 + - 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 From ce7b9e2dbe11f97604dd71892aa6bd77ce3b8621 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:29:37 +0800 Subject: [PATCH 3/7] fix: render Blazor canvas on first paint --- CSharpMath.Blazor.Example/Pages/Home.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CSharpMath.Blazor.Example/Pages/Home.razor b/CSharpMath.Blazor.Example/Pages/Home.razor index d3671bab..162efed0 100644 --- a/CSharpMath.Blazor.Example/Pages/Home.razor +++ b/CSharpMath.Blazor.Example/Pages/Home.razor @@ -28,7 +28,7 @@ surface.Canvas.Clear(global::SkiaSharp.SKColors.White); painter.LaTeX = latex; error = painter.ErrorMessage; - if (painter.Display is not null) painter.Draw(surface.Canvas, CSharpMath.Rendering.FrontEnd.TextAlignment.Center); + painter.Draw(surface.Canvas, CSharpMath.Rendering.FrontEnd.TextAlignment.Center); } private void Reset() { state.Reset(); latex = state.Latex; error = null; canvas?.Invalidate(); } From 017c47e3fa093e13be6b2d253a0edcdefb486915 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:35:40 +0800 Subject: [PATCH 4/7] fix: apply responsive canvas styles globally --- CSharpMath.Blazor.Example/Pages/Home.razor.css | 4 +--- CSharpMath.Blazor.Example/wwwroot/css/app.css | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CSharpMath.Blazor.Example/Pages/Home.razor.css b/CSharpMath.Blazor.Example/Pages/Home.razor.css index 08af28ca..397d7277 100644 --- a/CSharpMath.Blazor.Example/Pages/Home.razor.css +++ b/CSharpMath.Blazor.Example/Pages/Home.razor.css @@ -2,7 +2,5 @@ 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; } -/* SKCanvasView renders its canvas as the component's element; this class is deliberate. */ -.formula-canvas { display: block; width: 100%; height: 180px; } .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; } \ No newline at end of file +.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/wwwroot/css/app.css b/CSharpMath.Blazor.Example/wwwroot/css/app.css index e5cb319b..dfe7f0a0 100644 --- a/CSharpMath.Blazor.Example/wwwroot/css/app.css +++ b/CSharpMath.Blazor.Example/wwwroot/css/app.css @@ -1,2 +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; } From fa21c19f4f387baf116847ca0fc1eccbf668e884 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 10:50:11 +0800 Subject: [PATCH 5/7] test: verify DPR-scaled Blazor canvas --- .../FormulaEditorStateTests.cs | 18 ++++++++++----- CSharpMath.Blazor.Example/Program.cs | 2 +- browser-smoke/smoke.mjs | 22 ++++++++++++++----- 3 files changed, 29 insertions(+), 13 deletions(-) diff --git a/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs b/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs index ac887d47..323be9a1 100644 --- a/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs +++ b/CSharpMath.Blazor.Example.Tests/FormulaEditorStateTests.cs @@ -2,16 +2,22 @@ using Xunit; public class FormulaEditorStateTests { - [Fact] public void Default_is_multiline_and_reset_restores_it() { + [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(); + 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); + + [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); } -} \ No newline at end of file +} diff --git a/CSharpMath.Blazor.Example/Program.cs b/CSharpMath.Blazor.Example/Program.cs index 1ed48275..ea95b0a6 100644 --- a/CSharpMath.Blazor.Example/Program.cs +++ b/CSharpMath.Blazor.Example/Program.cs @@ -1,6 +1,6 @@ +using CSharpMath.Blazor.Example; using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using CSharpMath.Blazor.Example; var builder = WebAssemblyHostBuilder.CreateDefault(args); builder.RootComponents.Add("#app"); diff --git a/browser-smoke/smoke.mjs b/browser-smoke/smoke.mjs index 20baa5d2..8efd9d8a 100644 --- a/browser-smoke/smoke.mjs +++ b/browser-smoke/smoke.mjs @@ -9,26 +9,36 @@ 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], nonWhite: false, signature: '' }; + 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], nonWhite, dark, signature }; + 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'); return c && c.width === c.clientWidth && c.height === c.clientHeight; }, { timeout: 10000 }); +await page.waitForFunction(() => { + const c = document.querySelector('canvas'), dpr = window.devicePixelRatio || 1; + return c && Math.abs(c.width - c.clientWidth * dpr) <= 1 && Math.abs(c.height - c.clientHeight * dpr) <= 1; +}, null, { timeout: 10000 }); const first = await readCanvas(); console.log(JSON.stringify({first, errors})); -if (!first.nonWhite || !first.width || !first.height || first.width !== first.css[0] || first.height !== first.css[1]) throw new Error('initial canvas was blank, uninitialized, or dimensions did not match CSS'); +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 d = ctx.getImageData(0, 0, c.width, c.height).data; return d.some((v, i) => i % 4 === 3 && v > 0) && c.width > 0; }, first); 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.waitForTimeout(500); const resized = await readCanvas(); const after = resized.width; -if (!resized.nonWhite || before === after || resized.width !== resized.css[0] || resized.height !== resized.css[1]) throw new Error(`canvas did not respond to resize, was blank, or dimensions mismatched (${before} -> ${after})`); +const before = valid.width; +await page.setViewportSize({ width: 700, height: 800 }); +await page.waitForFunction(previousCssWidth => { + const c = document.querySelector('canvas'), dpr = window.devicePixelRatio || 1; + return c && c.clientWidth !== previousCssWidth && Math.abs(c.width - c.clientWidth * dpr) <= 1 && Math.abs(c.height - c.clientHeight * dpr) <= 1; +}, 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; From df17b3e6439dc66277e669a894ac01d0cf57e81a Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 11:27:22 +0800 Subject: [PATCH 6/7] test: wait for initial Blazor canvas paint --- browser-smoke/smoke.mjs | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/browser-smoke/smoke.mjs b/browser-smoke/smoke.mjs index 8efd9d8a..5495a74c 100644 --- a/browser-smoke/smoke.mjs +++ b/browser-smoke/smoke.mjs @@ -19,23 +19,39 @@ const readCanvas = () => canvas.evaluate(c => { 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; - return c && Math.abs(c.width - c.clientWidth * dpr) <= 1 && Math.abs(c.height - c.clientHeight * dpr) <= 1; -}, null, { timeout: 10000 }); + 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 d = ctx.getImageData(0, 0, c.width, c.height).data; return d.some((v, i) => i % 4 === 3 && v > 0) && c.width > 0; }, first); +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; - return c && c.clientWidth !== previousCssWidth && Math.abs(c.width - c.clientWidth * dpr) <= 1 && Math.abs(c.height - c.clientHeight * dpr) <= 1; + 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})`); From 66046fd23aeccc7869bfb533fa8acce27c24b9f7 Mon Sep 17 00:00:00 2001 From: Hadrian Tang Date: Mon, 31 Aug 2026 11:32:05 +0800 Subject: [PATCH 7/7] test: navigate through a valid Blazor lifecycle --- browser-smoke/smoke.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/browser-smoke/smoke.mjs b/browser-smoke/smoke.mjs index 5495a74c..09c5bf9d 100644 --- a/browser-smoke/smoke.mjs +++ b/browser-smoke/smoke.mjs @@ -67,6 +67,15 @@ await page.waitForFunction(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(`${base}/missing`); await page.goto(base); await page.locator('canvas').waitFor({ state: 'visible' }); +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();