From 83a5a1f05562bb23358fb5352dddd1f56cc46758 Mon Sep 17 00:00:00 2001 From: dsafdsaf132 Date: Fri, 10 Jul 2026 05:57:14 +0900 Subject: [PATCH 1/5] docs: add project review TODOs --- TODO.md | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 TODO.md diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ce2ae38 --- /dev/null +++ b/TODO.md @@ -0,0 +1,254 @@ +# Project Review TODO + +Review baseline: `v0.5.0` (`8e5dc58`), 2026-07-10 + +Scope: Gerber/Drill parser, WebGL renderer, browser UI, Node/CLI package, +build, release, and compatibility workflows. + +Priority definitions: + +- **P1**: Resource exhaustion or application termination can be triggered by + untrusted input. Address before adding more public input surfaces. +- **P2**: A runtime failure or regression can escape current recovery or CI + checks. Address in the next reliability cycle. +- **P3**: Error handling, documentation, or quality-gate debt that can obscure + future regressions. + +## P1: Input Workload Limits + +### [ ] P1-1 Add parser-wide work and allocation budgets + +Affected code: + +- `wasm/src/parser/state.rs` +- `wasm/src/parser/geometry.rs` +- `wasm/src/parser/aperture.rs` +- `wasm/src/parser/aperture_macro.rs` + +Gerber Step-and-Repeat counts currently accept the full `u32` range and are +expanded through nested loops. Standard and macro polygon vertex counts are +also used without a practical upper bound, with some paths relying on +infallible `Vec::push` allocation. A small Gerber input can therefore consume +the available CPU and WASM memory or terminate the runtime. + +Required work: + +- Reject invalid or excessive Step-and-Repeat dimensions before expansion. +- Validate polygon and aperture-macro vertex counts against supported Gerber + limits. +- Introduce a shared parser budget for generated primitives, regions, + sublayers, and interaction records. +- Use checked arithmetic and fallible reservations before expansion loops. +- Return a stable, user-facing parser limit error instead of reaching OOM. +- Add adversarial tests for oversized SR commands and polygon counts. + +Completion criteria: + +- Tiny inputs cannot request unbounded generated geometry. +- Parser limit failures leave the processor reusable. +- Normal performance fixtures continue to parse without reduced coverage. + +### [ ] P1-2 Apply ingestion limits to remote, archive, and repeat paths + +Affected code: + +- `js/core/config.js` +- `js/core/viewer.js` +- `js/loading/source-loader.js` + +The advertised `300 MiB` limit is enforced for local top-level files only. +Remote responses are accumulated without a byte cap, ZIP entries have no +per-entry or total uncompressed-size limit, and the query-string `repeat` +parameter has no maximum before it reaches `Array.from`. + +Required work: + +- Enforce the file limit using both `Content-Length` and streamed received + bytes; cancel the response reader when the limit is crossed. +- Enforce ZIP entry count, per-entry uncompressed size, and total uncompressed + size limits before reading layer text. +- Reject suspicious compression ratios where metadata permits detection. +- Define and enforce a maximum repeat count and maximum resulting layer count. +- Keep all limits in shared configuration and show the actual limit in UI + diagnostics. +- Add tests for missing/incorrect `Content-Length`, ZIP expansion, and crafted + `repeat` query parameters. + +Completion criteria: + +- Every browser ingestion path has a bounded byte and layer budget. +- Limit errors close readers, hide loading state, and leave the viewer usable. + +## P2: Renderer Reliability + +### [ ] P2-1 Make framebuffer replacement transactional + +Affected code: `wasm/src/renderer/mod.rs` + +`resize_to` records the new explicit size and replaces layer FBOs one at a +time. If a later allocation fails, the renderer retains mixed framebuffer +sizes. FBO creation also leaks already-created textures/framebuffers when a +subsequent WebGL allocation fails. Context restoration has a similar partial +commit risk while rebuilding per-layer caches. + +Required work: + +- Allocate all replacement FBOs and caches into temporary owners first. +- Commit the new size and resources only after every allocation succeeds. +- Add RAII cleanup guards for partially built FBOs and shader resources. +- Preserve the old renderer state on resize or restore failure. +- Add failure-injection tests with a mock WebGL resource factory. + +Completion criteria: + +- Failed resize/restore calls can be retried without rebuilding the processor. +- Every failed WebGL allocation releases resources created by that attempt. + +### [ ] P2-2 Abort failed browser PNG streams + +Affected code: + +- `packages/wasm-gerber-renderer/index.js` +- `packages/wasm-gerber-renderer/index.d.ts` +- `packages/wasm-gerber-renderer/README.md` + +Browser streaming export closes the writable only on success. On compression, +pixel-read, or write failure it releases the writer lock without aborting the +underlying stream, which can leave a partial file or open writable behind. + +Required work: + +- Expose an `abort(error)` operation from the writable sink adapter. +- Abort the writer or `FileSystemWritableFileStream` on export failure. +- Preserve the original rendering error if abort also fails. +- Test write rejection, compression failure, and successful close behavior. + +## P2: CI and Build Integrity + +### [ ] P2-3 Check every deployed JavaScript module + +Affected code: `.github/workflows/build-and-deploy.yml` + +The current `js/*.js` glob checks only `js/main.js`; it does not parse modules +under `js/core`, `js/loading`, `js/rendering`, `js/layers`, or `js/ui`. + +Required work: + +- Change the syntax gate to recursively enumerate all JavaScript files. +- Run the package test suite for browser helper changes, or move viewer tests + into a dedicated root test command. +- Make UI-only pull requests execute the relevant tests before deployment. + +Completion criteria: + +- A syntax error in any deployed module fails the pull-request workflow. +- Viewer helper tests run when their source files change. + +### [ ] P2-4 Validate rendered pixels, not only PNG byte length + +Affected code: `.github/workflows/renderer-compatibility.yml` + +CLI and Node smoke tests currently require only a PNG larger than 100 bytes. +A valid but entirely blank image satisfies this check, so shader, stencil, and +compositing regressions can pass the platform matrix. + +Required work: + +- Decode smoke-test PNGs and assert dimensions and a minimum non-background + pixel count. +- Include a fixture that covers polarity, holes, analytic arc regions, drills, + and inverted rendering. +- Store a compact image signature or bounded pixel-difference baseline where + exact output is stable. +- Keep a native context clear/readPixels test separate from renderer output + validation. + +### [ ] P2-5 Make the WASM build toolchain reproducible + +Affected code: + +- `scripts/vercel-build.sh` +- Rust toolchain configuration at the repository root + +The script declares `wasm-pack 0.14.0` but uses any existing `wasm-pack` found +on `PATH`. The review build consequently used `0.13.1`. Rust is also selected +as moving `stable`, so generated glue and binaries can differ across machines. + +Required work: + +- Verify the installed `wasm-pack` version and install/use the pinned version + when it differs. +- Pin Rust with `rust-toolchain.toml`, including the WASM target and required + components. +- Include tool versions in the WASM source hash or build metadata. +- Test that local `npm pack` and CI release builds use the same versions. + +### [ ] P2-6 Add real macOS x64 validation or correct the support claim + +Affected code: + +- `.github/workflows/renderer-compatibility.yml` +- `packages/wasm-gerber-renderer/README.md` +- Translated renderer READMEs and `SKILL.md` + +The README says the renderer compatibility workflow performs build-only +validation for macOS x64, but the workflow matrix has no macOS x64 job. + +Required work: + +- Add an Intel macOS build/smoke job when an appropriate runner is available, + or explicitly attribute build validation to the upstream + `node-gles-webgl2` project. +- Generate support tables from one source or test them against the workflow + matrix to prevent future drift. + +## P3: UI Error Handling and Quality Gates + +### [ ] P3-1 Handle file-upload promises at event boundaries + +Affected code: `js/core/viewer.js` + +File input and drop handlers call `handleFileUpload` without awaiting or +catching its promise. An unexpected collection or pipeline error produces an +unhandled rejection and skips final UI refresh and file-input reset. + +Required work: + +- Catch upload errors at both event boundaries and report them consistently. +- Move loading-modal cleanup, UI refresh, and file-input reset into a final + cleanup path that always runs. +- Add tests for archive-read failure and non-recoverable worker failure. + +### [ ] P3-2 Enforce formatting and linting in CI + +Current baseline: + +- `cargo fmt --manifest-path wasm/Cargo.toml --all -- --check` fails because + three test modules start with a blank line. +- `cargo clippy --manifest-path wasm/Cargo.toml --all-targets -- -D warnings` + fails on a redundant closure, a complex return type, and approximate + `FRAC_PI_2` constants in renderer tests. + +Required work: + +- Fix the current formatting and Clippy findings. +- Add `cargo fmt --check` and an agreed Clippy policy to pull-request CI. +- Keep generated WASM output outside formatting and lint inputs. + +## Verification Baseline + +The following checks passed during the review: + +- `cargo test --manifest-path wasm/Cargo.toml --all-targets`: 154 tests passed. +- `npm run check` in `packages/wasm-gerber-renderer`: 21 tests passed. +- `./scripts/vercel-build.sh`: WASM release build completed. +- `npm pack --dry-run`: package contained 15 files and packed successfully. +- Recursive `node --check` over `js/**/*.js`: passed. +- Required DOM IDs: no missing or duplicate IDs were detected. + +Remaining runtime validation: + +- Run browser interaction and visual checks at desktop and mobile viewports. +- Run the native Node/CLI pixel smoke on supported Linux, macOS, and Windows + runners. The review environment installed `node-gles-webgl2` successfully, + but its PRoot/Xvfb display could not initialize a native context. From 1a10e5bdc7dd586c691a1dab05c1f0db38ecb559 Mon Sep 17 00:00:00 2001 From: dsafdsaf132 Date: Fri, 10 Jul 2026 07:11:03 +0900 Subject: [PATCH 2/5] fix: harden parser renderer and input pipelines --- .github/workflows/build-and-deploy.yml | 27 +- .github/workflows/release.yml | 8 +- .github/workflows/renderer-compatibility.yml | 32 +- TODO.md | 41 ++- demo/smoke-test.drl | 9 + js/core/config.js | 5 + js/core/viewer.js | 80 ++--- js/loading/source-loader.js | 153 ++++++++- packages/wasm-gerber-renderer/README.kr.md | 8 +- packages/wasm-gerber-renderer/README.md | 10 +- .../wasm-gerber-renderer/README.zh-Hans.md | 8 +- .../wasm-gerber-renderer/README.zh-Hant.md | 8 +- packages/wasm-gerber-renderer/SKILL.md | 9 +- packages/wasm-gerber-renderer/index.d.ts | 12 +- packages/wasm-gerber-renderer/index.js | 27 +- .../test/browser-stream.test.mjs | 152 +++++++++ .../test/layer-kind.test.mjs | 205 +++++++++++- .../test/viewer-upload.test.mjs | 48 +++ rust-toolchain.toml | 5 + scripts/assert-png-content.mjs | 175 +++++++++++ scripts/vercel-build.sh | 23 +- wasm/src/geometry/shape.rs | 22 ++ wasm/src/interaction/mod.rs | 3 +- wasm/src/interaction/tests.rs | 1 - wasm/src/parser/aperture.rs | 12 +- wasm/src/parser/aperture_macro.rs | 49 ++- wasm/src/parser/geometry.rs | 249 ++++++++++++++- wasm/src/parser/geometry/tests.rs | 1 - wasm/src/parser/mod.rs | 4 +- wasm/src/parser/state.rs | 146 ++++++--- wasm/src/parser/tests.rs | 124 +++++++- wasm/src/renderer/mod.rs | 295 +++++++++++++----- wasm/src/renderer/shader.rs | 56 +++- wasm/src/renderer/tests.rs | 25 +- 34 files changed, 1756 insertions(+), 276 deletions(-) create mode 100644 demo/smoke-test.drl create mode 100644 packages/wasm-gerber-renderer/test/browser-stream.test.mjs create mode 100644 packages/wasm-gerber-renderer/test/viewer-upload.test.mjs create mode 100644 rust-toolchain.toml create mode 100644 scripts/assert-png-content.mjs diff --git a/.github/workflows/build-and-deploy.yml b/.github/workflows/build-and-deploy.yml index 97f5f15..d3e3d61 100644 --- a/.github/workflows/build-and-deploy.yml +++ b/.github/workflows/build-and-deploy.yml @@ -25,6 +25,11 @@ jobs: steps: - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "22.14.0" + - name: Cache cargo registry uses: actions/cache@v4 with: @@ -45,7 +50,7 @@ jobs: uses: actions/cache@v4 with: path: wasm/target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('wasm/Cargo.lock') }} + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('rust-toolchain.toml', 'wasm/Cargo.lock') }} restore-keys: | ${{ runner.os }}-cargo-build-target- @@ -54,28 +59,32 @@ jobs: uses: actions/cache@v4 with: path: wasm/pkg - key: ${{ runner.os }}-wasm-pkg-release-${{ hashFiles('scripts/vercel-build.sh', 'wasm/Cargo.lock', 'wasm/Cargo.toml', 'wasm/src/**') }} + key: ${{ runner.os }}-wasm-pkg-release-${{ hashFiles('scripts/vercel-build.sh', 'rust-toolchain.toml', 'wasm/Cargo.lock', 'wasm/Cargo.toml', 'wasm/src/**') }} - name: Build or reuse WASM package run: ./scripts/vercel-build.sh - name: Check JavaScript syntax run: | - for file in js/*.js; do - node --check "$file" - done + find js -type f -name '*.js' -print0 | sort -z | xargs -0 -n1 node --check + + - name: Check renderer package and viewer helpers + run: npm run check --prefix packages/wasm-gerber-renderer - name: Install Rust toolchain for tests - if: steps.wasm-pkg-cache.outputs.cache-hit != 'true' run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install 1.97.0 --profile minimal --component clippy --component rustfmt - name: Run tests - if: steps.wasm-pkg-cache.outputs.cache-hit != 'true' working-directory: ./wasm run: cargo test --verbose + - name: Check Rust formatting + run: cargo fmt --manifest-path wasm/Cargo.toml --all -- --check + + - name: Run Clippy + run: cargo clippy --manifest-path wasm/Cargo.toml --all-targets -- -D warnings + - name: Upload WASM package artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ae20b4f..d5622d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,8 +123,12 @@ jobs: - name: Set up Rust toolchain run: | - rustup toolchain install stable --profile minimal - rustup default stable + rustup toolchain install 1.97.0 --profile minimal --component clippy --component rustfmt + + - name: Check Rust formatting and lint + run: | + cargo fmt --manifest-path wasm/Cargo.toml --all -- --check + cargo clippy --manifest-path wasm/Cargo.toml --all-targets -- -D warnings - name: Test Rust crate run: cargo test --manifest-path wasm/Cargo.toml --verbose diff --git a/.github/workflows/renderer-compatibility.yml b/.github/workflows/renderer-compatibility.yml index 4923ece..f9239fb 100644 --- a/.github/workflows/renderer-compatibility.yml +++ b/.github/workflows/renderer-compatibility.yml @@ -7,16 +7,22 @@ on: paths: - ".github/workflows/renderer-compatibility.yml" - "demo/*.gbr" + - "demo/*.drl" - "packages/wasm-gerber-renderer/**" + - "scripts/assert-png-content.mjs" - "scripts/vercel-build.sh" + - "rust-toolchain.toml" - "wasm/**" pull_request: branches: ["main"] paths: - ".github/workflows/renderer-compatibility.yml" - "demo/*.gbr" + - "demo/*.drl" - "packages/wasm-gerber-renderer/**" + - "scripts/assert-png-content.mjs" - "scripts/vercel-build.sh" + - "rust-toolchain.toml" - "wasm/**" permissions: @@ -72,6 +78,8 @@ jobs: runner: ubuntu-24.04-arm - platform: macOS arm64 runner: macos-latest + - platform: macOS x64 + runner: macos-15-intel - platform: Windows x64 runner: windows-latest - platform: Windows arm64 @@ -169,7 +177,7 @@ jobs: --height 360 \ --background '#05070c' \ --alpha 1 - node -e "const { statSync } = require('node:fs'); const size = statSync('out-cli.png').size; if (size < 100) throw new Error('CLI PNG output is unexpectedly small'); console.log('CLI PNG bytes:', size);" + node "${GITHUB_WORKSPACE}/scripts/assert-png-content.mjs" out-cli.png '#05070c' 100 640 360 - name: Render through Node API run: | @@ -177,25 +185,37 @@ jobs: cd "${RUNNER_TEMP}/renderer-smoke" cat > smoke-node.mjs <<'NODE' import { fileLayer, renderGerberToPngBuffer } from "wasm-gerber-renderer/node"; + import { writeFile } from "node:fs/promises"; import { resolve } from "node:path"; const png = await renderGerberToPngBuffer( - [fileLayer(resolve(process.env.GITHUB_WORKSPACE, "demo/gerber-feature-test.gbr"))], + [ + fileLayer(resolve(process.env.GITHUB_WORKSPACE, "demo/gerber-feature-test.gbr")), + fileLayer(resolve(process.env.GITHUB_WORKSPACE, "demo/gerber-feature-test.gbr"), { + name: "inverted-mask.gbs", + inverted: true, + offsetX: 80, + }), + fileLayer(resolve(process.env.GITHUB_WORKSPACE, "demo/smoke-test.drl"), { + kind: "drill", + offsetX: 160, + }), + ], { width: 640, height: 360, background: "#ffffff", globalAlpha: 1, + invertedOutline: "bounds", }, ); - if (!(png instanceof Uint8Array) || png.length < 100) { - throw new Error("Node API PNG output is unexpectedly small"); - } - console.log("Node API PNG bytes:", png.length); + if (!(png instanceof Uint8Array)) throw new Error("Node API did not return bytes"); + await writeFile("out-node.png", png); NODE node_cmd=(node) if [[ "${RUNNER_OS}" == "Linux" ]]; then node_cmd=(xvfb-run -a node) fi "${node_cmd[@]}" smoke-node.mjs + node "${GITHUB_WORKSPACE}/scripts/assert-png-content.mjs" out-node.png '#ffffff' 100 640 360 diff --git a/TODO.md b/TODO.md index ce2ae38..d759ee6 100644 --- a/TODO.md +++ b/TODO.md @@ -16,7 +16,7 @@ Priority definitions: ## P1: Input Workload Limits -### [ ] P1-1 Add parser-wide work and allocation budgets +### [x] P1-1 Add parser-wide work and allocation budgets Affected code: @@ -48,7 +48,7 @@ Completion criteria: - Parser limit failures leave the processor reusable. - Normal performance fixtures continue to parse without reduced coverage. -### [ ] P1-2 Apply ingestion limits to remote, archive, and repeat paths +### [x] P1-2 Apply ingestion limits to remote, archive, and repeat paths Affected code: @@ -81,7 +81,7 @@ Completion criteria: ## P2: Renderer Reliability -### [ ] P2-1 Make framebuffer replacement transactional +### [x] P2-1 Make framebuffer replacement transactional Affected code: `wasm/src/renderer/mod.rs` @@ -104,7 +104,11 @@ Completion criteria: - Failed resize/restore calls can be retried without rebuilding the processor. - Every failed WebGL allocation releases resources created by that attempt. -### [ ] P2-2 Abort failed browser PNG streams +Residual test gap: transactional resource ownership is implemented, but direct +failure injection remains a follow-up because the renderer currently owns a +concrete `web_sys::WebGl2RenderingContext` rather than an injectable factory. + +### [x] P2-2 Abort failed browser PNG streams Affected code: @@ -125,7 +129,7 @@ Required work: ## P2: CI and Build Integrity -### [ ] P2-3 Check every deployed JavaScript module +### [x] P2-3 Check every deployed JavaScript module Affected code: `.github/workflows/build-and-deploy.yml` @@ -144,7 +148,7 @@ Completion criteria: - A syntax error in any deployed module fails the pull-request workflow. - Viewer helper tests run when their source files change. -### [ ] P2-4 Validate rendered pixels, not only PNG byte length +### [x] P2-4 Validate rendered pixels, not only PNG byte length Affected code: `.github/workflows/renderer-compatibility.yml` @@ -159,11 +163,13 @@ Required work: - Include a fixture that covers polarity, holes, analytic arc regions, drills, and inverted rendering. - Store a compact image signature or bounded pixel-difference baseline where - exact output is stable. + exact output is stable. The cross-platform gate currently uses dimensions + and a minimum non-background pixel count to avoid driver-specific image + diffs. - Keep a native context clear/readPixels test separate from renderer output validation. -### [ ] P2-5 Make the WASM build toolchain reproducible +### [x] P2-5 Make the WASM build toolchain reproducible Affected code: @@ -183,7 +189,7 @@ Required work: - Include tool versions in the WASM source hash or build metadata. - Test that local `npm pack` and CI release builds use the same versions. -### [ ] P2-6 Add real macOS x64 validation or correct the support claim +### [x] P2-6 Add real macOS x64 validation or correct the support claim Affected code: @@ -204,7 +210,7 @@ Required work: ## P3: UI Error Handling and Quality Gates -### [ ] P3-1 Handle file-upload promises at event boundaries +### [x] P3-1 Handle file-upload promises at event boundaries Affected code: `js/core/viewer.js` @@ -219,7 +225,7 @@ Required work: cleanup path that always runs. - Add tests for archive-read failure and non-recoverable worker failure. -### [ ] P3-2 Enforce formatting and linting in CI +### [x] P3-2 Enforce formatting and linting in CI Current baseline: @@ -239,16 +245,17 @@ Required work: The following checks passed during the review: -- `cargo test --manifest-path wasm/Cargo.toml --all-targets`: 154 tests passed. -- `npm run check` in `packages/wasm-gerber-renderer`: 21 tests passed. +- `cargo test --manifest-path wasm/Cargo.toml --all-targets`: 161 tests passed. +- `npm run check` in `packages/wasm-gerber-renderer`: 34 tests passed. - `./scripts/vercel-build.sh`: WASM release build completed. - `npm pack --dry-run`: package contained 15 files and packed successfully. - Recursive `node --check` over `js/**/*.js`: passed. - Required DOM IDs: no missing or duplicate IDs were detected. -Remaining runtime validation: +Remaining CI and runtime validation: - Run browser interaction and visual checks at desktop and mobile viewports. -- Run the native Node/CLI pixel smoke on supported Linux, macOS, and Windows - runners. The review environment installed `node-gles-webgl2` successfully, - but its PRoot/Xvfb display could not initialize a native context. +- Confirm the native Node/CLI pixel smoke on the Linux, macOS ARM64, macOS x64, + and Windows compatibility runners. The review environment installed + `node-gles-webgl2` successfully, but its PRoot/Xvfb display could not + initialize a native context. diff --git a/demo/smoke-test.drl b/demo/smoke-test.drl new file mode 100644 index 0000000..5c906e3 --- /dev/null +++ b/demo/smoke-test.drl @@ -0,0 +1,9 @@ +M48 +METRIC,LZ +T01C0.800 +% +T01 +X010000Y010000 +X020000Y020000 +X030000Y010000 +M30 diff --git a/js/core/config.js b/js/core/config.js index 68c91da..c2b3b1b 100644 --- a/js/core/config.js +++ b/js/core/config.js @@ -1,5 +1,10 @@ export const NOTIFICATION_DURATION_MS = 2000; export const MAX_FILE_SIZE_BYTES = 300 * 1024 * 1024; +export const MAX_ARCHIVE_ENTRY_COUNT = 1_000; +export const MAX_ARCHIVE_TOTAL_SIZE_BYTES = MAX_FILE_SIZE_BYTES; +export const MAX_ARCHIVE_COMPRESSION_RATIO = 1_000; +export const MAX_SOURCE_REPEAT = 100; +export const MAX_LAYER_COUNT = 500; export const MAX_SCREENSHOT_STREAM_BAND_BYTES = 1024 * 1024 * 1024; export const MAX_SCREENSHOT_RENDER_TARGET_BYTES = 2 * 1024 * 1024 * 1024; diff --git a/js/core/viewer.js b/js/core/viewer.js index 1ec7762..fba5b6c 100644 --- a/js/core/viewer.js +++ b/js/core/viewer.js @@ -1376,7 +1376,7 @@ export class GerberViewer { this.fileInput.addEventListener("change", (e) => { if (e.target.files.length > 0) { - this.handleFileUpload(e.target.files); + this.startFileUpload(e.target.files); } }); @@ -3125,10 +3125,9 @@ export class GerberViewer { this.isInitialUrlLoading = false; return; } - const repeat = getInitialSourceRepeat(); - const repeatOffset = getInitialSourceRepeatOffset(); - try { + const repeat = getInitialSourceRepeat(); + const repeatOffset = getInitialSourceRepeatOffset(); this.isInitialUrlLoading = true; this.updateUiState(); const url = new URL(sourceUrl); @@ -3193,38 +3192,38 @@ export class GerberViewer { return; } - const oversizedFiles = []; - const validFiles = []; - - this.setWorkspaceStatus("Loading files"); - - // Validate file sizes - for (const file of files) { - if (file.size > MAX_FILE_SIZE_BYTES) { - oversizedFiles.push({ - name: file.name, - size: formatFileSize(file.size), - limit: formatFileSize(MAX_FILE_SIZE_BYTES), - }); - } else { - validFiles.push(file); + try { + const oversizedFiles = []; + const validFiles = []; + + this.setWorkspaceStatus("Loading files"); + + // Validate file sizes + for (const file of files) { + if (file.size > MAX_FILE_SIZE_BYTES) { + oversizedFiles.push({ + name: file.name, + size: formatFileSize(file.size), + limit: formatFileSize(MAX_FILE_SIZE_BYTES), + }); + } else { + validFiles.push(file); + } } - } - // Show warning for oversized files - if (oversizedFiles.length > 0) { - this.showFileSizeWarning(oversizedFiles); - } + // Show warning for oversized files + if (oversizedFiles.length > 0) { + this.showFileSizeWarning(oversizedFiles); + } - if (validFiles.length > 0) { - this.showLoadingModal({ - title: "Loading files", - stage: "Preparing", - current: 0, - total: validFiles.length, - }); + if (validFiles.length > 0) { + this.showLoadingModal({ + title: "Loading files", + stage: "Preparing", + current: 0, + total: validFiles.length, + }); - try { const layerSources = await this.collectLayerSources(validFiles); if (layerSources.length > 0) { @@ -3240,15 +3239,18 @@ export class GerberViewer { this.addDiagnostic("info", "Files loaded", `${loadedCount} processed`); } } - } finally { - this.hideLoadingModal(); } + } finally { + this.hideLoadingModal(); + this.updateUiState(); + this.fileInput.value = ""; } + } - this.updateUiState(); - - // Clear file input - this.fileInput.value = ""; + startFileUpload(files) { + void this.handleFileUpload(files).catch((error) => { + this.handleLayerLoadError("File upload", error); + }); } async loadLayerSources(layerSources, { title = "Loading files" } = {}) { @@ -7043,7 +7045,7 @@ export class GerberViewer { const files = e.dataTransfer?.files; if (files?.length > 0) { - this.handleFileUpload(files); + this.startFileUpload(files); } } diff --git a/js/loading/source-loader.js b/js/loading/source-loader.js index cbe21a2..db788a3 100644 --- a/js/loading/source-loader.js +++ b/js/loading/source-loader.js @@ -9,6 +9,14 @@ import { looksLikeDrillContent, looksLikeGerberContent, } from "./file-utils.js"; +import { + MAX_ARCHIVE_COMPRESSION_RATIO, + MAX_ARCHIVE_ENTRY_COUNT, + MAX_ARCHIVE_TOTAL_SIZE_BYTES, + MAX_FILE_SIZE_BYTES, + MAX_LAYER_COUNT, + MAX_SOURCE_REPEAT, +} from "../core/config.js"; const UNKNOWN_ZIP_LAYER_SNIFF_LINES = 30; @@ -22,8 +30,12 @@ export function getInitialSourceRepeat(search = globalThis.location?.search ?? " const rawRepeat = params.get("repeat"); if (!rawRepeat) return 1; - const repeat = Number.parseInt(rawRepeat, 10); - if (!Number.isFinite(repeat)) return 1; + if (!/^\d+$/.test(rawRepeat)) return 1; + const repeat = Number(rawRepeat); + if (!Number.isSafeInteger(repeat)) return 1; + if (repeat > MAX_SOURCE_REPEAT) { + throw new RangeError(`Source repeat cannot exceed ${MAX_SOURCE_REPEAT}`); + } return Math.max(repeat, 1); } @@ -39,30 +51,69 @@ export function getInitialSourceRepeatOffset( }; } -export async function fetchRemoteFile(url, { onProgress = () => {} } = {}) { - const response = await fetch(url.href); +export async function fetchRemoteFile( + url, + { + onProgress = () => {}, + maxBytes = MAX_FILE_SIZE_BYTES, + fetchImpl = globalThis.fetch, + } = {}, +) { + if (typeof fetchImpl !== "function") { + throw new Error("Remote file loading requires fetch support"); + } + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new RangeError("Remote file byte limit must be a positive safe integer"); + } + const response = await fetchImpl(url.href); if (!response.ok) { throw new Error(`HTTP ${response.status} while loading ${url.href}`); } - const contentLength = Number(response.headers.get("content-length")); + const contentLengthHeader = response.headers.get("content-length"); + const contentLength = + contentLengthHeader == null ? null : Number(contentLengthHeader); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + try { + await response.body?.cancel?.(); + } catch (_cancelError) { + // Preserve the size-limit error. + } + throw createFileSizeLimitError(url.href, contentLength, maxBytes); + } + let blob; - if (!response.body || !Number.isFinite(contentLength) || contentLength <= 0) { + if (!response.body) { blob = await response.blob(); + if (blob.size > maxBytes) { + throw createFileSizeLimitError(url.href, blob.size, maxBytes); + } onProgress(1); } else { const reader = response.body.getReader(); const chunks = []; let receivedLength = 0; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - chunks.push(value); - receivedLength += value.length; - onProgress(Math.min(receivedLength / contentLength, 1)); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + receivedLength += value.length; + if (receivedLength > maxBytes) { + const error = createFileSizeLimitError(url.href, receivedLength, maxBytes); + await reader.cancel(error).catch(() => {}); + throw error; + } + chunks.push(value); + if (Number.isFinite(contentLength) && contentLength > 0) { + onProgress(Math.min(receivedLength / contentLength, 1)); + } + } + } finally { + reader.releaseLock?.(); } + onProgress(1); blob = new Blob(chunks, { type: response.headers.get("content-type") || "", @@ -78,14 +129,23 @@ export async function fetchRemoteFile(url, { onProgress = () => {} } = {}) { export async function collectLayerSources(files, callbacks = {}) { const layerSources = []; + if (files.length > MAX_LAYER_COUNT) { + throw new RangeError(`Cannot load more than ${MAX_LAYER_COUNT} files at once`); + } + for (let index = 0; index < files.length; index++) { const file = typeof files.item === "function" ? files.item(index) : files[index]; if (!file) continue; callbacks.onFileStart?.(file.name, index + 1, files.length); + if (file.size > MAX_FILE_SIZE_BYTES) { + throw createFileSizeLimitError(file.name, file.size, MAX_FILE_SIZE_BYTES); + } + if (isZipFile(file)) { layerSources.push(...(await collectZipLayerSources(file, callbacks))); + assertLayerCount(layerSources.length); continue; } @@ -95,12 +155,20 @@ export async function collectLayerSources(files, callbacks = {}) { sizeBytes: file.size, readText: (onProgress) => readFileText(file, onProgress), }); + assertLayerCount(layerSources.length); } return layerSources; } export function repeatLayerSources(layerSources, repeat, { offset = {} } = {}) { + if (!Number.isSafeInteger(repeat) || repeat < 1 || repeat > MAX_SOURCE_REPEAT) { + throw new RangeError(`Source repeat must be an integer from 1 to ${MAX_SOURCE_REPEAT}`); + } + const repeatedLayerCount = layerSources.length * repeat; + if (!Number.isSafeInteger(repeatedLayerCount) || repeatedLayerCount > MAX_LAYER_COUNT) { + throw new RangeError(`Repeated sources cannot exceed ${MAX_LAYER_COUNT} layers`); + } if (repeat <= 1) { return layerSources; } @@ -213,7 +281,9 @@ async function collectZipLayerSources( try { onArchiveStart(file.name); const zip = await jsZip.loadAsync(file); - const entries = Object.values(zip.files) + const archiveEntries = Object.values(zip.files); + validateZipEntries(archiveEntries, file.name); + const entries = archiveEntries .filter( (entry) => !entry.dir && @@ -233,6 +303,7 @@ async function collectZipLayerSources( : await createUnknownZipLayerSource(entry, file.name, onArchiveWarning); if (source) { sources.push(source); + assertLayerCount(sources.length); } } @@ -369,3 +440,57 @@ function getZipEntrySizeBytes(entry) { ); return Number.isFinite(size) && size > 0 ? size : null; } + +function getZipEntryCompressedSizeBytes(entry) { + const size = Number(entry._data?.compressedSize); + return Number.isFinite(size) && size > 0 ? size : null; +} + +function validateZipEntries(entries, archiveName) { + if (entries.length > MAX_ARCHIVE_ENTRY_COUNT) { + throw new RangeError( + `${archiveName} contains ${entries.length} entries; the limit is ${MAX_ARCHIVE_ENTRY_COUNT}`, + ); + } + + let totalSize = 0; + for (const entry of entries) { + const size = getZipEntrySizeBytes(entry); + if (size == null) continue; + if (size > MAX_FILE_SIZE_BYTES) { + throw createFileSizeLimitError( + `${archiveName}:${entry.name}`, + size, + MAX_FILE_SIZE_BYTES, + ); + } + totalSize += size; + if (!Number.isSafeInteger(totalSize) || totalSize > MAX_ARCHIVE_TOTAL_SIZE_BYTES) { + throw createFileSizeLimitError( + `${archiveName} uncompressed contents`, + totalSize, + MAX_ARCHIVE_TOTAL_SIZE_BYTES, + ); + } + + const compressedSize = getZipEntryCompressedSizeBytes(entry); + if ( + compressedSize != null && + size / compressedSize > MAX_ARCHIVE_COMPRESSION_RATIO + ) { + throw new RangeError( + `${archiveName}:${entry.name} exceeds the supported ZIP compression ratio`, + ); + } + } +} + +function assertLayerCount(count) { + if (count > MAX_LAYER_COUNT) { + throw new RangeError(`Cannot load more than ${MAX_LAYER_COUNT} layers at once`); + } +} + +function createFileSizeLimitError(name, size, limit) { + return new RangeError(`${name} is ${size} bytes; the limit is ${limit} bytes`); +} diff --git a/packages/wasm-gerber-renderer/README.kr.md b/packages/wasm-gerber-renderer/README.kr.md index 32268de..2218d7a 100644 --- a/packages/wasm-gerber-renderer/README.kr.md +++ b/packages/wasm-gerber-renderer/README.kr.md @@ -68,12 +68,10 @@ Node.js와 CLI 렌더링은 [`node-gles-webgl2`](https://github.com/dsafdsaf132/ | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | +| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -macOS x64는 `node-gles-webgl2` 지원 matrix에 포함되지만, 현재 renderer compatibility workflow에서는 build-only 검증만 수행합니다. - ## 브라우저 사용 ```js @@ -177,13 +175,13 @@ Node.js API에서도 일반 `string`은 Gerber 내용으로 취급됩니다. 파 - `renderGerberToCanvas(canvas, layers, frameOptions)`: WebGL2를 지원하는 기존 canvas에 한 번에 배치 렌더링합니다. `layers`는 단일 `GerberLayer`, 배열, 또는 `FileList`가 될 수 있습니다. 실패한 layer는 기본적으로 건너뜁니다. - `renderGerberToPng(canvas, layers, frameOptions, exportOptions)`: 브라우저에서 한 번 렌더링한 뒤 PNG `Blob`을 반환합니다. -- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`: PNG chunk를 `WritableStream`에 쓰고 stream을 닫습니다. 브라우저의 `CompressionStream` 지원이 필요합니다. +- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`: PNG chunk를 `WritableStream`에 쓰고 성공 시 닫으며 실패 시 중단합니다. 브라우저의 `CompressionStream` 지원이 필요합니다. - `createGerberRenderer(canvas, rendererOptions)`: 여러 frame이나 layer를 렌더링할 수 있는 재사용 렌더러를 만듭니다. - `renderer.withFrame(frameOptions, callback)`: frame을 시작하고 canvas/view 옵션을 적용한 뒤 callback이 끝나면 렌더링된 layer를 표시합니다. - `renderer.renderLayer(layer, layerOptions)`: 활성 frame에 layer 하나를 추가하고 숫자 layer ID를 반환합니다. 반드시 `withFrame()` 안에서 호출해야 하며, 이 엄격 API는 실패 시 Promise를 reject합니다. - `renderer.renderLayers(layers, options)`: 여러 layer를 추가하고 `{ renderedCount, failures }`를 반환합니다. 실패한 layer는 기본적으로 건너뛰며, 엄격 모드가 필요하면 `layerErrorMode: "throw"`를 사용하세요. - `renderer.exportPng(exportOptions)`: 마지막 브라우저 frame을 PNG `Blob`으로 내보냅니다. -- `renderer.exportPngStream(writable, exportOptions)`: 마지막 브라우저 frame을 `Blob`으로 조립하지 않고 `WritableStream`에 내보냅니다. +- `renderer.exportPngStream(writable, exportOptions)`: 마지막 브라우저 frame을 `Blob`으로 조립하지 않고 `WritableStream`에 내보내며 성공 시 닫고 실패 시 중단합니다. - `renderer.dispose()`: WebGL context를 해제합니다. ## Node.js 사용 diff --git a/packages/wasm-gerber-renderer/README.md b/packages/wasm-gerber-renderer/README.md index 2f10c79..11d986a 100644 --- a/packages/wasm-gerber-renderer/README.md +++ b/packages/wasm-gerber-renderer/README.md @@ -75,14 +75,10 @@ Node.js and CLI rendering are supported via | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | +| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -macOS x64 is included in the `node-gles-webgl2` support matrix, but the -renderer compatibility workflow currently performs build-only validation for -that platform. - ## Browser Usage ```js @@ -193,13 +189,13 @@ rendering from the filesystem. - `renderGerberToCanvas(canvas, layers, frameOptions)`: one-shot batch render into an existing WebGL2-capable canvas. `layers` may be a single `GerberLayer`, an array, or a `FileList`. Failed layers are skipped by default. - `renderGerberToPng(canvas, layers, frameOptions, exportOptions)`: one-shot browser render that returns a PNG `Blob`. -- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`: one-shot browser render that writes PNG chunks to a `WritableStream` and closes it. Requires browser `CompressionStream` support. +- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`: one-shot browser render that writes PNG chunks to a `WritableStream`, closes it on success, and aborts it on failure. Requires browser `CompressionStream` support. - `createGerberRenderer(canvas, rendererOptions)`: creates a reusable renderer for multiple frames or layers. - `renderer.withFrame(frameOptions, callback)`: starts a frame, applies canvas/view options, runs the callback, and presents rendered layers after it resolves. - `renderer.renderLayer(layer, layerOptions)`: adds one layer to the active frame and returns its numeric layer ID. Must be called inside `withFrame()`. This strict API rejects on failure. - `renderer.renderLayers(layers, options)`: adds multiple layers and returns `{ renderedCount, failures }`. Failed layers are skipped by default; use `layerErrorMode: "throw"` for strict behavior. - `renderer.exportPng(exportOptions)`: exports the last browser frame as a PNG `Blob`. -- `renderer.exportPngStream(writable, exportOptions)`: exports the last browser frame to a `WritableStream` and closes it without assembling a `Blob`. +- `renderer.exportPngStream(writable, exportOptions)`: exports the last browser frame to a `WritableStream`, closing it on success or aborting it on failure, without assembling a `Blob`. - `renderer.dispose()`: releases the WebGL context. ## Node.js Usage diff --git a/packages/wasm-gerber-renderer/README.zh-Hans.md b/packages/wasm-gerber-renderer/README.zh-Hans.md index 6580f34..01959c0 100644 --- a/packages/wasm-gerber-renderer/README.zh-Hans.md +++ b/packages/wasm-gerber-renderer/README.zh-Hans.md @@ -68,12 +68,10 @@ Node.js 和 CLI 渲染通过 [`node-gles-webgl2`](https://github.com/dsafdsaf132 | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | +| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -macOS x64 包含在 `node-gles-webgl2` 支持矩阵中,但当前 renderer compatibility workflow 只对该平台执行 build-only 验证。 - ## 浏览器用法 ```js @@ -177,13 +175,13 @@ type GerberNodeLayer = - `renderGerberToCanvas(canvas, layers, frameOptions)`:一次调用即可将图层批量渲染到现有的 WebGL2 canvas。`layers` 可以是单个 `GerberLayer`、数组或 `FileList`。失败的图层默认会被跳过。 - `renderGerberToPng(canvas, layers, frameOptions, exportOptions)`:在浏览器中完成一次性渲染,并返回 PNG `Blob`。 -- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`:把 PNG 数据块写入 `WritableStream` 并关闭它。需要浏览器支持 `CompressionStream`。 +- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`:把 PNG 数据块写入 `WritableStream`,成功时关闭,失败时中止。需要浏览器支持 `CompressionStream`。 - `createGerberRenderer(canvas, rendererOptions)`:创建可复用渲染器,用于渲染多个帧或多个图层。 - `renderer.withFrame(frameOptions, callback)`:开始一个渲染帧,应用 canvas 和视图选项,并在回调函数结束后显示渲染后的图层。 - `renderer.renderLayer(layer, layerOptions)`:向当前帧添加一个图层,并返回数值型图层 ID。必须在 `withFrame()` 内调用;这是严格接口,失败时会以该错误 reject。 - `renderer.renderLayers(layers, options)`:添加多个图层,并返回 `{ renderedCount, failures }`。失败的图层默认会被跳过;需要严格行为时使用 `layerErrorMode: "throw"`。 - `renderer.exportPng(exportOptions)`:把最后一个浏览器帧导出为 PNG `Blob`。 -- `renderer.exportPngStream(writable, exportOptions)`:把最后一个浏览器帧导出到 `WritableStream`,无需先组装成 `Blob`。 +- `renderer.exportPngStream(writable, exportOptions)`:把最后一个浏览器帧导出到 `WritableStream`,成功时关闭,失败时中止,无需先组装成 `Blob`。 - `renderer.dispose()`:释放 WebGL 上下文。 ## Node.js 用法 diff --git a/packages/wasm-gerber-renderer/README.zh-Hant.md b/packages/wasm-gerber-renderer/README.zh-Hant.md index bf9ee8e..0aff7e5 100644 --- a/packages/wasm-gerber-renderer/README.zh-Hant.md +++ b/packages/wasm-gerber-renderer/README.zh-Hant.md @@ -68,12 +68,10 @@ Node.js 與 CLI 渲染透過 [`node-gles-webgl2`](https://github.com/dsafdsaf132 | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | +| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -macOS x64 包含在 `node-gles-webgl2` 支援矩陣中,但目前 renderer compatibility workflow 只對該平台執行 build-only 驗證。 - ## 瀏覽器用法 ```js @@ -177,13 +175,13 @@ type GerberNodeLayer = - `renderGerberToCanvas(canvas, layers, frameOptions)`:一次呼叫即可將圖層批次渲染到既有的 WebGL2 canvas。`layers` 可以是單個 `GerberLayer`、陣列或 `FileList`。失敗的圖層預設會被跳過。 - `renderGerberToPng(canvas, layers, frameOptions, exportOptions)`:在瀏覽器中完成一次性渲染,並回傳 PNG `Blob`。 -- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`:把 PNG 資料區塊寫入 `WritableStream` 並關閉它。需要瀏覽器支援 `CompressionStream`。 +- `renderGerberToPngStream(canvas, writable, layers, frameOptions, exportOptions)`:把 PNG 資料區塊寫入 `WritableStream`,成功時關閉,失敗時中止。需要瀏覽器支援 `CompressionStream`。 - `createGerberRenderer(canvas, rendererOptions)`:建立可重複使用的渲染器,用於渲染多個渲染幀或多個圖層。 - `renderer.withFrame(frameOptions, callback)`:開始一個渲染幀,套用 canvas 與視圖選項,並在回呼函式結束後顯示渲染後的圖層。 - `renderer.renderLayer(layer, layerOptions)`:向目前渲染幀加入一個圖層,並回傳數值型圖層 ID。必須在 `withFrame()` 內呼叫;這是嚴格介面,失敗時會以該錯誤 reject。 - `renderer.renderLayers(layers, options)`:加入多個圖層,並回傳 `{ renderedCount, failures }`。失敗的圖層預設會被跳過;需要嚴格行為時使用 `layerErrorMode: "throw"`。 - `renderer.exportPng(exportOptions)`:把最後一個瀏覽器渲染幀匯出為 PNG `Blob`。 -- `renderer.exportPngStream(writable, exportOptions)`:把最後一個瀏覽器渲染幀匯出到 `WritableStream`,不需要先組裝成 `Blob`。 +- `renderer.exportPngStream(writable, exportOptions)`:把最後一個瀏覽器渲染幀匯出到 `WritableStream`,成功時關閉,失敗時中止,不需要先組裝成 `Blob`。 - `renderer.dispose()`:釋放 WebGL 上下文。 ## Node.js 用法 diff --git a/packages/wasm-gerber-renderer/SKILL.md b/packages/wasm-gerber-renderer/SKILL.md index 11f061e..65d3be1 100644 --- a/packages/wasm-gerber-renderer/SKILL.md +++ b/packages/wasm-gerber-renderer/SKILL.md @@ -23,8 +23,8 @@ npm install wasm-gerber-renderer node-gles-webgl2 Node.js and CLI rendering are supported via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux -x64/arm64, macOS arm64/x64, and Windows x64/arm64. macOS x64 is build-only in -the renderer compatibility workflow. +x64/arm64, macOS arm64/x64, and Windows x64/arm64. All listed platforms run +the renderer compatibility smoke test. For global CLI usage: @@ -178,7 +178,8 @@ const blob = await renderGerberToPng(canvas, file, { For browser file streaming, pass a `WritableStream` to `renderer.exportPngStream()` or `renderGerberToPngStream()`. This requires `CompressionStream` support, -closes the stream after `IEND`, and avoids building a PNG `Blob`. +closes the stream after `IEND` on success, aborts it on failure, and avoids +building a PNG `Blob`. ## Reusable Renderer @@ -300,7 +301,7 @@ Use `onLayerError` to report skipped layers. Use `layerErrorMode: "throw"` when ## Notes - Node.js rendering requires a WebGL2-capable native module; use `node-gles-webgl2`. -- Node.js and CLI rendering are supported via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux x64/arm64, macOS arm64/x64, and Windows x64/arm64. macOS x64 is build-only in the renderer compatibility workflow. +- Node.js and CLI rendering are supported and smoke-tested via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux x64/arm64, macOS arm64/x64, and Windows x64/arm64. - Parent directories for output PNG files must already exist. - Very large Gerber files can fail with memory limits; report the error and avoid retry loops. - Drill, job, image, text, and metadata files are not Gerber image layers and may be skipped. diff --git a/packages/wasm-gerber-renderer/index.d.ts b/packages/wasm-gerber-renderer/index.d.ts index cc51f7a..a7d005c 100644 --- a/packages/wasm-gerber-renderer/index.d.ts +++ b/packages/wasm-gerber-renderer/index.d.ts @@ -81,6 +81,14 @@ export type ExportOptions = { export type GerberCanvas = HTMLCanvasElement; +export type BrowserPngWritable = + | WritableStream + | { + write(chunk: Uint8Array): Promise | void; + close?(): Promise | void; + abort?(error?: unknown): Promise | void; + }; + export declare function createGerberRenderer( canvas: GerberCanvas, rendererOptions?: RendererOptions, @@ -101,7 +109,7 @@ export declare function renderGerberToPng( export declare function renderGerberToPngStream( canvas: GerberCanvas, - writable: WritableStream | { write(chunk: Uint8Array): Promise | void }, + writable: BrowserPngWritable, layers: GerberLayer | GerberLayer[] | FileList, frameOptions?: FrameOptions, exportOptions?: ExportOptions, @@ -123,7 +131,7 @@ export declare class GerberRenderer { exportPng(exportOptions?: ExportOptions): Promise; exportPngStream( - writable: WritableStream | { write(chunk: Uint8Array): Promise | void }, + writable: BrowserPngWritable, exportOptions?: ExportOptions, ): Promise; diff --git a/packages/wasm-gerber-renderer/index.js b/packages/wasm-gerber-renderer/index.js index d86189c..8825600 100644 --- a/packages/wasm-gerber-renderer/index.js +++ b/packages/wasm-gerber-renderer/index.js @@ -593,8 +593,19 @@ async function streamCanvasToPng(canvas, gl, writable, background, exportOptions }); await sink.write(pngChunk("IEND", new Uint8Array(0))); await sink.close(); + } catch (error) { + try { + await sink.abort(error); + } catch (_abortError) { + // Preserve the rendering or write error. + } + throw error; } finally { - sink.release(); + try { + sink.release(); + } catch (_releaseError) { + // Cleanup must not replace the rendering or write result. + } } } @@ -634,6 +645,7 @@ function createWebWritablePngSink(writable) { return { write: (chunk) => writer.write(chunk), close: () => writer.close(), + abort: (error) => writer.abort(error), release: () => writer.releaseLock(), }; } @@ -641,6 +653,7 @@ function createWebWritablePngSink(writable) { return { write: (chunk) => writable.write(chunk), close: () => writable.close?.(), + abort: (error) => writable.abort?.(error), release: () => {}, }; } @@ -690,8 +703,16 @@ async function deflatePngRowsToWebSink(sink, writeRows) { } throw error; } finally { - writer.releaseLock(); - reader.releaseLock(); + try { + writer.releaseLock(); + } catch (_releaseError) { + // Cleanup must not replace the compression result. + } + try { + reader.releaseLock(); + } catch (_releaseError) { + // Cleanup must not replace the compression result. + } } } diff --git a/packages/wasm-gerber-renderer/test/browser-stream.test.mjs b/packages/wasm-gerber-renderer/test/browser-stream.test.mjs new file mode 100644 index 0000000..c89a8cd --- /dev/null +++ b/packages/wasm-gerber-renderer/test/browser-stream.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GerberRenderer } from "../index.js"; + +test("browser PNG streaming aborts the writable after a render failure", async () => { + const gl = makeGl({ readError: new Error("readPixels failed") }); + const renderer = makeRenderer(gl); + const events = []; + const writable = { + async write() { + events.push("write"); + }, + async close() { + events.push("close"); + }, + async abort(error) { + events.push(["abort", error]); + }, + }; + + await assert.rejects( + renderer.exportPngStream(writable, { background: null, maxBandBytes: 1024 }), + /readPixels failed/, + ); + + assert.equal(events.filter((event) => event === "write").length >= 2, true); + assert.equal(events.includes("close"), false); + const abortEvent = events.find(Array.isArray); + assert.equal(abortEvent[0], "abort"); + assert.match(abortEvent[1].message, /readPixels failed/); +}); + +test("browser PNG streaming closes the writable after success", async () => { + const renderer = makeRenderer(makeGl()); + const events = []; + const writable = { + async write() { + events.push("write"); + }, + async close() { + events.push("close"); + }, + async abort() { + events.push("abort"); + }, + }; + + await renderer.exportPngStream(writable, { + background: null, + maxBandBytes: 1024, + }); + + assert.equal(events.at(-1), "close"); + assert.equal(events.includes("abort"), false); +}); + +test("browser PNG streaming preserves write errors when abort also fails", async () => { + const renderer = makeRenderer(makeGl()); + let aborted = false; + const writable = { + async write() { + throw new Error("write failed"); + }, + async abort() { + aborted = true; + throw new Error("abort failed"); + }, + }; + + await assert.rejects( + renderer.exportPngStream(writable, { background: null, maxBandBytes: 1024 }), + /write failed/, + ); + assert.equal(aborted, true); +}); + +test("browser PNG streaming aborts the destination after compression fails", async () => { + const OriginalCompressionStream = globalThis.CompressionStream; + let rejectRead; + globalThis.CompressionStream = class { + readable = { + getReader: () => ({ + read: () => + new Promise((_resolve, reject) => { + rejectRead = reject; + }), + cancel: async (error) => rejectRead?.(error), + releaseLock() {}, + }), + }; + + writable = { + getWriter: () => ({ + async write() { + throw new Error("compression failed"); + }, + async abort(error) { + rejectRead?.(error); + }, + releaseLock() {}, + }), + }; + }; + + try { + const renderer = makeRenderer(makeGl()); + let abortError = null; + const writable = { + async write() {}, + async abort(error) { + abortError = error; + }, + }; + + await assert.rejects( + renderer.exportPngStream(writable, { background: null, maxBandBytes: 1024 }), + /compression failed/, + ); + assert.match(abortError.message, /compression failed/); + } finally { + globalThis.CompressionStream = OriginalCompressionStream; + } +}); + +function makeRenderer(gl) { + const canvas = { + width: 1, + height: 1, + getContext() { + return gl; + }, + }; + const renderer = new GerberRenderer(canvas, { releaseContext: false }, {}); + renderer.gl = gl; + renderer.lastFrame = { background: null }; + return renderer; +} + +function makeGl({ readError = null } = {}) { + return { + FRAMEBUFFER: 0x8d40, + RGBA: 0x1908, + UNSIGNED_BYTE: 0x1401, + bindFramebuffer() {}, + finish() {}, + readPixels(_x, _y, _width, _height, _format, _type, pixels) { + if (readError) throw readError; + pixels.fill(255); + }, + }; +} diff --git a/packages/wasm-gerber-renderer/test/layer-kind.test.mjs b/packages/wasm-gerber-renderer/test/layer-kind.test.mjs index 2f46ca1..9a96466 100644 --- a/packages/wasm-gerber-renderer/test/layer-kind.test.mjs +++ b/packages/wasm-gerber-renderer/test/layer-kind.test.mjs @@ -5,8 +5,19 @@ import { getLayerSourceKind, } from "../../../js/loading/file-utils.js"; import { + fetchRemoteFile, + getInitialSourceRepeat, collectLayerSources, + repeatLayerSources, } from "../../../js/loading/source-loader.js"; +import { + MAX_ARCHIVE_COMPRESSION_RATIO, + MAX_ARCHIVE_ENTRY_COUNT, + MAX_ARCHIVE_TOTAL_SIZE_BYTES, + MAX_FILE_SIZE_BYTES, + MAX_LAYER_COUNT, + MAX_SOURCE_REPEAT, +} from "../../../js/core/config.js"; import { GerberRenderer } from "../index.js"; import { fileLayer, NodeGerberRenderer } from "../node.js"; import { @@ -198,6 +209,184 @@ M02*`; assert.equal(sources.length, 0); }); +test("viewer source repeat enforces repeat and resulting layer limits", () => { + assert.equal(getInitialSourceRepeat(`?repeat=${MAX_SOURCE_REPEAT}`), MAX_SOURCE_REPEAT); + assert.throws( + () => getInitialSourceRepeat(`?repeat=${MAX_SOURCE_REPEAT + 1}`), + /cannot exceed/, + ); + assert.throws( + () => repeatLayerSources([{ name: "layer.gbr" }], MAX_SOURCE_REPEAT + 1), + /must be an integer/, + ); + + const sources = Array.from( + { length: Math.floor(MAX_LAYER_COUNT / MAX_SOURCE_REPEAT) + 1 }, + (_, index) => ({ name: `layer-${index}.gbr` }), + ); + assert.throws( + () => repeatLayerSources(sources, MAX_SOURCE_REPEAT), + /cannot exceed.*layers/, + ); +}); + +test("viewer remote loader rejects oversized Content-Length before reading", async () => { + let cancelled = false; + const response = { + ok: true, + headers: makeHeaders({ "content-length": String(MAX_FILE_SIZE_BYTES + 1) }), + body: { + async cancel() { + cancelled = true; + }, + }, + }; + + await assert.rejects( + fetchRemoteFile(new URL("https://example.test/board.gbr"), { + fetchImpl: async () => response, + }), + /limit is/, + ); + assert.equal(cancelled, true); +}); + +test("viewer remote loader cancels streams that exceed the byte limit", async () => { + let readIndex = 0; + let cancelled = false; + const chunks = [new Uint8Array(6), new Uint8Array(6)]; + const response = { + ok: true, + headers: makeHeaders({ "content-length": "5" }), + body: { + getReader() { + return { + async read() { + if (readIndex >= chunks.length) return { done: true }; + return { done: false, value: chunks[readIndex++] }; + }, + async cancel() { + cancelled = true; + }, + }; + }, + }, + }; + + await assert.rejects( + fetchRemoteFile(new URL("https://example.test/board.gbr"), { + maxBytes: 10, + fetchImpl: async () => response, + }), + /limit is 10 bytes/, + ); + assert.equal(cancelled, true); +}); + +test("viewer rejects oversized ZIP files before opening the archive", async () => { + let opened = false; + const archive = makeFile("zip", "layers.zip", "application/zip"); + archive.size = MAX_FILE_SIZE_BYTES + 1; + + await assert.rejects( + collectLayerSources([archive], { + jsZip: { + async loadAsync() { + opened = true; + return { files: {} }; + }, + }, + }), + /limit is/, + ); + assert.equal(opened, false); +}); + +test("viewer ZIP rejects oversized entries before decompression", async () => { + const errors = []; + const entry = makeZipEntry(GERBER_CONTENT, "board.gbr", { + uncompressedSize: MAX_FILE_SIZE_BYTES + 1, + unreadable: true, + }); + + const sources = await collectLayerSources( + [makeFile("zip", "layers.zip", "application/zip")], + { + jsZip: { + async loadAsync() { + return { files: { "board.gbr": entry } }; + }, + }, + onArchiveError(_name, error) { + errors.push(error); + }, + }, + ); + + assert.deepEqual(sources, []); + assert.match(errors[0].message, /limit is/); +}); + +test("viewer ZIP rejects excessive entry counts", async () => { + const files = Object.fromEntries( + Array.from({ length: MAX_ARCHIVE_ENTRY_COUNT + 1 }, (_, index) => { + const name = `layer-${index}.gbr`; + return [name, makeZipEntry(GERBER_CONTENT, name, { unreadable: true })]; + }), + ); + const errors = []; + + const sources = await collectLayerSources( + [makeFile("zip", "layers.zip", "application/zip")], + { + jsZip: { async loadAsync() { return { files }; } }, + onArchiveError(_name, error) { + errors.push(error); + }, + }, + ); + + assert.deepEqual(sources, []); + assert.match(errors[0].message, /contains 1001 entries/); +}); + +test("viewer ZIP rejects excessive total expansion and compression ratios", async () => { + const halfTotal = Math.floor(MAX_ARCHIVE_TOTAL_SIZE_BYTES / 2) + 1; + const expansionFiles = { + "first.gbr": makeZipEntry(GERBER_CONTENT, "first.gbr", { + uncompressedSize: halfTotal, + }), + "second.gbr": makeZipEntry(GERBER_CONTENT, "second.gbr", { + uncompressedSize: halfTotal, + }), + }; + const ratioFiles = { + "ratio.gbr": makeZipEntry(GERBER_CONTENT, "ratio.gbr", { + uncompressedSize: MAX_ARCHIVE_COMPRESSION_RATIO + 1, + compressedSize: 1, + }), + }; + + for (const [files, expected] of [ + [expansionFiles, /uncompressed contents.*limit is/], + [ratioFiles, /compression ratio/], + ]) { + const errors = []; + const sources = await collectLayerSources( + [makeFile("zip", "layers.zip", "application/zip")], + { + jsZip: { async loadAsync() { return { files }; } }, + onArchiveError(_name, error) { + errors.push(error); + }, + }, + ); + + assert.deepEqual(sources, []); + assert.match(errors[0].message, expected); + } +}); + function makeFile(content, name, type = "") { const blob = new Blob([content], { type }); return { @@ -220,7 +409,10 @@ function makeZipEntry(content, name, options = {}) { return { dir: false, name, - _data: { uncompressedSize: options.uncompressedSize ?? bytes.byteLength }, + _data: { + uncompressedSize: options.uncompressedSize ?? bytes.byteLength, + compressedSize: options.compressedSize, + }, async async(type, onProgress) { if (options.unreadable) { throw new Error("entry should not be read"); @@ -234,6 +426,17 @@ function makeZipEntry(content, name, options = {}) { }; } +function makeHeaders(values = {}) { + const normalized = new Map( + Object.entries(values).map(([key, value]) => [key.toLowerCase(), value]), + ); + return { + get(name) { + return normalized.get(name.toLowerCase()) ?? null; + }, + }; +} + test("package layer kind uses source paths, names, and raw content deliberately", () => { assert.equal(normalizeLayerKind(null, { path: "holes.drl" }), "drill"); assert.equal(normalizeLayerKind(null, { path: "holes.drd" }, "", DRILL_CONTENT), "drill"); diff --git a/packages/wasm-gerber-renderer/test/viewer-upload.test.mjs b/packages/wasm-gerber-renderer/test/viewer-upload.test.mjs new file mode 100644 index 0000000..fd858ff --- /dev/null +++ b/packages/wasm-gerber-renderer/test/viewer-upload.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { GerberViewer } from "../../../js/core/viewer.js"; + +test("viewer upload cleanup runs when source collection fails", async () => { + const calls = []; + const context = { + fileInput: { value: "selected" }, + isRendererBusy: () => false, + setWorkspaceStatus: () => {}, + showFileSizeWarning: () => {}, + showLoadingModal: () => calls.push("show"), + hideLoadingModal: () => calls.push("hide"), + updateUiState: () => calls.push("update"), + async collectLayerSources() { + throw new Error("collection failed"); + }, + }; + + await assert.rejects( + GerberViewer.prototype.handleFileUpload.call(context, [ + { name: "board.gbr", size: 1 }, + ]), + /collection failed/, + ); + + assert.deepEqual(calls, ["show", "hide", "update"]); + assert.equal(context.fileInput.value, ""); +}); + +test("viewer upload event boundary reports rejected promises", async () => { + let reported = null; + const context = { + async handleFileUpload() { + throw new Error("upload failed"); + }, + handleLayerLoadError(name, error) { + reported = { name, error }; + }, + }; + + GerberViewer.prototype.startFileUpload.call(context, []); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.equal(reported.name, "File upload"); + assert.match(reported.error.message, /upload failed/); +}); diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..2558922 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +[toolchain] +channel = "1.97.0" +profile = "minimal" +components = ["clippy", "rustfmt"] +targets = ["wasm32-unknown-unknown"] diff --git a/scripts/assert-png-content.mjs b/scripts/assert-png-content.mjs new file mode 100644 index 0000000..66f2f31 --- /dev/null +++ b/scripts/assert-png-content.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node + +import { readFile } from "node:fs/promises"; +import { inflateSync } from "node:zlib"; + +const [ + , + , + filePath, + backgroundArg, + minimumArg = "100", + expectedWidthArg, + expectedHeightArg, +] = process.argv; +if (!filePath || !backgroundArg) { + throw new Error( + "Usage: node scripts/assert-png-content.mjs <#rrggbb|transparent> [minimum-pixels] [expected-width expected-height]", + ); +} + +const minimumPixels = Number(minimumArg); +if (!Number.isSafeInteger(minimumPixels) || minimumPixels < 1) { + throw new Error(`Invalid minimum pixel count: ${minimumArg}`); +} +const expectedWidth = parseExpectedDimension(expectedWidthArg, "width"); +const expectedHeight = parseExpectedDimension(expectedHeightArg, "height"); +if ((expectedWidth == null) !== (expectedHeight == null)) { + throw new Error("Expected width and height must be provided together"); +} + +const png = await readFile(filePath); +const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]); +if (!png.subarray(0, signature.length).equals(signature)) { + throw new Error(`${filePath} is not a PNG file`); +} + +let width = 0; +let height = 0; +let bitDepth = 0; +let colorType = -1; +let interlace = -1; +const idatChunks = []; +let offset = signature.length; +while (offset + 12 <= png.length) { + const length = png.readUInt32BE(offset); + const dataStart = offset + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > png.length) { + throw new Error(`${filePath} contains a truncated PNG chunk`); + } + const type = png.toString("ascii", offset + 4, dataStart); + const data = png.subarray(dataStart, dataEnd); + if (type === "IHDR") { + width = data.readUInt32BE(0); + height = data.readUInt32BE(4); + bitDepth = data[8]; + colorType = data[9]; + interlace = data[12]; + } else if (type === "IDAT") { + idatChunks.push(data); + } else if (type === "IEND") { + break; + } + offset = dataEnd + 4; +} + +if (width < 1 || height < 1 || bitDepth !== 8 || interlace !== 0) { + throw new Error(`${filePath} uses an unsupported PNG layout`); +} +if ( + expectedWidth != null && + (width !== expectedWidth || height !== expectedHeight) +) { + throw new Error( + `${filePath} is ${width}x${height}; expected ${expectedWidth}x${expectedHeight}`, + ); +} +const channels = colorType === 2 ? 3 : colorType === 6 ? 4 : 0; +if (channels === 0) { + throw new Error(`${filePath} uses unsupported PNG color type ${colorType}`); +} + +const stride = width * channels; +const filtered = inflateSync(Buffer.concat(idatChunks)); +if (filtered.length !== (stride + 1) * height) { + throw new Error(`${filePath} has an unexpected decoded byte length`); +} + +const background = parseBackground(backgroundArg); +let previous = Buffer.alloc(stride); +let foregroundPixels = 0; +for (let y = 0; y < height; y += 1) { + const rowStart = y * (stride + 1); + const filter = filtered[rowStart]; + const current = Buffer.allocUnsafe(stride); + for (let index = 0; index < stride; index += 1) { + const source = filtered[rowStart + 1 + index]; + const left = index >= channels ? current[index - channels] : 0; + const above = previous[index]; + const upperLeft = index >= channels ? previous[index - channels] : 0; + current[index] = (source + filterPredictor(filter, left, above, upperLeft)) & 0xff; + } + + for (let x = 0; x < width; x += 1) { + const pixel = x * channels; + const alpha = channels === 4 ? current[pixel + 3] : 255; + const differs = background == null + ? alpha > 0 + : [0, 1, 2].some((channel) => { + const value = channels === 4 + ? compositeChannel(current[pixel + channel], alpha, background[channel]) + : current[pixel + channel]; + return value !== background[channel]; + }); + if (differs) foregroundPixels += 1; + } + previous = current; +} + +if (foregroundPixels < minimumPixels) { + throw new Error( + `${filePath} is effectively blank: ${foregroundPixels} foreground pixels, expected at least ${minimumPixels}`, + ); +} + +console.log( + `${filePath}: ${width}x${height}, ${foregroundPixels} foreground pixels, ${png.length} bytes`, +); + +function parseBackground(value) { + if (value === "transparent") return null; + const match = /^#([0-9a-f]{6})$/i.exec(value); + if (!match) throw new Error(`Invalid background color: ${value}`); + const hex = match[1]; + return [0, 2, 4].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)); +} + +function parseExpectedDimension(value, name) { + if (value == null) return null; + const dimension = Number(value); + if (!Number.isSafeInteger(dimension) || dimension < 1) { + throw new Error(`Invalid expected ${name}: ${value}`); + } + return dimension; +} + +function compositeChannel(foreground, alpha, background) { + return Math.round((foreground * alpha + background * (255 - alpha)) / 255); +} + +function filterPredictor(filter, left, above, upperLeft) { + switch (filter) { + case 0: + return 0; + case 1: + return left; + case 2: + return above; + case 3: + return Math.floor((left + above) / 2); + case 4: + return paeth(left, above, upperLeft); + default: + throw new Error(`Unsupported PNG row filter ${filter}`); + } +} + +function paeth(left, above, upperLeft) { + const estimate = left + above - upperLeft; + const leftDistance = Math.abs(estimate - left); + const aboveDistance = Math.abs(estimate - above); + const upperLeftDistance = Math.abs(estimate - upperLeft); + if (leftDistance <= aboveDistance && leftDistance <= upperLeftDistance) return left; + return aboveDistance <= upperLeftDistance ? above : upperLeft; +} diff --git a/scripts/vercel-build.sh b/scripts/vercel-build.sh index b497fa9..6a3127b 100755 --- a/scripts/vercel-build.sh +++ b/scripts/vercel-build.sh @@ -12,6 +12,7 @@ export CARGO_HOME="${CARGO_HOME:-$HOME/.cargo}" export RUSTUP_HOME="${RUSTUP_HOME:-$HOME/.rustup}" export PATH="$CARGO_HOME/bin:$PATH" WASM_PACK_VERSION="0.14.0" +RUST_TOOLCHAIN_VERSION="1.97.0" for rust_env in "$CARGO_HOME/env" "$RUSTUP_HOME/env" /rust/env; do if [[ -f "$rust_env" ]]; then @@ -25,7 +26,7 @@ wasm_package_hash() { ( cd "$REPO_ROOT" { - printf '%s\0' scripts/vercel-build.sh wasm/Cargo.lock wasm/Cargo.toml + printf '%s\0' scripts/vercel-build.sh rust-toolchain.toml wasm/Cargo.lock wasm/Cargo.toml find wasm/src -type f -print0 | sort -z } | xargs -0 sha256sum ) | sha256sum | cut -d ' ' -f1 @@ -44,11 +45,18 @@ if [[ exit 0 fi -rustup toolchain install stable --profile minimal -rustup default stable -rustup target add wasm32-unknown-unknown --toolchain stable +installed_rustc_version="$(RUSTUP_TOOLCHAIN="$RUST_TOOLCHAIN_VERSION" rustc --version 2>/dev/null || true)" +if [[ "$installed_rustc_version" != "rustc $RUST_TOOLCHAIN_VERSION "* ]]; then + rustup toolchain install "$RUST_TOOLCHAIN_VERSION" --profile minimal +fi +export RUSTUP_TOOLCHAIN="$RUST_TOOLCHAIN_VERSION" +rust_sysroot="$(rustc --print sysroot)" +if [[ ! -d "$rust_sysroot/lib/rustlib/wasm32-unknown-unknown" ]]; then + rustup target add wasm32-unknown-unknown --toolchain "$RUST_TOOLCHAIN_VERSION" +fi -if ! command -v wasm-pack >/dev/null 2>&1; then +installed_wasm_pack_version="$(wasm-pack --version 2>/dev/null || true)" +if [[ "$installed_wasm_pack_version" != "wasm-pack $WASM_PACK_VERSION" ]]; then case "$(uname -m)" in x86_64 | amd64) wasm_pack_arch="x86_64" @@ -76,6 +84,11 @@ if ! command -v wasm-pack >/dev/null 2>&1; then rm -rf "$wasm_pack_tmp" fi +if [[ "$(wasm-pack --version)" != "wasm-pack $WASM_PACK_VERSION" ]]; then + echo "Failed to activate wasm-pack $WASM_PACK_VERSION" >&2 + exit 1 +fi + cd "$REPO_ROOT/wasm" wasm-pack build --target web --out-dir pkg --release printf '%s\n' "$wasm_hash" > pkg/.source-hash diff --git a/wasm/src/geometry/shape.rs b/wasm/src/geometry/shape.rs index 542dc07..797517c 100644 --- a/wasm/src/geometry/shape.rs +++ b/wasm/src/geometry/shape.rs @@ -504,6 +504,28 @@ impl PathRegions { self.wedge_vertex_offsets.len().saturating_sub(1) } + pub(crate) fn work_item_count(&self) -> usize { + self.wedge_vertices + .len() + .saturating_add(self.sector_vertices.len()) + .saturating_add(self.cover_vertices.len()) + .saturating_add(self.clear_vertices.len()) + .saturating_add( + self.pick_contours + .iter() + .flat_map(|region| region.iter()) + .map(Vec::len) + .sum::(), + ) + .saturating_add( + self.source_contours + .iter() + .flat_map(|region| region.iter()) + .map(|contour| contour.points.len().saturating_add(contour.segments.len())) + .sum::(), + ) + } + pub(crate) fn has_geometry(&self) -> bool { self.region_count() > 0 && (!self.wedge_vertices.is_empty() diff --git a/wasm/src/interaction/mod.rs b/wasm/src/interaction/mod.rs index b39d10c..62ebdc9 100644 --- a/wasm/src/interaction/mod.rs +++ b/wasm/src/interaction/mod.rs @@ -2671,8 +2671,7 @@ fn validate_compact_pick_offsets_invariant( } fn validate_compact_path_region(path_regions: &PathRegions) -> Result<(), JsValue> { - validate_compact_path_region_invariant(path_regions) - .map_err(|message| JsValue::from_str(message)) + validate_compact_path_region_invariant(path_regions).map_err(JsValue::from_str) } fn validate_compact_path_region_invariant(path_regions: &PathRegions) -> Result<(), &'static str> { diff --git a/wasm/src/interaction/tests.rs b/wasm/src/interaction/tests.rs index 2bfb42a..6555537 100644 --- a/wasm/src/interaction/tests.rs +++ b/wasm/src/interaction/tests.rs @@ -1,4 +1,3 @@ - use super::*; fn circle_feature(x: f32, radius: f32, polarity: Polarity) -> InteractionFeature { diff --git a/wasm/src/parser/aperture.rs b/wasm/src/parser/aperture.rs index 97a99f5..90f253f 100644 --- a/wasm/src/parser/aperture.rs +++ b/wasm/src/parser/aperture.rs @@ -551,8 +551,15 @@ pub fn parse_aperture( if params.len() >= 2 { if let (Ok(diameter), Ok(num_vertices)) = ( params[0].trim().parse::(), - params[1].trim().parse::(), + params[1].trim().parse::(), ) { + if !num_vertices.is_finite() + || num_vertices.fract() != 0.0 + || !(3.0..=12.0).contains(&num_vertices) + { + return; + } + let num_vertices = num_vertices as u32; let diameter_mm = diameter * unit_multiplier; // Parse rotation (degrees, defaults to 0) @@ -578,11 +585,10 @@ pub fn parse_aperture( aperture.width = diameter_mm; aperture.height = diameter_mm; aperture.hole_diameter = hole_diameter_mm; - aperture.vertices = num_vertices.max(0.0) as u32; + aperture.vertices = num_vertices; aperture.rotation = rotation_radians; } let radius = diameter_mm / 2.0; - let num_vertices = num_vertices as u32; let angle_step = 2.0 * std::f32::consts::PI / num_vertices as f32; // Fan triangulation with rotation diff --git a/wasm/src/parser/aperture_macro.rs b/wasm/src/parser/aperture_macro.rs index 8a9232f..b5e9aa3 100644 --- a/wasm/src/parser/aperture_macro.rs +++ b/wasm/src/parser/aperture_macro.rs @@ -2,6 +2,11 @@ use super::geometry::{line_to_triangles, rotate_point, triangulate_outline, Prim use std::collections::HashMap; use std::mem::take; +const MIN_OUTLINE_VERTICES: usize = 3; +const MAX_OUTLINE_VERTICES: usize = 5_000; +const MIN_POLYGON_VERTICES: usize = 3; +const MAX_POLYGON_VERTICES: usize = 12; + /// Aperture macro definition - kept as statements until parameters arrive #[derive(Clone, Debug)] pub struct ApertureMacro { @@ -392,6 +397,20 @@ fn same_point(a: [f32; 2], b: [f32; 2]) -> bool { (a[0] - b[0]).abs() < 0.000001 && (a[1] - b[1]).abs() < 0.000001 } +fn evaluate_integer_parameter( + expr: &str, + variables: &HashMap, + minimum: usize, + maximum: usize, +) -> Option { + let value = evaluate_expression(expr, variables).ok()?; + if !value.is_finite() || value.fract() != 0.0 { + return None; + } + let value = value as usize; + (minimum..=maximum).contains(&value).then_some(value) +} + /// Parse primitive statement: 1,1,$7,$5-$3,$6-$3,$4* pub fn parse_primitive_statement( stmt: &str, @@ -447,14 +466,19 @@ pub fn parse_primitive_statement( return None; } let exposure: f32 = evaluate_expression(parts[1], variables).ok()?; - let num_vertices: u32 = evaluate_expression(parts[2], variables).ok()? as u32; - let closed_point_count = num_vertices as usize + 1; - let closed_rotation_idx = 3 + closed_point_count * 2; - let open_rotation_idx = 3 + (num_vertices as usize) * 2; + let num_vertices = evaluate_integer_parameter( + parts[2], + variables, + MIN_OUTLINE_VERTICES, + MAX_OUTLINE_VERTICES, + )?; + let closed_point_count = num_vertices.checked_add(1)?; + let closed_rotation_idx = 3usize.checked_add(closed_point_count.checked_mul(2)?)?; + let open_rotation_idx = 3usize.checked_add(num_vertices.checked_mul(2)?)?; let (point_count, rotation_idx) = if parts.len() > closed_rotation_idx { (closed_point_count, closed_rotation_idx) } else { - (num_vertices as usize, open_rotation_idx) + (num_vertices, open_rotation_idx) }; let rotation: f32 = if parts.len() > rotation_idx { degrees_to_radians(evaluate_expression(parts[rotation_idx], variables).ok()?) @@ -464,6 +488,7 @@ pub fn parse_primitive_statement( // Collect vertices let mut vertices = Vec::new(); + vertices.try_reserve_exact(point_count).ok()?; for i in 0..point_count { let x_idx = 3 + i * 2; let y_idx = 3 + i * 2 + 1; @@ -510,7 +535,12 @@ pub fn parse_primitive_statement( return None; } let exposure: f32 = evaluate_expression(parts[1], variables).ok()?; - let num_vertices: u32 = evaluate_expression(parts[2], variables).ok()? as u32; + let num_vertices = evaluate_integer_parameter( + parts[2], + variables, + MIN_POLYGON_VERTICES, + MAX_POLYGON_VERTICES, + )?; let center_x: f32 = evaluate_expression(parts[3], variables).ok()?; let center_y: f32 = evaluate_expression(parts[4], variables).ok()?; let diameter: f32 = evaluate_expression(parts[5], variables).ok()?; @@ -524,8 +554,9 @@ pub fn parse_primitive_statement( let radius = diameter / 2.0; let mut vertices = Vec::new(); let angle_step = 2.0 * std::f32::consts::PI / num_vertices as f32; + vertices.try_reserve_exact(num_vertices).ok()?; - for i in 0..num_vertices as usize { + for i in 0..num_vertices { let angle = angle_step * i as f32; let x = center_x + radius * angle.cos(); let y = center_y + radius * angle.sin(); @@ -533,8 +564,8 @@ pub fn parse_primitive_statement( } // Fan triangulation: create triangles from center to all adjacent vertices - for i in 0..(num_vertices as usize) { - let next_i = (i + 1) % (num_vertices as usize); + for i in 0..num_vertices { + let next_i = (i + 1) % num_vertices; let mut triangle = Primitive::Triangle { vertices: [[center_x, center_y], vertices[i], vertices[next_i]], exposure, diff --git a/wasm/src/parser/geometry.rs b/wasm/src/parser/geometry.rs index 325d62a..00754f9 100644 --- a/wasm/src/parser/geometry.rs +++ b/wasm/src/parser/geometry.rs @@ -16,6 +16,11 @@ use std::mem::size_of; use std::mem::take; use std::rc::Rc; +const MAX_GENERATED_ITEMS_PER_COMMAND: usize = 5_000_000; +const PATH_WEDGE_VERTEX_FLOATS: usize = 6; +const PATH_COVER_VERTEX_FLOATS: usize = 12; +const PATH_SECTOR_QUAD_VERTICES: usize = 6; + fn format_primitive_allocation(additional: usize) -> String { let primitives = format_count(additional); match additional.checked_mul(size_of::()) { @@ -37,6 +42,196 @@ fn checked_primitive_count( }) } +fn consume_expansion( + state: &ParserState, + count: usize, + multiplier: usize, + context: &str, +) -> Result { + let total = checked_primitive_count(count, multiplier, context)?; + if total > MAX_GENERATED_ITEMS_PER_COMMAND { + return Err(format!( + "Gerber {context} expands to {total} items, exceeding the per-command limit of {MAX_GENERATED_ITEMS_PER_COMMAND}" + )); + } + state.consume_generated_items(total, context)?; + Ok(total) +} + +fn aperture_flash_work_items(aperture: &Aperture) -> Result { + if aperture.triangle_template.is_some() && !aperture.has_negative { + return Ok(1); + } + + let count = aperture.primitives.len().max(1); + if aperture.has_negative { + checked_primitive_count(count, 64, "negative aperture flash") + } else { + Ok(count) + } +} + +fn block_flash_work_items(block_layers: &[PolarityLayer]) -> Result { + block_layers.iter().try_fold(0usize, |total, layer| { + let layer_items = layer + .primitives + .len() + .checked_add(layer.path_regions.work_item_count()) + .ok_or_else(|| "Gerber aperture block work item count overflow".to_string())? + .max(1); + total + .checked_add(layer_items) + .ok_or_else(|| "Gerber aperture block work item count overflow".to_string()) + }) +} + +fn checked_work_item_sum(total: usize, additional: usize, context: &str) -> Result { + total + .checked_add(additional) + .ok_or_else(|| format!("Gerber {context} work item count overflow")) +} + +fn region_source_work_items(region_contours: &[RegionContour]) -> Result { + region_contours.iter().try_fold(0usize, |total, contour| { + let contour_items = contour + .points + .len() + .checked_add(contour.segments.len()) + .ok_or_else(|| "Gerber region source work item count overflow".to_string())?; + checked_work_item_sum(total, contour_items, "region source") + }) +} + +fn flattened_contour_point_count( + contour: &RegionContour, + arc_tessellation_quality: u32, +) -> Result { + if contour.segments.is_empty() { + return Ok(contour.points.len()); + } + + let mut count = 0usize; + for segment in &contour.segments { + if count == 0 { + count = 1; + } + let additional = match *segment { + RegionSegment::Line { .. } => 1, + RegionSegment::Arc { + start, + end, + center, + radius, + start_angle, + sweep_angle, + clamp_sweep, + } => { + let arc = canonical_arc_geometry( + start, + end, + center, + radius, + start_angle, + sweep_angle, + clamp_sweep, + ); + let max_angle_step = + region_arc_tessellation_max_angle_step(arc_tessellation_quality); + ((arc.sweep_angle.abs() / max_angle_step).ceil() as usize).clamp(1, 512) + } + }; + count = checked_work_item_sum(count, additional, "region pick contour")?; + } + Ok(count) +} + +fn path_region_work_items_per_copy( + region_contours: &[RegionContour], + arc_tessellation_quality: u32, + collect_pick_contours: bool, + collect_source_contours: bool, +) -> Result { + if path_region_bounds(region_contours, 0.0, 0.0).is_none() { + return Ok(1); + } + + let mut total = PATH_COVER_VERTEX_FLOATS + .checked_mul(2) + .ok_or_else(|| "Gerber path region work item count overflow".to_string())?; + let flatten_pick_contours = region_contours_have_arcs(region_contours); + + for contour in region_contours { + for segment in &contour.segments { + let segment_items = match *segment { + RegionSegment::Line { .. } => PATH_WEDGE_VERTEX_FLOATS, + RegionSegment::Arc { + start, + end, + center, + radius, + start_angle, + sweep_angle, + clamp_sweep, + } => { + let arc = canonical_arc_geometry( + start, + end, + center, + radius, + start_angle, + sweep_angle, + clamp_sweep, + ); + let sweep = arc + .sweep_angle + .clamp(-std::f32::consts::TAU, std::f32::consts::TAU); + let chunk_count = + ((sweep.abs() / std::f32::consts::FRAC_PI_2).ceil() as usize).max(1); + let items_per_chunk = PATH_WEDGE_VERTEX_FLOATS + .checked_add( + PATH_SECTOR_QUAD_VERTICES + .checked_mul(PATH_SECTOR_VERTEX_FLOATS) + .ok_or_else(|| { + "Gerber path region work item count overflow".to_string() + })?, + ) + .ok_or_else(|| "Gerber path region work item count overflow".to_string())?; + checked_primitive_count(chunk_count, items_per_chunk, "path region arc")? + } + }; + total = checked_work_item_sum(total, segment_items, "path region")?; + } + + if let (Some(first), Some(last)) = (contour.points.first(), contour.points.last()) { + if !points_coincide(first[0], first[1], last[0], last[1]) { + total = + checked_work_item_sum(total, PATH_WEDGE_VERTEX_FLOATS, "path region closure")?; + } + } + + if collect_pick_contours { + let pick_points = if flatten_pick_contours { + flattened_contour_point_count(contour, arc_tessellation_quality)? + } else { + contour.points.len() + }; + if pick_points >= 3 { + total = checked_work_item_sum(total, pick_points, "region pick contour")?; + } + } + } + + if collect_source_contours { + total = checked_work_item_sum( + total, + region_source_work_items(region_contours)?, + "region source", + )?; + } + + Ok(total.max(1)) +} + fn try_reserve_primitives( primitives: &mut Vec, additional: usize, @@ -1353,7 +1548,14 @@ pub fn flash_aperture( y: f32, ) -> Result<(), String> { if let Some(aperture) = apertures.get(&state.current_aperture) { + let repeat_count = state.step_repeat_count("aperture flash")?; if let Some(block_layers) = aperture.block_layers.as_ref() { + consume_expansion( + state, + block_flash_work_items(block_layers)?, + repeat_count, + "aperture block flash", + )?; flash_block_aperture( block_layers, state, @@ -1372,11 +1574,7 @@ pub fn flash_aperture( state.mirror_y, state.layer_rotation, ) { - let repeat_count = checked_primitive_count( - state.sr_x as usize, - state.sr_y as usize, - "aperture triangle template flash step repeat", - )?; + consume_expansion(state, 1, repeat_count, "aperture triangle template flash")?; try_reserve_primitives(primitives, repeat_count, "aperture triangle template flash")?; for sy in 0..state.sr_y { for sx in 0..state.sr_x { @@ -1390,6 +1588,13 @@ pub fn flash_aperture( return Ok(()); } + consume_expansion( + state, + aperture_flash_work_items(aperture)?, + repeat_count, + "aperture flash", + )?; + // Step and Repeat iteration for sy in 0..state.sr_y { for sx in 0..state.sr_x { @@ -1426,6 +1631,17 @@ pub fn execute_interpolation( let start_y = state.y; if let Some(aperture) = apertures.get(&state.current_aperture) { + let repeat_count = state.step_repeat_count("interpolation")?; + let per_copy = if points_coincide(start_x, start_y, end_x, end_y) { + aperture_flash_work_items(aperture)? + } else if aperture.is_solid_circle { + 3 + } else { + 0 + }; + if per_copy > 0 { + consume_expansion(state, per_copy, repeat_count, "interpolation")?; + } for sy in 0..state.sr_y { for sx in 0..state.sr_x { let offset_x = sx as f32 * state.sr_i; @@ -1915,6 +2131,14 @@ pub fn build_path_regions( collect_source_contours: bool, ) -> Result { let mut path_regions = PathRegions::empty(); + let repeat_count = state.step_repeat_count("path region")?; + let work_items = path_region_work_items_per_copy( + region_contours, + arc_tessellation_quality, + collect_pick_contours, + collect_source_contours, + )?; + consume_expansion(state, work_items, repeat_count, "path region")?; for sy in 0..state.sr_y { for sx in 0..state.sr_x { @@ -2305,7 +2529,11 @@ fn push_wedge_triangle( b: [f32; 2], c: [f32; 2], ) -> Result<(), String> { - try_reserve_values(vertices, 6, "path region wedge vertices")?; + try_reserve_values( + vertices, + PATH_WEDGE_VERTEX_FLOATS, + "path region wedge vertices", + )?; vertices.extend_from_slice(&[a[0], a[1], b[0], b[1], c[0], c[1]]); Ok(()) } @@ -2317,7 +2545,11 @@ fn push_cover_quad( min_y: f32, max_y: f32, ) -> Result<(), String> { - try_reserve_values(vertices, 12, "path region cover vertices")?; + try_reserve_values( + vertices, + PATH_COVER_VERTEX_FLOATS, + "path region cover vertices", + )?; vertices.extend_from_slice(&[ min_x, min_y, max_x, min_y, min_x, max_y, min_x, max_y, max_x, min_y, max_x, max_y, ]); @@ -2392,7 +2624,7 @@ fn push_sector_cap_quad( try_reserve_values( vertices, - 6 * PATH_SECTOR_VERTEX_FLOATS, + PATH_SECTOR_QUAD_VERTICES * PATH_SECTOR_VERTEX_FLOATS, "path region arc sector vertices", )?; for point in [start, end, end_outer, start, end_outer, start_outer] { @@ -2939,6 +3171,7 @@ pub fn parse_graphic_command( repeat_count, "region", )?; + consume_expansion(state, additional, 1, "region")?; try_reserve_primitives(primitives, additional, "region")?; for sy in 0..state.sr_y { diff --git a/wasm/src/parser/geometry/tests.rs b/wasm/src/parser/geometry/tests.rs index 19238ed..33357e7 100644 --- a/wasm/src/parser/geometry/tests.rs +++ b/wasm/src/parser/geometry/tests.rs @@ -1,4 +1,3 @@ - use super::*; #[test] diff --git a/wasm/src/parser/mod.rs b/wasm/src/parser/mod.rs index e41f9cb..2bb37d4 100644 --- a/wasm/src/parser/mod.rs +++ b/wasm/src/parser/mod.rs @@ -1084,7 +1084,7 @@ fn parse_command( .map_err(|message| JsValue::from_str(&message))?; } else if line.starts_with("%SR") { // Step and repeat: %SRX3Y2I10J20*% - parse_sr(&line, state); + parse_sr(&line, state).map_err(|message| JsValue::from_str(&message))?; } else if line.starts_with("%IF") { // Image polarity: %IFPOS*% or %IFNEG*% parse_if(&line, state); @@ -1234,7 +1234,9 @@ fn parse_aperture_block( collect_region_source_contours, )?; if let Some(enclosing_state) = nested_block_state { + let generated_items = state.generated_items(); *state = enclosing_state; + state.set_generated_items(generated_items); } } else if block_line.starts_with('G') || block_line.starts_with('D') diff --git a/wasm/src/parser/state.rs b/wasm/src/parser/state.rs index d51288b..d767dc2 100644 --- a/wasm/src/parser/state.rs +++ b/wasm/src/parser/state.rs @@ -2,10 +2,15 @@ use super::geometry::Primitive; use super::PolarityLayer; use crate::geometry::PathRegions; use crate::parser::common::{ - CoordinateFormat as CommonCoordinateFormat, ZeroSuppression as CommonZeroSuppression, + read_word_value, CoordinateFormat as CommonCoordinateFormat, + ZeroSuppression as CommonZeroSuppression, }; +use std::cell::Cell; use std::mem::take; +pub(crate) const MAX_STEP_REPEAT_COPIES: usize = 100_000; +pub(crate) const MAX_GENERATED_ITEMS: usize = 5_000_000; + /// Polarity - Dark (positive) or Clear (negative) #[derive(Clone, Copy, Debug, PartialEq)] pub enum Polarity { @@ -97,6 +102,7 @@ pub struct ParserState { pub mirror_y: bool, // Layer Rotation pub layer_rotation: f32, + generated_items: Cell, } impl Default for ParserState { @@ -124,10 +130,52 @@ impl Default for ParserState { mirror_x: false, mirror_y: false, layer_rotation: 0.0, + generated_items: Cell::new(0), } } } +impl ParserState { + pub(crate) fn step_repeat_count(&self, context: &str) -> Result { + let count = (self.sr_x as usize) + .checked_mul(self.sr_y as usize) + .ok_or_else(|| format!("Gerber {context} step-repeat count overflow"))?; + if count > MAX_STEP_REPEAT_COPIES { + return Err(format!( + "Gerber {context} step-repeat count {count} exceeds the supported limit of {MAX_STEP_REPEAT_COPIES}" + )); + } + Ok(count) + } + + pub(crate) fn consume_generated_items( + &self, + additional: usize, + context: &str, + ) -> Result<(), String> { + let next = self + .generated_items + .get() + .checked_add(additional) + .ok_or_else(|| format!("Gerber {context} generated geometry count overflow"))?; + if next > MAX_GENERATED_ITEMS { + return Err(format!( + "Gerber generated geometry exceeds the supported limit of {MAX_GENERATED_ITEMS} items while processing {context}" + )); + } + self.generated_items.set(next); + Ok(()) + } + + pub(crate) fn generated_items(&self) -> usize { + self.generated_items.get() + } + + pub(crate) fn set_generated_items(&self, value: usize) { + self.generated_items.set(value); + } +} + /// Parse Format specification - %FSLAX24Y24*% /// Format: %FS[L|T][A|I][X_int_digits][X_dec_digits][Y_int_digits][Y_dec_digits]*% /// Example: %FSLAX24Y24*% = Leading, Absolute, 2 integer digits + 4 decimal digits @@ -216,7 +264,7 @@ pub fn parse_format_spec(line: &str, state: &mut ParserState) { /// Parse Step and Repeat - %SRX3Y2I10J20*% /// Format: %SR[X count][Y count][I x_step][J y_step]*% /// %SR* without parameters disables step and repeat -pub fn parse_sr(line: &str, state: &mut ParserState) { +pub fn parse_sr(line: &str, state: &mut ParserState) -> Result<(), String> { // Extract SRX3Y2I10J20 part from %SRX3Y2I10J20*% format let spec_str = line .trim_start_matches('%') @@ -224,7 +272,7 @@ pub fn parse_sr(line: &str, state: &mut ParserState) { .trim_end_matches('*'); if !spec_str.starts_with("SR") { - return; + return Ok(()); } let spec_content = &spec_str[2..]; // "X3Y2I10J20" part @@ -235,52 +283,66 @@ pub fn parse_sr(line: &str, state: &mut ParserState) { state.sr_y = 1; state.sr_i = 0.0; state.sr_j = 0.0; - return; + return Ok(()); } - // Extract X count - if let Some(x_pos) = spec_content.find('X') { - let x_part = &spec_content[x_pos + 1..]; - let x_end = x_part - .find(|c: char| !c.is_ascii_digit()) - .unwrap_or(x_part.len()); - if let Ok(x_count) = x_part[..x_end].parse::() { - state.sr_x = x_count; + let parse_count = |word: char, current: u32| -> Result { + let Some(raw) = read_word_value(spec_content, word, false) else { + if spec_content.contains(word) { + return Err(format!( + "Invalid Gerber step-repeat {word} count: missing numeric value" + )); + } + return Ok(current); + }; + let value = raw + .parse::() + .map_err(|_| format!("Invalid Gerber step-repeat {word} count `{raw}`"))?; + if value == 0 { + return Err(format!( + "Gerber step-repeat {word} count must be at least 1" + )); } - } - - // Extract Y count - if let Some(y_pos) = spec_content.find('Y') { - let y_part = &spec_content[y_pos + 1..]; - let y_end = y_part - .find(|c: char| !c.is_ascii_digit() && c != '-' && c != '+' && c != '.') - .unwrap_or(y_part.len()); - if let Ok(y_count) = y_part[..y_end].parse::() { - state.sr_y = y_count; + Ok(value) + }; + let parse_step = |word: char, current: f32| -> Result { + let Some(raw) = read_word_value(spec_content, word, true) else { + if spec_content.contains(word) { + return Err(format!( + "Invalid Gerber step-repeat {word} distance: missing numeric value" + )); + } + return Ok(current); + }; + let value = raw + .parse::() + .map_err(|_| format!("Invalid Gerber step-repeat {word} distance `{raw}`"))?; + if !value.is_finite() || value < 0.0 { + return Err(format!( + "Gerber step-repeat {word} distance must be finite and non-negative" + )); } - } + Ok(value * state.unit_multiplier) + }; - // Extract I step (X direction spacing) - if let Some(i_pos) = spec_content.find('I') { - let i_part = &spec_content[i_pos + 1..]; - let i_end = i_part - .find(|c: char| !c.is_ascii_digit() && c != '-' && c != '+' && c != '.') - .unwrap_or(i_part.len()); - if let Ok(i_step) = i_part[..i_end].parse::() { - state.sr_i = i_step * state.unit_multiplier; - } + let next_x = parse_count('X', state.sr_x)?; + let next_y = parse_count('Y', state.sr_y)?; + let next_i = parse_step('I', state.sr_i)?; + let next_j = parse_step('J', state.sr_j)?; + let repeat_count = (next_x as usize) + .checked_mul(next_y as usize) + .ok_or_else(|| "Gerber step-repeat count overflow".to_string())?; + if repeat_count > MAX_STEP_REPEAT_COPIES { + return Err(format!( + "Gerber step-repeat count {repeat_count} exceeds the supported limit of {MAX_STEP_REPEAT_COPIES}" + )); } - // Extract J step (Y direction spacing) - if let Some(j_pos) = spec_content.find('J') { - let j_part = &spec_content[j_pos + 1..]; - let j_end = j_part - .find(|c: char| !c.is_ascii_digit() && c != '-' && c != '+' && c != '.') - .unwrap_or(j_part.len()); - if let Ok(j_step) = j_part[..j_end].parse::() { - state.sr_j = j_step * state.unit_multiplier; - } - } + state.sr_x = next_x; + state.sr_y = next_y; + state.sr_i = next_i; + state.sr_j = next_j; + Ok(()) } /// Parse Polarity - %LPD* (positive) or %LPC* (negative) diff --git a/wasm/src/parser/tests.rs b/wasm/src/parser/tests.rs index 9029f33..e2d442c 100644 --- a/wasm/src/parser/tests.rs +++ b/wasm/src/parser/tests.rs @@ -1,10 +1,10 @@ use super::aperture_macro::{evaluate_expression, parse_macro}; +use super::state::{parse_sr, MAX_GENERATED_ITEMS, MAX_STEP_REPEAT_COPIES}; use super::{ format_count, parse_gerber, parse_gerber_payload_with_options, parse_gerber_with_options, - GerberParser, Polarity, + GerberParser, ParserState, Polarity, }; -use crate::geometry::RegionSegment; -use crate::geometry::{GerberData, PATH_SECTOR_VERTEX_FLOATS}; +use crate::geometry::{GerberData, RegionContour, RegionSegment, PATH_SECTOR_VERTEX_FLOATS}; use crate::interaction::FeatureKind; use std::collections::HashMap; @@ -81,6 +81,124 @@ fn allocation_count_formatting_groups_digits_without_underflow() { assert_eq!(format_count(24_000_000), "24,000,000"); } +#[test] +fn rejects_step_repeat_counts_above_parser_limit() { + let mut state = ParserState::default(); + let command = format!("%SRX{}Y1I1J0*%", MAX_STEP_REPEAT_COPIES + 1); + + let error = parse_sr(&command, &mut state).expect_err("oversized SR must fail"); + + assert!(error.contains("exceeds the supported limit")); + assert_eq!(state.sr_x, 1); + assert_eq!(state.sr_y, 1); +} + +#[test] +fn rejects_malformed_step_repeat_without_changing_state() { + let mut state = ParserState::default(); + + let error = parse_sr("%SRX2YI1J1*%", &mut state).expect_err("missing Y count must fail"); + + assert!(error.contains("missing numeric value")); + assert_eq!(state.sr_x, 1); + assert_eq!(state.sr_y, 1); + assert_eq!(state.sr_i, 0.0); + assert_eq!(state.sr_j, 0.0); +} + +#[test] +fn generated_geometry_budget_is_cumulative() { + let state = ParserState::default(); + + state + .consume_generated_items(MAX_GENERATED_ITEMS, "test") + .expect("budget boundary should be accepted"); + let error = state + .consume_generated_items(1, "test") + .expect_err("budget overflow must fail"); + + assert!(error.contains("generated geometry exceeds")); +} + +#[test] +fn rejects_standard_polygon_vertex_counts_outside_specification() { + let data = "\ +%FSLAX24Y24*% +%MOMM*% +%ADD10P,1.0X4294967295*% +D10* +X000000Y000000D03* +M02*"; + let mut parser = GerberParser::with_options(true, 1); + + let layers = parser + .parse(data) + .expect("invalid polygon should be ignored safely"); + + assert!(layers.is_empty()); + assert!(!parser.apertures.contains_key("10")); +} + +#[test] +fn accepts_decimal_notation_for_integral_polygon_vertex_counts() { + let layers = parse_gerber( + "\ +%FSLAX24Y24*% +%MOMM*% +%ADD10P,1.0X6.0*% +D10* +X000000Y000000D03* +M02*", + ) + .expect("integral decimal vertex count should remain supported"); + + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].triangle_templates.len(), 1); + assert_eq!(layers[0].triangle_templates[0].vertices.len(), 36); + assert_eq!(layers[0].triangle_templates[0].instance_x.len(), 1); +} + +#[test] +fn rejects_macro_vertex_counts_before_allocation() { + let mut macros = HashMap::new(); + parse_macro( + "%AMLIMITS*5,1,4294967295,0,0,1,0*4,1,4294967295,0,0,0,0*%", + &mut macros, + ); + + let primitives = macros + .get("LIMITS") + .expect("macro should be parsed") + .instantiate(&[]); + + assert!(primitives.is_empty()); +} + +#[test] +fn rejects_repeated_arc_region_before_interaction_tessellation() { + let mut contour = RegionContour::default(); + contour + .push_arc( + [1.0, 0.0], + [-1.0, 0.0], + [0.0, 0.0], + 1.0, + 0.0, + std::f32::consts::PI, + ) + .expect("test arc should be valid"); + let mut state = ParserState::default(); + state.sr_x = MAX_STEP_REPEAT_COPIES as u32; + + let error = match super::geometry::build_path_regions(&[contour], &state, 2, true, false) { + Ok(_) => panic!("repeated arc region must be rejected before expansion"), + Err(error) => error, + }; + + assert!(error.contains("path region expands")); + assert_eq!(state.generated_items(), 0); +} + #[test] fn trailing_zero_omission_coordinates_are_right_padded() { let layers = parse_gerber( diff --git a/wasm/src/renderer/mod.rs b/wasm/src/renderer/mod.rs index 14176bc..8b4cf75 100644 --- a/wasm/src/renderer/mod.rs +++ b/wasm/src/renderer/mod.rs @@ -23,10 +23,12 @@ use crate::parser::ParserState; use js_sys::{Array, Float32Array, Reflect, Uint32Array}; use wasm_bindgen::{prelude::*, JsCast}; use web_sys::{ - WebGl2RenderingContext, WebGlBuffer, WebGlFramebuffer, WebGlTexture, WebGlVertexArrayObject, + WebGl2RenderingContext, WebGlBuffer, WebGlFramebuffer, WebGlRenderbuffer, WebGlTexture, + WebGlVertexArrayObject, }; const PATH_SECTOR_VERTEX_FLOATS_U32: u32 = PATH_SECTOR_VERTEX_FLOATS as u32; +type OutlineFillLayers = (Vec, Vec); /// Metadata for a single user layer (may contain multiple polarity sublayers) pub struct LayerMetadata { @@ -64,6 +66,25 @@ struct BufferCacheBuildGuard { committed: bool, } +struct FboBuildGuard { + gl: WebGl2RenderingContext, + framebuffer: Option, + texture: Option, + stencil: Option, +} + +struct FboListBuildGuard { + gl: WebGl2RenderingContext, + fbos: Vec>, +} + +struct RendererResourcesBuildGuard { + gl: WebGl2RenderingContext, + programs: Option, + quad_buffer: Option, + fbos: Vec>, +} + #[derive(Clone)] struct OutlineSegment { start: [f32; 2], @@ -114,6 +135,120 @@ impl Drop for BufferCacheBuildGuard { } } +impl FboBuildGuard { + fn new(gl: &WebGl2RenderingContext) -> Self { + Self { + gl: gl.clone(), + framebuffer: None, + texture: None, + stencil: None, + } + } + + fn commit(mut self) -> Fbo { + Fbo { + framebuffer: self + .framebuffer + .take() + .expect("completed FBO must have a framebuffer"), + texture: self + .texture + .take() + .expect("completed FBO must have a texture"), + stencil: self.stencil.take(), + } + } +} + +impl Drop for FboBuildGuard { + fn drop(&mut self) { + self.gl + .bind_texture(WebGl2RenderingContext::TEXTURE_2D, None); + self.gl + .bind_renderbuffer(WebGl2RenderingContext::RENDERBUFFER, None); + self.gl + .bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, None); + if let Some(stencil) = self.stencil.take() { + self.gl.delete_renderbuffer(Some(&stencil)); + } + if let Some(framebuffer) = self.framebuffer.take() { + self.gl.delete_framebuffer(Some(&framebuffer)); + } + if let Some(texture) = self.texture.take() { + self.gl.delete_texture(Some(&texture)); + } + } +} + +impl FboListBuildGuard { + fn new(gl: &WebGl2RenderingContext, capacity: usize) -> Result { + let mut fbos = Vec::new(); + fbos.try_reserve(capacity).map_err(|_| { + JsValue::from_str("Unable to reserve memory for replacement framebuffers") + })?; + Ok(Self { + gl: gl.clone(), + fbos, + }) + } + + fn push(&mut self, fbo: Option) { + self.fbos.push(fbo); + } + + fn commit(mut self) -> Vec> { + std::mem::take(&mut self.fbos) + } +} + +impl Drop for FboListBuildGuard { + fn drop(&mut self) { + for fbo in self.fbos.drain(..).flatten() { + Renderer::delete_fbo(&self.gl, fbo); + } + } +} + +impl RendererResourcesBuildGuard { + fn new(gl: &WebGl2RenderingContext, fbo_capacity: usize) -> Result { + let mut fbos = Vec::new(); + fbos.try_reserve(fbo_capacity) + .map_err(|_| JsValue::from_str("Unable to reserve renderer resource build state"))?; + Ok(Self { + gl: gl.clone(), + programs: None, + quad_buffer: None, + fbos, + }) + } + + fn commit(mut self) -> (ShaderPrograms, WebGlBuffer, Vec>) { + ( + self.programs + .take() + .expect("completed renderer resources must have shader programs"), + self.quad_buffer + .take() + .expect("completed renderer resources must have a quad buffer"), + std::mem::take(&mut self.fbos), + ) + } +} + +impl Drop for RendererResourcesBuildGuard { + fn drop(&mut self) { + for fbo in self.fbos.drain(..).flatten() { + Renderer::delete_fbo(&self.gl, fbo); + } + if let Some(quad_buffer) = self.quad_buffer.take() { + self.gl.delete_buffer(Some(&quad_buffer)); + } + if let Some(programs) = self.programs.take() { + Renderer::delete_shader_programs(&self.gl, &programs); + } + } +} + impl Renderer { /// Create a new renderer with WebGL context (no layers initially) pub fn new(gl: WebGl2RenderingContext) -> Result { @@ -134,11 +269,10 @@ impl Renderer { gl: WebGl2RenderingContext, explicit_size: Option<(u32, u32)>, ) -> Result { - // Compile shader programs - let programs = ShaderPrograms::new(&gl)?; - - // Create quad buffer for instanced rendering (shared across all layers) - let quad_buffer = Self::create_quad_buffer(&gl)?; + let mut pending = RendererResourcesBuildGuard::new(&gl, 0)?; + pending.programs = Some(ShaderPrograms::new(&gl)?); + pending.quad_buffer = Some(Self::create_quad_buffer(&gl)?); + let (programs, quad_buffer, _) = pending.commit(); Ok(Renderer { gl, @@ -237,6 +371,16 @@ impl Renderer { } let boundary = Boundary::new(min_x, max_x, min_y, max_y); + let free_slot = self.layers.iter().position(|layer| layer.is_none()); + if free_slot.is_none() { + self.layers + .try_reserve(1) + .map_err(|_| JsValue::from_str("Unable to reserve memory for renderer layers"))?; + } + + // Create buffer caches before allocating GPU resources so a CPU + // allocation failure cannot leak a completed framebuffer. + let buffer_caches = Self::create_buffer_caches(gerber_data.len())?; // Create FBO for this layer. Arc-containing path regions need stencil fill. let needs_stencil = gerber_data @@ -244,9 +388,6 @@ impl Renderer { .any(|data| data.path_regions.has_geometry()); let fbo = Self::create_fbo(&self.gl, width, height, needs_stencil)?; - // Create buffer caches for each polarity sublayer - let buffer_caches = Self::create_buffer_caches(gerber_data.len())?; - let layer_metadata = LayerMetadata { gerber_data, fbo, @@ -261,7 +402,7 @@ impl Renderer { }; // Find next free slot or extend vec - if let Some(free_slot) = self.layers.iter().position(|layer| layer.is_none()) { + if let Some(free_slot) = free_slot { self.layers[free_slot] = Some(layer_metadata); self.layer_count += 1; Ok(free_slot) @@ -305,7 +446,7 @@ impl Renderer { fn inverted_outline_fill_layers( outline_data: &[GerberData], - ) -> Result<(Vec, Vec), JsValue> { + ) -> Result { match Self::outline_fill_layer_with_contours(outline_data) { Ok((fill_layer, fill_contours)) => Ok((vec![fill_layer], fill_contours)), Err(outline_error) => match Self::region_outline_fill_layers(outline_data)? { @@ -317,7 +458,7 @@ impl Renderer { fn region_outline_fill_layers( outline_data: &[GerberData], - ) -> Result, Vec)>, JsValue> { + ) -> Result, JsValue> { let mut fill_layers = Vec::new(); let mut all_contours = Vec::new(); @@ -2984,26 +3125,22 @@ impl Renderer { } let width_i32 = Self::checked_u32_to_i32("FBO width", width)?; let height_i32 = Self::checked_u32_to_i32("FBO height", height)?; + let mut pending = FboBuildGuard::new(gl); let texture = gl.create_texture().ok_or("Failed to create texture")?; + pending.texture = Some(texture.clone()); gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, Some(&texture)); - if let Err(error) = gl - .tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( - WebGl2RenderingContext::TEXTURE_2D, - 0, - WebGl2RenderingContext::RGBA as i32, - width_i32, - height_i32, - 0, - WebGl2RenderingContext::RGBA, - WebGl2RenderingContext::UNSIGNED_BYTE, - None, - ) - { - gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, None); - gl.delete_texture(Some(&texture)); - return Err(error); - } + gl.tex_image_2d_with_i32_and_i32_and_i32_and_format_and_type_and_opt_u8_array( + WebGl2RenderingContext::TEXTURE_2D, + 0, + WebGl2RenderingContext::RGBA as i32, + width_i32, + height_i32, + 0, + WebGl2RenderingContext::RGBA, + WebGl2RenderingContext::UNSIGNED_BYTE, + None, + )?; gl.tex_parameteri( WebGl2RenderingContext::TEXTURE_2D, WebGl2RenderingContext::TEXTURE_MIN_FILTER, @@ -3026,6 +3163,7 @@ impl Renderer { ); let framebuffer = gl.create_framebuffer().ok_or("Failed to create FBO")?; + pending.framebuffer = Some(framebuffer.clone()); gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, Some(&framebuffer)); gl.framebuffer_texture_2d( WebGl2RenderingContext::FRAMEBUFFER, @@ -3035,10 +3173,11 @@ impl Renderer { 0, ); - let stencil = if with_stencil { + if with_stencil { let stencil = gl .create_renderbuffer() .ok_or_else(|| JsValue::from_str("Failed to create stencil renderbuffer"))?; + pending.stencil = Some(stencil.clone()); gl.bind_renderbuffer(WebGl2RenderingContext::RENDERBUFFER, Some(&stencil)); gl.renderbuffer_storage( WebGl2RenderingContext::RENDERBUFFER, @@ -3052,21 +3191,10 @@ impl Renderer { WebGl2RenderingContext::RENDERBUFFER, Some(&stencil), ); - Some(stencil) - } else { - None - }; + } let status = gl.check_framebuffer_status(WebGl2RenderingContext::FRAMEBUFFER); if status != WebGl2RenderingContext::FRAMEBUFFER_COMPLETE { - gl.bind_texture(WebGl2RenderingContext::TEXTURE_2D, None); - gl.bind_renderbuffer(WebGl2RenderingContext::RENDERBUFFER, None); - gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, None); - if let Some(stencil) = stencil { - gl.delete_renderbuffer(Some(&stencil)); - } - gl.delete_framebuffer(Some(&framebuffer)); - gl.delete_texture(Some(&texture)); return Err(JsValue::from_str(&format!( "Framebuffer is incomplete: 0x{:x}", status @@ -3077,11 +3205,7 @@ impl Renderer { gl.bind_renderbuffer(WebGl2RenderingContext::RENDERBUFFER, None); gl.bind_framebuffer(WebGl2RenderingContext::FRAMEBUFFER, None); - Ok(Fbo { - framebuffer, - texture, - stencil, - }) + Ok(pending.commit()) } /// Create and bind a single-channel instance buffer @@ -5791,19 +5915,32 @@ impl Renderer { /// Resize framebuffers to explicit dimensions. pub fn resize_to(&mut self, width: u32, height: u32) -> Result<(), JsValue> { Self::validate_framebuffer_size(width, height)?; + let mut pending_fbos = FboListBuildGuard::new(&self.gl, self.layers.len())?; + + for layer in &self.layers { + let fbo = match layer { + Some(layer) => Some(Self::create_fbo( + &self.gl, + width, + height, + layer.has_path_regions, + )?), + None => None, + }; + pending_fbos.push(fbo); + } + + let replacements = pending_fbos.commit(); if self.explicit_size.is_some() { self.explicit_size = Some((width, height)); } - - // Recreate FBO for each active layer - for layer in self.layers.iter_mut().flatten() { - let old_fbo = std::mem::replace( - &mut layer.fbo, - Self::create_fbo(&self.gl, width, height, layer.has_path_regions)?, - ); - Self::delete_fbo(&self.gl, old_fbo); - layer.fbo_dirty = true; - layer.fbo_transform = None; + for (layer, replacement) in self.layers.iter_mut().zip(replacements) { + if let (Some(layer), Some(replacement)) = (layer, replacement) { + let old_fbo = std::mem::replace(&mut layer.fbo, replacement); + Self::delete_fbo(&self.gl, old_fbo); + layer.fbo_dirty = true; + layer.fbo_transform = None; + } } Ok(()) @@ -5823,47 +5960,53 @@ impl Renderer { )); } - let programs = ShaderPrograms::new(&gl)?; - let quad_buffer = Self::create_quad_buffer(&gl)?; + let mut new_buffer_caches = + Self::reserved_vec("restored buffer caches", self.layers.len())?; + for layer in &self.layers { + new_buffer_caches.push(match layer { + Some(layer) => Some(Self::create_buffer_caches(layer.gerber_data.len())?), + None => None, + }); + } + let (width, height) = match self.explicit_size { Some(size) => size, None => Self::get_canvas_size_from_gl(&gl)?, }; - let mut new_fbos = Self::reserved_vec("restored framebuffers", self.layers.len())?; + let mut pending = RendererResourcesBuildGuard::new(&gl, self.layers.len())?; + pending.programs = Some(ShaderPrograms::new(&gl)?); + pending.quad_buffer = Some(Self::create_quad_buffer(&gl)?); for layer in &self.layers { if layer.is_some() { let has_path_regions = layer.as_ref().is_some_and(|layer| layer.has_path_regions); - let fbo = match Self::create_fbo(&gl, width, height, has_path_regions) { - Ok(fbo) => fbo, - Err(error) => { - for fbo in new_fbos.into_iter().flatten() { - Self::delete_fbo(&gl, fbo); - } - gl.delete_buffer(Some(&quad_buffer)); - Self::delete_shader_programs(&gl, &programs); - return Err(error); - } - }; - new_fbos.push(Some(fbo)); + pending.fbos.push(Some(Self::create_fbo( + &gl, + width, + height, + has_path_regions, + )?)); } else { - new_fbos.push(None); + pending.fbos.push(None); } } + let (programs, quad_buffer, new_fbos) = pending.commit(); let old_gl = self.gl.clone(); let old_programs = std::mem::replace(&mut self.programs, programs); let old_quad_buffer = std::mem::replace(&mut self.quad_buffer, quad_buffer); - for (layer, new_fbo) in self.layers.iter_mut().zip(new_fbos) { - if let (Some(layer), Some(new_fbo)) = (layer, new_fbo) { + for ((layer, new_fbo), new_caches) in + self.layers.iter_mut().zip(new_fbos).zip(new_buffer_caches) + { + if let (Some(layer), Some(new_fbo), Some(new_caches)) = (layer, new_fbo, new_caches) { let old_fbo = std::mem::replace(&mut layer.fbo, new_fbo); Self::delete_fbo(&old_gl, old_fbo); for cache in std::mem::take(&mut layer.buffer_caches) { Self::delete_buffer_cache(&old_gl, cache); } - layer.buffer_caches = Self::create_buffer_caches(layer.gerber_data.len())?; + layer.buffer_caches = new_caches; layer.fbo_dirty = true; layer.fbo_transform = None; } diff --git a/wasm/src/renderer/shader.rs b/wasm/src/renderer/shader.rs index a34c965..ff37429 100644 --- a/wasm/src/renderer/shader.rs +++ b/wasm/src/renderer/shader.rs @@ -97,9 +97,49 @@ pub struct ShaderPrograms { pub path_sector: ShaderProgram, } +struct ShaderProgramsBuildGuard { + gl: WebGl2RenderingContext, + programs: Vec, + committed: bool, +} + +impl ShaderProgramsBuildGuard { + fn new(gl: &WebGl2RenderingContext) -> Result { + let mut programs = Vec::new(); + programs + .try_reserve(10) + .map_err(|_| JsValue::from_str("Unable to reserve shader program build state"))?; + Ok(Self { + gl: gl.clone(), + programs, + committed: false, + }) + } + + fn track(&mut self, program: &ShaderProgram) { + self.programs.push(program.program.clone()); + } + + fn commit(mut self) { + self.committed = true; + } +} + +impl Drop for ShaderProgramsBuildGuard { + fn drop(&mut self) { + if self.committed { + return; + } + for program in self.programs.drain(..) { + self.gl.delete_program(Some(&program)); + } + } +} + impl ShaderPrograms { /// Compile all shader programs pub fn new(gl: &WebGl2RenderingContext) -> Result { + let mut pending = ShaderProgramsBuildGuard::new(gl)?; let triangle = compile_program( gl, TRIANGLE_VERTEX_SHADER, @@ -112,6 +152,7 @@ impl ShaderPrograms { ], &["transform", "color"], )?; + pending.track(&triangle); let triangle_template = compile_program( gl, @@ -120,6 +161,7 @@ impl ShaderPrograms { &["position", "instance_x", "instance_y"], &["transform", "color"], )?; + pending.track(&triangle_template); let line = compile_program( gl, @@ -142,6 +184,7 @@ impl ShaderPrograms { "inner_outline_world", ], )?; + pending.track(&line); let circle = compile_program( gl, @@ -161,6 +204,7 @@ impl ShaderPrograms { "inner_outline_world", ], )?; + pending.track(&circle); let circle_holed = compile_program( gl, @@ -177,6 +221,7 @@ impl ShaderPrograms { ], &["transform", "color"], )?; + pending.track(&circle_holed); let arc = compile_program( gl, @@ -200,6 +245,7 @@ impl ShaderPrograms { "inner_outline_world", ], )?; + pending.track(&arc); let thermal = compile_program( gl, @@ -216,6 +262,7 @@ impl ShaderPrograms { ], &["transform", "color"], )?; + pending.track(&thermal); let texture = compile_program( gl, @@ -224,6 +271,7 @@ impl ShaderPrograms { &["position"], &["u_texture", "u_color"], )?; + pending.track(&texture); let path_solid = compile_program( gl, @@ -232,6 +280,7 @@ impl ShaderPrograms { &["position"], &["transform", "color"], )?; + pending.track(&path_solid); let path_sector = compile_program( gl, @@ -240,8 +289,9 @@ impl ShaderPrograms { &["position", "center", "radius"], &["transform"], )?; + pending.track(&path_sector); - Ok(ShaderPrograms { + let programs = ShaderPrograms { triangle, triangle_template, line, @@ -252,7 +302,9 @@ impl ShaderPrograms { texture, path_solid, path_sector, - }) + }; + pending.commit(); + Ok(programs) } } diff --git a/wasm/src/renderer/tests.rs b/wasm/src/renderer/tests.rs index f50acaf..912dc9d 100644 --- a/wasm/src/renderer/tests.rs +++ b/wasm/src/renderer/tests.rs @@ -1,4 +1,3 @@ - use super::*; use crate::parser::GerberParser; @@ -94,9 +93,27 @@ fn validate_path_region_data_rejects_legacy_sector_stride() { vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0], vec![0, 3], vec![ - 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 1.5708, // - 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.5708, // - 2.0, 2.0, 0.0, 0.0, 1.0, 0.0, 1.5708, + 1.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + std::f32::consts::FRAC_PI_2, + 0.0, + 1.0, + 0.0, + 0.0, + 1.0, + 0.0, + std::f32::consts::FRAC_PI_2, + 2.0, + 2.0, + 0.0, + 0.0, + 1.0, + 0.0, + std::f32::consts::FRAC_PI_2, ], vec![0, 3], vec![0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0], From 8e039f98b01060bc9cde3d30ad24e1472c4b30d3 Mon Sep 17 00:00:00 2001 From: dsafdsaf132 Date: Fri, 10 Jul 2026 07:18:09 +0900 Subject: [PATCH 3/5] chore: remove review checklist --- TODO.md | 261 -------------------------------------------------------- 1 file changed, 261 deletions(-) delete mode 100644 TODO.md diff --git a/TODO.md b/TODO.md deleted file mode 100644 index d759ee6..0000000 --- a/TODO.md +++ /dev/null @@ -1,261 +0,0 @@ -# Project Review TODO - -Review baseline: `v0.5.0` (`8e5dc58`), 2026-07-10 - -Scope: Gerber/Drill parser, WebGL renderer, browser UI, Node/CLI package, -build, release, and compatibility workflows. - -Priority definitions: - -- **P1**: Resource exhaustion or application termination can be triggered by - untrusted input. Address before adding more public input surfaces. -- **P2**: A runtime failure or regression can escape current recovery or CI - checks. Address in the next reliability cycle. -- **P3**: Error handling, documentation, or quality-gate debt that can obscure - future regressions. - -## P1: Input Workload Limits - -### [x] P1-1 Add parser-wide work and allocation budgets - -Affected code: - -- `wasm/src/parser/state.rs` -- `wasm/src/parser/geometry.rs` -- `wasm/src/parser/aperture.rs` -- `wasm/src/parser/aperture_macro.rs` - -Gerber Step-and-Repeat counts currently accept the full `u32` range and are -expanded through nested loops. Standard and macro polygon vertex counts are -also used without a practical upper bound, with some paths relying on -infallible `Vec::push` allocation. A small Gerber input can therefore consume -the available CPU and WASM memory or terminate the runtime. - -Required work: - -- Reject invalid or excessive Step-and-Repeat dimensions before expansion. -- Validate polygon and aperture-macro vertex counts against supported Gerber - limits. -- Introduce a shared parser budget for generated primitives, regions, - sublayers, and interaction records. -- Use checked arithmetic and fallible reservations before expansion loops. -- Return a stable, user-facing parser limit error instead of reaching OOM. -- Add adversarial tests for oversized SR commands and polygon counts. - -Completion criteria: - -- Tiny inputs cannot request unbounded generated geometry. -- Parser limit failures leave the processor reusable. -- Normal performance fixtures continue to parse without reduced coverage. - -### [x] P1-2 Apply ingestion limits to remote, archive, and repeat paths - -Affected code: - -- `js/core/config.js` -- `js/core/viewer.js` -- `js/loading/source-loader.js` - -The advertised `300 MiB` limit is enforced for local top-level files only. -Remote responses are accumulated without a byte cap, ZIP entries have no -per-entry or total uncompressed-size limit, and the query-string `repeat` -parameter has no maximum before it reaches `Array.from`. - -Required work: - -- Enforce the file limit using both `Content-Length` and streamed received - bytes; cancel the response reader when the limit is crossed. -- Enforce ZIP entry count, per-entry uncompressed size, and total uncompressed - size limits before reading layer text. -- Reject suspicious compression ratios where metadata permits detection. -- Define and enforce a maximum repeat count and maximum resulting layer count. -- Keep all limits in shared configuration and show the actual limit in UI - diagnostics. -- Add tests for missing/incorrect `Content-Length`, ZIP expansion, and crafted - `repeat` query parameters. - -Completion criteria: - -- Every browser ingestion path has a bounded byte and layer budget. -- Limit errors close readers, hide loading state, and leave the viewer usable. - -## P2: Renderer Reliability - -### [x] P2-1 Make framebuffer replacement transactional - -Affected code: `wasm/src/renderer/mod.rs` - -`resize_to` records the new explicit size and replaces layer FBOs one at a -time. If a later allocation fails, the renderer retains mixed framebuffer -sizes. FBO creation also leaks already-created textures/framebuffers when a -subsequent WebGL allocation fails. Context restoration has a similar partial -commit risk while rebuilding per-layer caches. - -Required work: - -- Allocate all replacement FBOs and caches into temporary owners first. -- Commit the new size and resources only after every allocation succeeds. -- Add RAII cleanup guards for partially built FBOs and shader resources. -- Preserve the old renderer state on resize or restore failure. -- Add failure-injection tests with a mock WebGL resource factory. - -Completion criteria: - -- Failed resize/restore calls can be retried without rebuilding the processor. -- Every failed WebGL allocation releases resources created by that attempt. - -Residual test gap: transactional resource ownership is implemented, but direct -failure injection remains a follow-up because the renderer currently owns a -concrete `web_sys::WebGl2RenderingContext` rather than an injectable factory. - -### [x] P2-2 Abort failed browser PNG streams - -Affected code: - -- `packages/wasm-gerber-renderer/index.js` -- `packages/wasm-gerber-renderer/index.d.ts` -- `packages/wasm-gerber-renderer/README.md` - -Browser streaming export closes the writable only on success. On compression, -pixel-read, or write failure it releases the writer lock without aborting the -underlying stream, which can leave a partial file or open writable behind. - -Required work: - -- Expose an `abort(error)` operation from the writable sink adapter. -- Abort the writer or `FileSystemWritableFileStream` on export failure. -- Preserve the original rendering error if abort also fails. -- Test write rejection, compression failure, and successful close behavior. - -## P2: CI and Build Integrity - -### [x] P2-3 Check every deployed JavaScript module - -Affected code: `.github/workflows/build-and-deploy.yml` - -The current `js/*.js` glob checks only `js/main.js`; it does not parse modules -under `js/core`, `js/loading`, `js/rendering`, `js/layers`, or `js/ui`. - -Required work: - -- Change the syntax gate to recursively enumerate all JavaScript files. -- Run the package test suite for browser helper changes, or move viewer tests - into a dedicated root test command. -- Make UI-only pull requests execute the relevant tests before deployment. - -Completion criteria: - -- A syntax error in any deployed module fails the pull-request workflow. -- Viewer helper tests run when their source files change. - -### [x] P2-4 Validate rendered pixels, not only PNG byte length - -Affected code: `.github/workflows/renderer-compatibility.yml` - -CLI and Node smoke tests currently require only a PNG larger than 100 bytes. -A valid but entirely blank image satisfies this check, so shader, stencil, and -compositing regressions can pass the platform matrix. - -Required work: - -- Decode smoke-test PNGs and assert dimensions and a minimum non-background - pixel count. -- Include a fixture that covers polarity, holes, analytic arc regions, drills, - and inverted rendering. -- Store a compact image signature or bounded pixel-difference baseline where - exact output is stable. The cross-platform gate currently uses dimensions - and a minimum non-background pixel count to avoid driver-specific image - diffs. -- Keep a native context clear/readPixels test separate from renderer output - validation. - -### [x] P2-5 Make the WASM build toolchain reproducible - -Affected code: - -- `scripts/vercel-build.sh` -- Rust toolchain configuration at the repository root - -The script declares `wasm-pack 0.14.0` but uses any existing `wasm-pack` found -on `PATH`. The review build consequently used `0.13.1`. Rust is also selected -as moving `stable`, so generated glue and binaries can differ across machines. - -Required work: - -- Verify the installed `wasm-pack` version and install/use the pinned version - when it differs. -- Pin Rust with `rust-toolchain.toml`, including the WASM target and required - components. -- Include tool versions in the WASM source hash or build metadata. -- Test that local `npm pack` and CI release builds use the same versions. - -### [x] P2-6 Add real macOS x64 validation or correct the support claim - -Affected code: - -- `.github/workflows/renderer-compatibility.yml` -- `packages/wasm-gerber-renderer/README.md` -- Translated renderer READMEs and `SKILL.md` - -The README says the renderer compatibility workflow performs build-only -validation for macOS x64, but the workflow matrix has no macOS x64 job. - -Required work: - -- Add an Intel macOS build/smoke job when an appropriate runner is available, - or explicitly attribute build validation to the upstream - `node-gles-webgl2` project. -- Generate support tables from one source or test them against the workflow - matrix to prevent future drift. - -## P3: UI Error Handling and Quality Gates - -### [x] P3-1 Handle file-upload promises at event boundaries - -Affected code: `js/core/viewer.js` - -File input and drop handlers call `handleFileUpload` without awaiting or -catching its promise. An unexpected collection or pipeline error produces an -unhandled rejection and skips final UI refresh and file-input reset. - -Required work: - -- Catch upload errors at both event boundaries and report them consistently. -- Move loading-modal cleanup, UI refresh, and file-input reset into a final - cleanup path that always runs. -- Add tests for archive-read failure and non-recoverable worker failure. - -### [x] P3-2 Enforce formatting and linting in CI - -Current baseline: - -- `cargo fmt --manifest-path wasm/Cargo.toml --all -- --check` fails because - three test modules start with a blank line. -- `cargo clippy --manifest-path wasm/Cargo.toml --all-targets -- -D warnings` - fails on a redundant closure, a complex return type, and approximate - `FRAC_PI_2` constants in renderer tests. - -Required work: - -- Fix the current formatting and Clippy findings. -- Add `cargo fmt --check` and an agreed Clippy policy to pull-request CI. -- Keep generated WASM output outside formatting and lint inputs. - -## Verification Baseline - -The following checks passed during the review: - -- `cargo test --manifest-path wasm/Cargo.toml --all-targets`: 161 tests passed. -- `npm run check` in `packages/wasm-gerber-renderer`: 34 tests passed. -- `./scripts/vercel-build.sh`: WASM release build completed. -- `npm pack --dry-run`: package contained 15 files and packed successfully. -- Recursive `node --check` over `js/**/*.js`: passed. -- Required DOM IDs: no missing or duplicate IDs were detected. - -Remaining CI and runtime validation: - -- Run browser interaction and visual checks at desktop and mobile viewports. -- Confirm the native Node/CLI pixel smoke on the Linux, macOS ARM64, macOS x64, - and Windows compatibility runners. The review environment installed - `node-gles-webgl2` successfully, but its PRoot/Xvfb display could not - initialize a native context. From 8edf878e0bc0a94fa12d6144170eeda43fc10b40 Mon Sep 17 00:00:00 2001 From: dsafdsaf132 Date: Fri, 10 Jul 2026 08:14:02 +0900 Subject: [PATCH 4/5] ci: make macos x64 validation build-only --- .github/workflows/renderer-compatibility.yml | 17 +++++++++++++++++ packages/wasm-gerber-renderer/README.kr.md | 5 ++++- packages/wasm-gerber-renderer/README.md | 6 +++++- packages/wasm-gerber-renderer/README.zh-Hans.md | 5 ++++- packages/wasm-gerber-renderer/README.zh-Hant.md | 5 ++++- packages/wasm-gerber-renderer/SKILL.md | 6 +++--- 6 files changed, 37 insertions(+), 7 deletions(-) diff --git a/.github/workflows/renderer-compatibility.yml b/.github/workflows/renderer-compatibility.yml index f9239fb..ff272b7 100644 --- a/.github/workflows/renderer-compatibility.yml +++ b/.github/workflows/renderer-compatibility.yml @@ -80,6 +80,7 @@ jobs: runner: macos-latest - platform: macOS x64 runner: macos-15-intel + runtime_smoke: false - platform: Windows x64 runner: windows-latest - platform: Windows arm64 @@ -122,6 +123,7 @@ jobs: npm install "$package_file" node-gles-webgl2@latest - name: Check native WebGL2 context + if: matrix.runtime_smoke != false run: | set -euo pipefail cd "${RUNNER_TEMP}/renderer-smoke" @@ -162,6 +164,7 @@ jobs: NODE - name: Render through CLI + if: matrix.runtime_smoke != false run: | set -euo pipefail cd "${RUNNER_TEMP}/renderer-smoke" @@ -180,6 +183,7 @@ jobs: node "${GITHUB_WORKSPACE}/scripts/assert-png-content.mjs" out-cli.png '#05070c' 100 640 360 - name: Render through Node API + if: matrix.runtime_smoke != false run: | set -euo pipefail cd "${RUNNER_TEMP}/renderer-smoke" @@ -219,3 +223,16 @@ jobs: fi "${node_cmd[@]}" smoke-node.mjs node "${GITHUB_WORKSPACE}/scripts/assert-png-content.mjs" out-node.png '#ffffff' 100 640 360 + + - name: Validate build-only platform + if: matrix.runtime_smoke == false + run: | + set -euo pipefail + cd "${RUNNER_TEMP}/renderer-smoke" + node <<'NODE' + const nodeGles = require("node-gles-webgl2"); + if (typeof nodeGles.createWebGLRenderingContext !== "function") { + throw new Error("node-gles-webgl2 native module did not load"); + } + console.log("Native module loaded; runtime smoke is unavailable on this hosted runner"); + NODE diff --git a/packages/wasm-gerber-renderer/README.kr.md b/packages/wasm-gerber-renderer/README.kr.md index 2218d7a..783a471 100644 --- a/packages/wasm-gerber-renderer/README.kr.md +++ b/packages/wasm-gerber-renderer/README.kr.md @@ -68,10 +68,13 @@ Node.js와 CLI 렌더링은 [`node-gles-webgl2`](https://github.com/dsafdsaf132/ | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +macOS x64는 GitHub 호스팅 Intel runner에서 runtime 렌더링에 필요한 ANGLE +EGL display를 생성할 수 없어 설치와 native module load만 검증합니다. + ## 브라우저 사용 ```js diff --git a/packages/wasm-gerber-renderer/README.md b/packages/wasm-gerber-renderer/README.md index 11d986a..926b778 100644 --- a/packages/wasm-gerber-renderer/README.md +++ b/packages/wasm-gerber-renderer/README.md @@ -75,10 +75,14 @@ Node.js and CLI rendering are supported via | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +macOS x64 validates installation and native module loading only because the +GitHub-hosted Intel runner cannot create the ANGLE EGL display required for +runtime rendering. + ## Browser Usage ```js diff --git a/packages/wasm-gerber-renderer/README.zh-Hans.md b/packages/wasm-gerber-renderer/README.zh-Hans.md index 01959c0..c8df3e3 100644 --- a/packages/wasm-gerber-renderer/README.zh-Hans.md +++ b/packages/wasm-gerber-renderer/README.zh-Hans.md @@ -68,10 +68,13 @@ Node.js 和 CLI 渲染通过 [`node-gles-webgl2`](https://github.com/dsafdsaf132 | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +macOS x64 仅验证安装和原生模块加载,因为 GitHub 托管的 Intel runner 无法创建 +运行时渲染所需的 ANGLE EGL display。 + ## 浏览器用法 ```js diff --git a/packages/wasm-gerber-renderer/README.zh-Hant.md b/packages/wasm-gerber-renderer/README.zh-Hant.md index 0aff7e5..b798647 100644 --- a/packages/wasm-gerber-renderer/README.zh-Hant.md +++ b/packages/wasm-gerber-renderer/README.zh-Hant.md @@ -68,10 +68,13 @@ Node.js 與 CLI 渲染透過 [`node-gles-webgl2`](https://github.com/dsafdsaf132 | Linux x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Linux arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | macOS arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | -| macOS x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +| macOS x64 | ![build only](https://img.shields.io/badge/CI-build%20only-yellow) | | Windows x64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | | Windows arm64 | ![tested](https://img.shields.io/badge/CI-tested-brightgreen) | +macOS x64 僅驗證安裝與原生模組載入,因為 GitHub 託管的 Intel runner 無法建立 +runtime 渲染所需的 ANGLE EGL display。 + ## 瀏覽器用法 ```js diff --git a/packages/wasm-gerber-renderer/SKILL.md b/packages/wasm-gerber-renderer/SKILL.md index 65d3be1..d498efe 100644 --- a/packages/wasm-gerber-renderer/SKILL.md +++ b/packages/wasm-gerber-renderer/SKILL.md @@ -23,8 +23,8 @@ npm install wasm-gerber-renderer node-gles-webgl2 Node.js and CLI rendering are supported via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux -x64/arm64, macOS arm64/x64, and Windows x64/arm64. All listed platforms run -the renderer compatibility smoke test. +x64/arm64, macOS arm64/x64, and Windows x64/arm64. macOS x64 is build-only in +the renderer compatibility workflow because the hosted runner has no EGL display. For global CLI usage: @@ -301,7 +301,7 @@ Use `onLayerError` to report skipped layers. Use `layerErrorMode: "throw"` when ## Notes - Node.js rendering requires a WebGL2-capable native module; use `node-gles-webgl2`. -- Node.js and CLI rendering are supported and smoke-tested via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux x64/arm64, macOS arm64/x64, and Windows x64/arm64. +- Node.js and CLI rendering are supported via [`node-gles-webgl2`](https://github.com/dsafdsaf132/node-gles-webgl2) on Linux x64/arm64, macOS arm64/x64, and Windows x64/arm64. macOS x64 receives build-only validation because the hosted runner has no EGL display. - Parent directories for output PNG files must already exist. - Very large Gerber files can fail with memory limits; report the error and avoid retry loops. - Drill, job, image, text, and metadata files are not Gerber image layers and may be skipped. From 8f14e80e1e243652f2a0ae7e056c68b7e5b03a0f Mon Sep 17 00:00:00 2001 From: dsafdsaf132 Date: Fri, 10 Jul 2026 08:20:10 +0900 Subject: [PATCH 5/5] ci: preserve runtime smoke coverage --- .github/workflows/renderer-compatibility.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/renderer-compatibility.yml b/.github/workflows/renderer-compatibility.yml index ff272b7..2fb880b 100644 --- a/.github/workflows/renderer-compatibility.yml +++ b/.github/workflows/renderer-compatibility.yml @@ -74,17 +74,22 @@ jobs: include: - platform: Linux x64 runner: ubuntu-latest + runtime_smoke: true - platform: Linux arm64 runner: ubuntu-24.04-arm + runtime_smoke: true - platform: macOS arm64 runner: macos-latest + runtime_smoke: true - platform: macOS x64 runner: macos-15-intel runtime_smoke: false - platform: Windows x64 runner: windows-latest + runtime_smoke: true - platform: Windows arm64 runner: windows-11-arm + runtime_smoke: true defaults: run: