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 +
+
@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..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)) { } + +@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 +

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..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();