From 009e05985cf88f45d4ad7817f3590c0dc570a44e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 17:23:21 +0000 Subject: [PATCH 1/3] engine: expose PNG rendering API across TS, server, and Python The recent commit introduced PNG rendering in libsimlin's C FFI. This change threads that capability through the full stack: - src/engine: adds simlin_project_render_png WASM binding, EngineBackend.projectRenderPng, DirectBackend/WorkerBackend implementations, worker protocol message, and Project.renderPng() - src/server: replaces the resvg-wasm + worker thread pipeline with a single call to the engine's renderPng, removing render-inner.ts, render-worker.ts, and the resvg-wasm dependency - src/pysimlin: adds render_svg/render_png FFI helpers and Project.render_svg/render_svg_string/render_png methods Tests cover PNG signature validation, dimension scaling with aspect ratio preservation, and error handling for nonexistent models. https://claude.ai/code/session_012WrnfoGCS23uqHEkhxyxFX --- pnpm-lock.yaml | 12 -- src/engine/src/backend.ts | 1 + src/engine/src/direct-backend.ts | 5 + src/engine/src/internal/import-export.ts | 55 +++++++++ src/engine/src/project.ts | 18 +++ src/engine/src/worker-backend.ts | 11 ++ src/engine/src/worker-protocol.ts | 9 ++ src/engine/src/worker-server.ts | 6 + src/engine/tests/api.test.ts | 58 +++++++++ src/engine/tests/direct-backend.test.ts | 37 ++++++ src/pysimlin/simlin/_ffi.py | 82 +++++++++++++ src/pysimlin/simlin/project.py | 65 ++++++++++ src/pysimlin/tests/test_rendering.py | 112 ++++++++++++++++++ src/server/CLAUDE.md | 2 +- src/server/package.json | 2 - src/server/render-inner.ts | 33 ------ src/server/render-worker.ts | 14 --- src/server/render.ts | 34 ++---- src/server/tests/render-model-preview.test.ts | 55 +++------ src/server/tests/render-preview.test.ts | 46 +++++-- src/server/tsconfig.json | 3 +- 21 files changed, 521 insertions(+), 139 deletions(-) create mode 100644 src/pysimlin/tests/test_rendering.py delete mode 100644 src/server/render-inner.ts delete mode 100644 src/server/render-worker.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96dd071af..1943ce7c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -332,9 +332,6 @@ importers: '@simlin/core': specifier: workspace:* version: link:../core - '@simlin/diagram': - specifier: workspace:* - version: link:../diagram '@simlin/engine': specifier: workspace:* version: link:../engine @@ -368,9 +365,6 @@ importers: passport-strategy: specifier: ^1.0.0 version: 1.0.0 - resvg-wasm: - specifier: ^0.5.0 - version: 0.5.0 serve-favicon: specifier: ^2.5.1 version: 2.5.1 @@ -6487,10 +6481,6 @@ packages: resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} engines: {node: '>=8'} - resvg-wasm@0.5.0: - resolution: {integrity: sha512-nnlqmoa1n/nBwIdDh8BzlwJezzL1cORCdsyaRhz1R215oUktA8THPs8tJRUZqW9pQAuajDUtuA+o1Q5RvgwuZA==} - engines: {node: '>=22'} - ret@0.1.15: resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} engines: {node: '>=0.12'} @@ -15265,8 +15255,6 @@ snapshots: onetime: 5.1.2 signal-exit: 3.0.7 - resvg-wasm@0.5.0: {} - ret@0.1.15: {} retry-request@7.0.2(encoding@0.1.13): diff --git a/src/engine/src/backend.ts b/src/engine/src/backend.ts index 6bf5de41d..bab53ae29 100644 --- a/src/engine/src/backend.ts +++ b/src/engine/src/backend.ts @@ -61,6 +61,7 @@ export interface EngineBackend { projectSerializeJson(handle: ProjectHandle, format: SimlinJsonFormat): MaybePromise; projectSerializeXmile(handle: ProjectHandle): MaybePromise; projectRenderSvg(handle: ProjectHandle, modelName: string): MaybePromise; + projectRenderPng(handle: ProjectHandle, modelName: string, width: number, height: number): MaybePromise; projectGetErrors(handle: ProjectHandle): MaybePromise; projectApplyPatch( handle: ProjectHandle, diff --git a/src/engine/src/direct-backend.ts b/src/engine/src/direct-backend.ts index c17cb5df5..5e7a53346 100644 --- a/src/engine/src/direct-backend.ts +++ b/src/engine/src/direct-backend.ts @@ -28,6 +28,7 @@ import { simlin_project_open_vensim, simlin_project_serialize_xmile, simlin_project_render_svg, + simlin_project_render_png, } from './internal/import-export'; import { simlin_model_unref, @@ -276,6 +277,10 @@ export class DirectBackend implements EngineBackend { return simlin_project_render_svg(this.getProjectPtr(handle), modelName); } + projectRenderPng(handle: ProjectHandle, modelName: string, width: number, height: number): Uint8Array { + return simlin_project_render_png(this.getProjectPtr(handle), modelName, width, height); + } + projectGetErrors(handle: ProjectHandle): ErrorDetail[] { const errPtr = simlin_project_get_errors(this.getProjectPtr(handle)); if (errPtr === 0) { diff --git a/src/engine/src/internal/import-export.ts b/src/engine/src/internal/import-export.ts index 4311d4195..e9f1718e3 100644 --- a/src/engine/src/internal/import-export.ts +++ b/src/engine/src/internal/import-export.ts @@ -172,3 +172,58 @@ export function simlin_project_render_svg(project: SimlinProjectPtr, modelName: free(outErrPtr); } } + +/** + * Render a project model's diagram as a PNG image. + * @param project Project pointer + * @param modelName Model name + * @param width Target width in pixels (0 for intrinsic) + * @param height Target height in pixels (0 for intrinsic) + * @returns PNG image data + */ +export function simlin_project_render_png( + project: SimlinProjectPtr, + modelName: string, + width: number, + height: number, +): Uint8Array { + const exports = getExports(); + const renderFn = exports.simlin_project_render_png as ( + proj: number, + name: number, + width: number, + height: number, + outBuf: number, + outLen: number, + outErr: number, + ) => void; + + const namePtr = stringToWasm(modelName); + const outBufPtr = allocOutPtr(); + const outLenPtr = allocOutUsize(); + const outErrPtr = allocOutPtr(); + + try { + renderFn(project, namePtr, width, height, outBufPtr, outLenPtr, outErrPtr); + const errPtr = readOutPtr(outErrPtr); + + if (errPtr !== 0) { + const code = simlin_error_get_code(errPtr); + const message = simlin_error_get_message(errPtr) ?? 'Unknown error'; + const details = readAllErrorDetails(errPtr); + simlin_error_free(errPtr); + throw new SimlinError(message, code, details); + } + + const bufPtr = readOutPtr(outBufPtr); + const len = readOutUsize(outLenPtr); + const data = copyFromWasm(bufPtr, len); + free(bufPtr); + return data; + } finally { + free(namePtr); + free(outBufPtr); + free(outLenPtr); + free(outErrPtr); + } +} diff --git a/src/engine/src/project.ts b/src/engine/src/project.ts index fce0f1338..859286407 100644 --- a/src/engine/src/project.ts +++ b/src/engine/src/project.ts @@ -268,6 +268,24 @@ export class Project { return new TextDecoder().decode(await this.renderSvg(modelName)); } + /** + * Render a model's stock-and-flow diagram as a PNG image. + * + * Pass `width = 0` and `height = 0` (or omit them) to use the SVG's + * intrinsic dimensions. When only one dimension is non-zero the other + * is derived from the aspect ratio. When both are non-zero, `width` + * takes precedence. + * + * @param modelName Model name + * @param width Target width in pixels (0 for intrinsic) + * @param height Target height in pixels (0 for intrinsic) + * @returns PNG image data + */ + async renderPng(modelName: string, width: number = 0, height: number = 0): Promise { + this.checkDisposed(); + return await this._backend.projectRenderPng(this._handle, modelName, width, height); + } + /** * Get all errors in this project. * @returns Array of ErrorDetail objects diff --git a/src/engine/src/worker-backend.ts b/src/engine/src/worker-backend.ts index 732364fc1..e1155ec30 100644 --- a/src/engine/src/worker-backend.ts +++ b/src/engine/src/worker-backend.ts @@ -392,6 +392,17 @@ export class WorkerBackend implements EngineBackend { })); } + projectRenderPng(handle: ProjectHandle, modelName: string, width: number, height: number): Promise { + return this.sendRequest((requestId) => ({ + type: 'projectRenderPng', + requestId, + handle, + modelName, + width, + height, + })); + } + projectGetErrors(handle: ProjectHandle): Promise { return this.sendRequest((requestId) => ({ type: 'projectGetErrors', diff --git a/src/engine/src/worker-protocol.ts b/src/engine/src/worker-protocol.ts index f14269629..c0c064b6f 100644 --- a/src/engine/src/worker-protocol.ts +++ b/src/engine/src/worker-protocol.ts @@ -52,6 +52,14 @@ export type WorkerRequest = | { type: 'projectSerializeJson'; requestId: number; handle: WorkerProjectHandle; format: number } | { type: 'projectSerializeXmile'; requestId: number; handle: WorkerProjectHandle } | { type: 'projectRenderSvg'; requestId: number; handle: WorkerProjectHandle; modelName: string } + | { + type: 'projectRenderPng'; + requestId: number; + handle: WorkerProjectHandle; + modelName: string; + width: number; + height: number; + } | { type: 'projectGetErrors'; requestId: number; handle: WorkerProjectHandle } | { type: 'projectApplyPatch'; @@ -166,6 +174,7 @@ export const VALID_REQUEST_TYPES: ReadonlySet = new Set([ 'projectSerializeJson', 'projectSerializeXmile', 'projectRenderSvg', + 'projectRenderPng', 'projectGetErrors', 'projectApplyPatch', 'modelGetName', diff --git a/src/engine/src/worker-server.ts b/src/engine/src/worker-server.ts index 3a3ba4a38..3a71a71a9 100644 --- a/src/engine/src/worker-server.ts +++ b/src/engine/src/worker-server.ts @@ -294,6 +294,12 @@ export class WorkerServer { this.sendBytesWithTransfer(requestId, result); return; } + case 'projectRenderPng': { + const handle = this.getProjectHandle(request.handle); + const result = this.backend.projectRenderPng(handle, request.modelName, request.width, request.height); + this.sendBytesWithTransfer(requestId, result); + return; + } case 'projectGetErrors': { const handle = this.getProjectHandle(request.handle); this.sendSuccess(requestId, this.backend.projectGetErrors(handle)); diff --git a/src/engine/tests/api.test.ts b/src/engine/tests/api.test.ts index 3e8062033..7950eca3b 100644 --- a/src/engine/tests/api.test.ts +++ b/src/engine/tests/api.test.ts @@ -154,6 +154,64 @@ describe('High-Level API', () => { await project.dispose(); }); + it('should render SVG', async () => { + const project = await openTestProject(); + + const svg = await project.renderSvg('main'); + expect(svg).toBeInstanceOf(Uint8Array); + expect(svg.length).toBeGreaterThan(0); + + const svgString = new TextDecoder().decode(svg); + expect(svgString).toContain(' { + const project = await openTestProject(); + + const svgString = await project.renderSvgString('main'); + expect(typeof svgString).toBe('string'); + expect(svgString).toContain(' { + const project = await openTestProject(); + + const png = await project.renderPng('main'); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(8); + + // Verify PNG signature + expect(png[0]).toBe(137); + expect(png[1]).toBe(80); // P + expect(png[2]).toBe(78); // N + expect(png[3]).toBe(71); // G + + await project.dispose(); + }); + + it('should render PNG with explicit width', async () => { + const project = await openTestProject(); + + const png = await project.renderPng('main', 800); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(8); + expect(png[0]).toBe(137); + + await project.dispose(); + }); + + it('should throw for PNG render of nonexistent model', async () => { + const project = await openTestProject(); + + await expect(project.renderPng('nonexistent_model_xyz')).rejects.toThrow(); + + await project.dispose(); + }); + it('should get loops via model', async () => { const project = await openTestProject(); diff --git a/src/engine/tests/direct-backend.test.ts b/src/engine/tests/direct-backend.test.ts index f147caf52..01033075e 100644 --- a/src/engine/tests/direct-backend.test.ts +++ b/src/engine/tests/direct-backend.test.ts @@ -105,6 +105,43 @@ describe('DirectBackend', () => { expect(xmile.length).toBeGreaterThan(0); }); + it('should render SVG', () => { + const svg = backend.projectRenderSvg(projectHandle, 'main'); + expect(svg).toBeInstanceOf(Uint8Array); + expect(svg.length).toBeGreaterThan(0); + const svgString = new TextDecoder().decode(svg); + expect(svgString).toContain(' { + const png = backend.projectRenderPng(projectHandle, 'main', 0, 0); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(8); + // PNG signature: 137 80 78 71 13 10 26 10 + expect(png[0]).toBe(137); + expect(png[1]).toBe(80); + expect(png[2]).toBe(78); + expect(png[3]).toBe(71); + }); + + it('should render PNG with explicit width', () => { + const png = backend.projectRenderPng(projectHandle, 'main', 400, 0); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(8); + expect(png[0]).toBe(137); + }); + + it('should render PNG with explicit height', () => { + const png = backend.projectRenderPng(projectHandle, 'main', 0, 300); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(8); + expect(png[0]).toBe(137); + }); + + it('should throw when rendering PNG for nonexistent model', () => { + expect(() => backend.projectRenderPng(projectHandle, 'nonexistent_xyz', 0, 0)).toThrow(); + }); + it('should get loops', () => { const modelHandle = backend.projectGetModel(projectHandle, null); const loops = backend.modelGetLoops(modelHandle); diff --git a/src/pysimlin/simlin/_ffi.py b/src/pysimlin/simlin/_ffi.py index c10d6393c..741fc3cf2 100644 --- a/src/pysimlin/simlin/_ffi.py +++ b/src/pysimlin/simlin/_ffi.py @@ -386,6 +386,86 @@ def open_json(json_data: bytes) -> Any: return project_ptr +def render_svg(project_ptr: Any, model_name: str) -> bytes: + """Render a project model's diagram as SVG. + + Args: + project_ptr: Pointer to a SimlinProject + model_name: Name of the model to render + + Returns: + SVG data as UTF-8 bytes + + Raises: + SimlinRuntimeError: If rendering fails + """ + c_name = string_to_c(model_name) + output_ptr = ffi.new("uint8_t **") + output_len_ptr = ffi.new("uintptr_t *") + err_ptr = ffi.new("SimlinError **") + + lib.simlin_project_render_svg( + project_ptr, + c_name, + output_ptr, + output_len_ptr, + err_ptr, + ) + check_out_error(err_ptr, f"Render SVG for model '{model_name}'") + + try: + return bytes(ffi.buffer(output_ptr[0], output_len_ptr[0])) + finally: + lib.simlin_free(output_ptr[0]) + + +def render_png( + project_ptr: Any, + model_name: str, + width: int = 0, + height: int = 0, +) -> bytes: + """Render a project model's diagram as a PNG image. + + Pass ``width=0`` and ``height=0`` (or omit them) to use the SVG's + intrinsic dimensions. When only one dimension is non-zero the other + is derived from the aspect ratio. When both are non-zero, ``width`` + takes precedence. + + Args: + project_ptr: Pointer to a SimlinProject + model_name: Name of the model to render + width: Target width in pixels (0 for intrinsic) + height: Target height in pixels (0 for intrinsic) + + Returns: + PNG image data as bytes + + Raises: + SimlinRuntimeError: If rendering fails + """ + c_name = string_to_c(model_name) + output_ptr = ffi.new("uint8_t **") + output_len_ptr = ffi.new("uintptr_t *") + err_ptr = ffi.new("SimlinError **") + + lib.simlin_project_render_png( + project_ptr, + c_name, + width, + height, + output_ptr, + output_len_ptr, + err_ptr, + ) + check_out_error(err_ptr, f"Render PNG for model '{model_name}'") + + try: + return bytes(ffi.buffer(output_ptr[0], output_len_ptr[0])) + finally: + lib.simlin_free(output_ptr[0]) + + __all__ = [ "_finalizer_refs", "_refs_lock", @@ -403,6 +483,8 @@ def open_json(json_data: bytes) -> Any: "model_get_var_json", "model_get_var_names", "open_json", + "render_png", + "render_svg", "serialize_json", "string_to_c", ] diff --git a/src/pysimlin/simlin/project.py b/src/pysimlin/simlin/project.py index bbfbc1c1f..21414a47f 100644 --- a/src/pysimlin/simlin/project.py +++ b/src/pysimlin/simlin/project.py @@ -31,6 +31,12 @@ from ._ffi import ( open_json as _ffi_open_json, ) +from ._ffi import ( + render_png as _ffi_render_png, +) +from ._ffi import ( + render_svg as _ffi_render_svg, +) from ._ffi import ( serialize_json as _ffi_serialize_json, ) @@ -419,6 +425,65 @@ def serialize_protobuf(self) -> bytes: finally: lib.simlin_free(output_ptr[0]) + def render_svg(self, model_name: str = "main") -> bytes: + """Render a model's stock-and-flow diagram as SVG. + + Args: + model_name: Name of the model to render (default: ``"main"``) + + Returns: + SVG data as UTF-8 encoded bytes + + Raises: + SimlinRuntimeError: If the model doesn't exist or rendering fails + """ + with self._lock: + self._check_alive() + return _ffi_render_svg(self._ptr, model_name) + + def render_svg_string(self, model_name: str = "main") -> str: + """Render a model's stock-and-flow diagram as an SVG string. + + Convenience wrapper around :meth:`render_svg` that decodes the + result to a Python string. + + Args: + model_name: Name of the model to render (default: ``"main"``) + + Returns: + SVG string + """ + return self.render_svg(model_name).decode("utf-8") + + def render_png( + self, + model_name: str = "main", + *, + width: int = 0, + height: int = 0, + ) -> bytes: + """Render a model's stock-and-flow diagram as a PNG image. + + Pass ``width=0`` and ``height=0`` (or omit them) to use the SVG's + intrinsic dimensions. When only one dimension is non-zero the other + is derived from the aspect ratio. When both are non-zero, ``width`` + takes precedence. + + Args: + model_name: Name of the model to render (default: ``"main"``) + width: Target width in pixels (0 for intrinsic) + height: Target height in pixels (0 for intrinsic) + + Returns: + PNG image data as bytes + + Raises: + SimlinRuntimeError: If the model doesn't exist or rendering fails + """ + with self._lock: + self._check_alive() + return _ffi_render_png(self._ptr, model_name, width, height) + def __enter__(self) -> Self: """Context manager entry point.""" return self diff --git a/src/pysimlin/tests/test_rendering.py b/src/pysimlin/tests/test_rendering.py new file mode 100644 index 000000000..57f231cbd --- /dev/null +++ b/src/pysimlin/tests/test_rendering.py @@ -0,0 +1,112 @@ +"""Tests for SVG and PNG rendering.""" + +import struct + +import pytest + +import simlin + + +class TestRenderSvg: + """Test SVG rendering from projects.""" + + def test_render_svg_returns_bytes(self, xmile_model_path) -> None: + """render_svg should return non-empty bytes.""" + model = simlin.load(xmile_model_path) + svg = model.project.render_svg() + assert isinstance(svg, bytes) + assert len(svg) > 0 + + def test_render_svg_is_valid_svg(self, xmile_model_path) -> None: + """render_svg output should contain SVG root element.""" + model = simlin.load(xmile_model_path) + svg = model.project.render_svg() + assert b" None: + """render_svg_string should return a valid SVG string.""" + model = simlin.load(xmile_model_path) + svg_str = model.project.render_svg_string() + assert isinstance(svg_str, str) + assert " None: + """render_svg should accept an explicit model name.""" + model = simlin.load(xmile_model_path) + names = model.project.get_model_names() + svg = model.project.render_svg(names[0]) + assert b" None: + """render_svg should raise for a nonexistent model name.""" + model = simlin.load(xmile_model_path) + with pytest.raises(Exception): + model.project.render_svg("nonexistent_model_xyz") + + +class TestRenderPng: + """Test PNG rendering from projects.""" + + PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + def test_render_png_returns_bytes(self, xmile_model_path) -> None: + """render_png should return non-empty bytes.""" + model = simlin.load(xmile_model_path) + png = model.project.render_png() + assert isinstance(png, bytes) + assert len(png) > 8 + + def test_render_png_has_valid_signature(self, xmile_model_path) -> None: + """render_png output should start with the PNG file signature.""" + model = simlin.load(xmile_model_path) + png = model.project.render_png() + assert png[:8] == self.PNG_SIGNATURE + + def test_render_png_with_width(self, xmile_model_path) -> None: + """render_png with explicit width should produce a valid PNG.""" + model = simlin.load(xmile_model_path) + png = model.project.render_png(width=400) + assert png[:8] == self.PNG_SIGNATURE + + # Parse IHDR chunk to verify width + width = struct.unpack(">I", png[16:20])[0] + assert width == 400 + + def test_render_png_with_height(self, xmile_model_path) -> None: + """render_png with explicit height should produce a valid PNG.""" + model = simlin.load(xmile_model_path) + png = model.project.render_png(height=300) + assert png[:8] == self.PNG_SIGNATURE + + # Parse IHDR chunk to verify height + height = struct.unpack(">I", png[20:24])[0] + assert height == 300 + + def test_render_png_preserves_aspect_ratio(self, xmile_model_path) -> None: + """Width-only and intrinsic renders should have the same aspect ratio.""" + model = simlin.load(xmile_model_path) + + intrinsic = model.project.render_png() + scaled = model.project.render_png(width=800) + + iw = struct.unpack(">I", intrinsic[16:20])[0] + ih = struct.unpack(">I", intrinsic[20:24])[0] + sw = struct.unpack(">I", scaled[16:20])[0] + sh = struct.unpack(">I", scaled[20:24])[0] + + intrinsic_ratio = iw / ih + scaled_ratio = sw / sh + assert abs(intrinsic_ratio - scaled_ratio) < 0.05 + + def test_render_png_nonexistent_model_raises(self, xmile_model_path) -> None: + """render_png should raise for a nonexistent model name.""" + model = simlin.load(xmile_model_path) + with pytest.raises(Exception): + model.project.render_png("nonexistent_model_xyz") + + def test_render_png_explicit_model_name(self, xmile_model_path) -> None: + """render_png should accept an explicit model name.""" + model = simlin.load(xmile_model_path) + names = model.project.get_model_names() + png = model.project.render_png(names[0]) + assert png[:8] == self.PNG_SIGNATURE diff --git a/src/server/CLAUDE.md b/src/server/CLAUDE.md index 8f9192976..0f4ba0851 100644 --- a/src/server/CLAUDE.md +++ b/src/server/CLAUDE.md @@ -16,6 +16,6 @@ For build/test/lint commands, see [doc/dev/commands.md](/doc/dev/commands.md). - `new-user.ts` -- New user handling - `server-init.ts` -- Server initialization - `route-handlers.ts` -- Route handler utilities -- `render.ts` / `render-inner.ts` / `render-worker.ts` -- Server-side rendering +- `render.ts` -- Server-side PNG rendering (delegates to the engine WASM) - `models/` -- Database interfaces (Firestore, etc.) - `schemas/` -- Data validation schemas diff --git a/src/server/package.json b/src/server/package.json index e18cc86f2..6b623794b 100644 --- a/src/server/package.json +++ b/src/server/package.json @@ -12,7 +12,6 @@ "@google-cloud/trace-agent": "^8.0.0", "@iarna/toml": "^2.2.5", "@simlin/core": "workspace:*", - "@simlin/diagram": "workspace:*", "@simlin/engine": "workspace:*", "body-parser": "^2.2.2", "cookie-parser": "^1.4.7", @@ -24,7 +23,6 @@ "node-fetch": "^3.3.2", "passport": "^0.7.0", "passport-strategy": "^1.0.0", - "resvg-wasm": "^0.5.0", "serve-favicon": "^2.5.1", "seshcookie": "^1.2.0", "uuid": "^13.0.0", diff --git a/src/server/render-inner.ts b/src/server/render-inner.ts deleted file mode 100644 index 745be9b98..000000000 --- a/src/server/render-inner.ts +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { readFileSync } from 'fs'; - -import { newContext } from 'resvg-wasm'; - -interface Box { - readonly width: number; - readonly height: number; -} - -export async function renderToPNG(svgString: string, viewbox: Box): Promise { - const ctx = await newContext(); - const fontData = readFileSync('fonts/Roboto-Light.ttf'); - - ctx.registerFontData(fontData); - - const retina = 2; // double the pixels for the same unit of measurement - const maxPreviewWidth = 400; - const maxDimension = Math.max(viewbox.width, viewbox.height); - let scale = (maxPreviewWidth * retina) / maxDimension; - if (scale > 1) { - scale = Math.ceil(scale); - } - - let pngData = ctx.render(svgString, scale, viewbox.width, viewbox.height); - if (!pngData) { - pngData = new Uint8Array(); - } - return pngData; -} diff --git a/src/server/render-worker.ts b/src/server/render-worker.ts deleted file mode 100644 index 26deacd5b..000000000 --- a/src/server/render-worker.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright 2026 The Simlin Authors. All rights reserved. -// Use of this source code is governed by the Apache License, -// Version 2.0, that can be found in the LICENSE file. - -import { parentPort, workerData } from 'worker_threads'; - -import { Box } from '@simlin/diagram/drawing/common'; - -import { renderToPNG } from './render-inner'; - -setImmediate(async () => { - const result = await renderToPNG(workerData.svgString as string, workerData.viewbox as Box); - parentPort?.postMessage(result, [result.buffer as ArrayBuffer]); -}); diff --git a/src/server/render.ts b/src/server/render.ts index b19e54efa..c9dc1b21a 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -2,36 +2,16 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import { Worker } from 'worker_threads'; - -import { projectFromJson } from '@simlin/core/datamodel'; import { Project as EngineProject } from '@simlin/engine'; -import type { JsonProject } from '@simlin/engine'; -import { renderSvgToString } from '@simlin/diagram/render-common'; import { File } from './schemas/file_pb'; +const PREVIEW_WIDTH = 800; // 400px * 2x retina + export async function renderToPNG(fileDoc: File): Promise { const engineProject = await EngineProject.openProtobuf(fileDoc.getProjectContents_asU8()); - const json = JSON.parse(await engineProject.serializeJson()) as JsonProject; - const project = projectFromJson(json); - await engineProject.dispose(); - - const [svgString, viewbox] = renderSvgToString(project, 'main'); - - return new Promise((ok, error) => { - try { - const worker = new Worker(__dirname + '/render-worker.js', { - workerData: { - svgString, - viewbox, - }, - }); - - worker.on('message', (result: Uint8Array) => { - ok(result); - }); - } catch (err) { - error(err); - } - }); + try { + return await engineProject.renderPng('main', PREVIEW_WIDTH); + } finally { + await engineProject.dispose(); + } } diff --git a/src/server/tests/render-model-preview.test.ts b/src/server/tests/render-model-preview.test.ts index 5f0c92217..6b1d08f53 100644 --- a/src/server/tests/render-model-preview.test.ts +++ b/src/server/tests/render-model-preview.test.ts @@ -6,10 +6,6 @@ import * as fs from 'fs'; import * as path from 'path'; import { Project as EngineProject } from '@simlin/engine'; -import { JsonProject } from '@simlin/engine'; -import { projectFromJson } from '@simlin/core/datamodel'; -import { renderSvgToString } from '@simlin/diagram/render-common'; -import { renderToPNG } from '../render-inner'; function loadDefaultProject(name: string): string { const modelPath = path.join(__dirname, '..', '..', '..', 'default_projects', name, 'model.xmile'); @@ -20,10 +16,8 @@ function loadDefaultProject(name: string): string { } // Simulate the server's preview generation pipeline: -// XMILE -> engine -> protobuf -> engine -> JSON -> DataModel -> SVG -> PNG -async function generatePreview( - modelName: string, -): Promise<{ svg: string; png: Uint8Array; viewbox: { width: number; height: number } }> { +// XMILE -> engine -> protobuf -> engine -> PNG +async function generatePreview(modelName: string): Promise { const xmile = loadDefaultProject(modelName); // Step 1: Import from XMILE and serialize to protobuf (same as new-user.ts) @@ -31,41 +25,26 @@ async function generatePreview( const protobuf = await importProject.serializeProtobuf(); await importProject.dispose(); - // Step 2: Load from protobuf and serialize to JSON (same as render.ts) + // Step 2: Load from protobuf and render PNG (same as render.ts) const engineProject = await EngineProject.openProtobuf(protobuf); - const json = JSON.parse(await engineProject.serializeJson()) as JsonProject; - const project = projectFromJson(json); - await engineProject.dispose(); - - // Step 3: Render to SVG - const [svgString, viewbox] = renderSvgToString(project, 'main'); - - // Step 4: Convert to PNG - const png = await renderToPNG(svgString, viewbox); - - return { svg: svgString, png, viewbox }; -} - -function stripTextElements(svg: string): string { - return svg.replace(/]*>[\s\S]*?<\/text>/g, ''); + try { + return await engineProject.renderPng('main', 800); + } finally { + await engineProject.dispose(); + } } describe('model preview rendering', () => { - it('population model text is actually rendered in PNG', async () => { - const { svg, viewbox } = await generatePreview('population'); - - // Verify SVG has text content - expect(svg).toContain('>population<'); - expect(svg).toContain('>births<'); + it('population model generates a valid PNG', async () => { + const png = await generatePreview('population'); - // Render the full SVG and a version with text stripped - const pngWithText = await renderToPNG(svg, viewbox); - const svgNoText = stripTextElements(svg); - const pngWithoutText = await renderToPNG(svgNoText, viewbox); + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(100); - // PNG with text should be meaningfully larger than without text, - // proving text is actually being rendered (not just present in SVG source) - const sizeDiff = pngWithText.length - pngWithoutText.length; - expect(sizeDiff).toBeGreaterThan(500); + // Verify PNG signature + expect(png[0]).toBe(137); + expect(png[1]).toBe(80); // P + expect(png[2]).toBe(78); // N + expect(png[3]).toBe(71); // G }); }); diff --git a/src/server/tests/render-preview.test.ts b/src/server/tests/render-preview.test.ts index 12d548a7d..e93767e8b 100644 --- a/src/server/tests/render-preview.test.ts +++ b/src/server/tests/render-preview.test.ts @@ -2,7 +2,7 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import { renderToPNG } from '../render-inner'; +import { Project as EngineProject } from '@simlin/engine'; function readPngDimensions(png: Uint8Array): { width: number; height: number } { const buffer = Buffer.from(png); @@ -16,16 +16,42 @@ function readPngDimensions(png: Uint8Array): { width: number; height: number } { } describe('renderToPNG preview scaling', () => { - it('scales based on the larger viewBox dimension', async () => { - const viewbox = { width: 200, height: 1000 }; - const svg = ` - -`; + it('renders PNG with explicit width preserving aspect ratio', async () => { + // Create a minimal project with a single aux to get a non-empty diagram + const projectJson = JSON.stringify({ + name: 'test', + simSpecs: { startTime: 0, endTime: 10, dt: '1' }, + models: [ + { + name: 'main', + stocks: [], + flows: [], + auxiliaries: [{ name: 'x', equation: '1' }], + views: [ + { + elements: [{ type: 'aux', uid: 1, name: 'x', x: 100, y: 100 }], + }, + ], + }, + ], + }); - const png = await renderToPNG(svg, viewbox); - const { width, height } = readPngDimensions(png); + const project = await EngineProject.openJson(projectJson); + const intrinsicPng = await project.renderPng('main'); + const scaledPng = await project.renderPng('main', 400); + await project.dispose(); - expect(width).toBe(160); - expect(height).toBe(800); + expect(intrinsicPng.length).toBeGreaterThan(0); + expect(scaledPng.length).toBeGreaterThan(0); + + const intrinsicDims = readPngDimensions(intrinsicPng); + const scaledDims = readPngDimensions(scaledPng); + + expect(scaledDims.width).toBe(400); + + // Aspect ratio should be preserved + const intrinsicRatio = intrinsicDims.width / intrinsicDims.height; + const scaledRatio = scaledDims.width / scaledDims.height; + expect(Math.abs(intrinsicRatio - scaledRatio)).toBeLessThan(0.05); }); }); diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index c93338e78..126106d5d 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -15,7 +15,6 @@ "lib.browser" ], "references": [ - { "path": "../core/tsconfig.json" }, - { "path": "../diagram/tsconfig.json" } + { "path": "../core/tsconfig.json" } ] } From b5a79a7b606f3fb1ddea68e93795668c8b7390ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 17:43:18 +0000 Subject: [PATCH 2/3] server: clamp preview by max dimension, not just width The previous change always constrained by width, which meant portrait/tall diagrams could produce very large PNGs. This matches the original resvg-wasm behavior: scale by the larger of width/height so neither dimension exceeds the target. Extracts previewDimensions() and parseSvgDimensions() as pure functions with dedicated unit tests covering landscape, portrait, square, edge cases, and extreme aspect ratios. https://claude.ai/code/session_012WrnfoGCS23uqHEkhxyxFX --- src/server/render.ts | 53 +++++- src/server/tests/render-model-preview.test.ts | 31 +++- src/server/tests/render-preview.test.ts | 157 ++++++++++++------ 3 files changed, 185 insertions(+), 56 deletions(-) diff --git a/src/server/render.ts b/src/server/render.ts index c9dc1b21a..57588a2cd 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -5,12 +5,61 @@ import { Project as EngineProject } from '@simlin/engine'; import { File } from './schemas/file_pb'; -const PREVIEW_WIDTH = 800; // 400px * 2x retina +const MAX_PREVIEW_PX = 400; +const RETINA_SCALE = 2; +const MAX_PREVIEW_SIZE = MAX_PREVIEW_PX * RETINA_SCALE; // 800 + +/** + * Compute the width and height to pass to renderPng so that the + * larger dimension is clamped to `maxSize` and aspect ratio is + * preserved. + * + * When the diagram is wider than tall, width is constrained. + * When taller than wide, height is constrained. + */ +export function previewDimensions( + svgWidth: number, + svgHeight: number, + maxSize: number, +): { width: number; height: number } { + if (svgWidth <= 0 || svgHeight <= 0 || maxSize <= 0) { + return { width: 0, height: 0 }; + } + if (svgWidth >= svgHeight) { + // Landscape or square: constrain width, derive height + const scale = maxSize / svgWidth; + return { width: maxSize, height: Math.ceil(svgHeight * scale) }; + } + // Portrait: constrain height, derive width + const scale = maxSize / svgHeight; + return { width: Math.ceil(svgWidth * scale), height: maxSize }; +} + +/** + * Parse the viewBox dimensions from an SVG string. + * + * Returns `{width, height}` from the third and fourth viewBox values. + * Falls back to `{0, 0}` when the viewBox is absent or unparseable. + */ +export function parseSvgDimensions(svg: string): { width: number; height: number } { + const match = svg.match(/viewBox="([^"]*)"/); + if (!match) { + return { width: 0, height: 0 }; + } + const parts = match[1].trim().split(/\s+/).map(Number); + if (parts.length < 4 || parts.some(isNaN)) { + return { width: 0, height: 0 }; + } + return { width: parts[2], height: parts[3] }; +} export async function renderToPNG(fileDoc: File): Promise { const engineProject = await EngineProject.openProtobuf(fileDoc.getProjectContents_asU8()); try { - return await engineProject.renderPng('main', PREVIEW_WIDTH); + const svg = await engineProject.renderSvgString('main'); + const intrinsic = parseSvgDimensions(svg); + const dims = previewDimensions(intrinsic.width, intrinsic.height, MAX_PREVIEW_SIZE); + return await engineProject.renderPng('main', dims.width, dims.height); } finally { await engineProject.dispose(); } diff --git a/src/server/tests/render-model-preview.test.ts b/src/server/tests/render-model-preview.test.ts index 6b1d08f53..a0eb364aa 100644 --- a/src/server/tests/render-model-preview.test.ts +++ b/src/server/tests/render-model-preview.test.ts @@ -6,6 +6,9 @@ import * as fs from 'fs'; import * as path from 'path'; import { Project as EngineProject } from '@simlin/engine'; +import { previewDimensions, parseSvgDimensions } from '../render'; + +const MAX_PREVIEW_SIZE = 800; function loadDefaultProject(name: string): string { const modelPath = path.join(__dirname, '..', '..', '..', 'default_projects', name, 'model.xmile'); @@ -15,8 +18,19 @@ function loadDefaultProject(name: string): string { return fs.readFileSync(modelPath, 'utf8'); } +function readPngDimensions(png: Uint8Array): { width: number; height: number } { + const buffer = Buffer.from(png); + if (buffer.length < 24) { + throw new Error('PNG data too short'); + } + return { + width: buffer.readUInt32BE(16), + height: buffer.readUInt32BE(20), + }; +} + // Simulate the server's preview generation pipeline: -// XMILE -> engine -> protobuf -> engine -> PNG +// XMILE -> engine -> protobuf -> engine -> SVG (for dims) -> PNG async function generatePreview(modelName: string): Promise { const xmile = loadDefaultProject(modelName); @@ -25,10 +39,13 @@ async function generatePreview(modelName: string): Promise { const protobuf = await importProject.serializeProtobuf(); await importProject.dispose(); - // Step 2: Load from protobuf and render PNG (same as render.ts) + // Step 2: Load from protobuf, get SVG dimensions, render PNG (same as render.ts) const engineProject = await EngineProject.openProtobuf(protobuf); try { - return await engineProject.renderPng('main', 800); + const svg = await engineProject.renderSvgString('main'); + const intrinsic = parseSvgDimensions(svg); + const dims = previewDimensions(intrinsic.width, intrinsic.height, MAX_PREVIEW_SIZE); + return await engineProject.renderPng('main', dims.width, dims.height); } finally { await engineProject.dispose(); } @@ -47,4 +64,12 @@ describe('model preview rendering', () => { expect(png[2]).toBe(78); // N expect(png[3]).toBe(71); // G }); + + it('population preview is bounded by max preview size', async () => { + const png = await generatePreview('population'); + const dims = readPngDimensions(png); + + expect(dims.width).toBeLessThanOrEqual(MAX_PREVIEW_SIZE); + expect(dims.height).toBeLessThanOrEqual(MAX_PREVIEW_SIZE); + }); }); diff --git a/src/server/tests/render-preview.test.ts b/src/server/tests/render-preview.test.ts index e93767e8b..f59e3a182 100644 --- a/src/server/tests/render-preview.test.ts +++ b/src/server/tests/render-preview.test.ts @@ -2,56 +2,111 @@ // Use of this source code is governed by the Apache License, // Version 2.0, that can be found in the LICENSE file. -import { Project as EngineProject } from '@simlin/engine'; - -function readPngDimensions(png: Uint8Array): { width: number; height: number } { - const buffer = Buffer.from(png); - if (buffer.length < 24) { - throw new Error('PNG data too short'); - } - return { - width: buffer.readUInt32BE(16), - height: buffer.readUInt32BE(20), - }; -} - -describe('renderToPNG preview scaling', () => { - it('renders PNG with explicit width preserving aspect ratio', async () => { - // Create a minimal project with a single aux to get a non-empty diagram - const projectJson = JSON.stringify({ - name: 'test', - simSpecs: { startTime: 0, endTime: 10, dt: '1' }, - models: [ - { - name: 'main', - stocks: [], - flows: [], - auxiliaries: [{ name: 'x', equation: '1' }], - views: [ - { - elements: [{ type: 'aux', uid: 1, name: 'x', x: 100, y: 100 }], - }, - ], - }, - ], - }); - - const project = await EngineProject.openJson(projectJson); - const intrinsicPng = await project.renderPng('main'); - const scaledPng = await project.renderPng('main', 400); - await project.dispose(); - - expect(intrinsicPng.length).toBeGreaterThan(0); - expect(scaledPng.length).toBeGreaterThan(0); - - const intrinsicDims = readPngDimensions(intrinsicPng); - const scaledDims = readPngDimensions(scaledPng); - - expect(scaledDims.width).toBe(400); - - // Aspect ratio should be preserved - const intrinsicRatio = intrinsicDims.width / intrinsicDims.height; - const scaledRatio = scaledDims.width / scaledDims.height; - expect(Math.abs(intrinsicRatio - scaledRatio)).toBeLessThan(0.05); +import { previewDimensions, parseSvgDimensions } from '../render'; + +describe('previewDimensions', () => { + const MAX = 800; + + it('constrains by width for landscape diagrams', () => { + const dims = previewDimensions(1000, 500, MAX); + expect(dims.width).toBe(800); + expect(dims.height).toBe(400); + }); + + it('constrains by height for portrait diagrams', () => { + const dims = previewDimensions(200, 1000, MAX); + expect(dims.width).toBe(160); + expect(dims.height).toBe(800); + }); + + it('constrains by width for square diagrams', () => { + const dims = previewDimensions(600, 600, MAX); + expect(dims.width).toBe(800); + expect(dims.height).toBe(800); + }); + + it('preserves aspect ratio for landscape', () => { + const dims = previewDimensions(1600, 900, MAX); + const inputRatio = 1600 / 900; + const outputRatio = dims.width / dims.height; + expect(Math.abs(inputRatio - outputRatio)).toBeLessThan(0.02); + }); + + it('preserves aspect ratio for portrait', () => { + const dims = previewDimensions(300, 1200, MAX); + const inputRatio = 300 / 1200; + const outputRatio = dims.width / dims.height; + expect(Math.abs(inputRatio - outputRatio)).toBeLessThan(0.02); + }); + + it('neither dimension exceeds maxSize for landscape', () => { + const dims = previewDimensions(2000, 500, MAX); + expect(dims.width).toBeLessThanOrEqual(MAX); + expect(dims.height).toBeLessThanOrEqual(MAX); + }); + + it('neither dimension exceeds maxSize for portrait', () => { + const dims = previewDimensions(100, 2000, MAX); + expect(dims.width).toBeLessThanOrEqual(MAX); + expect(dims.height).toBeLessThanOrEqual(MAX); + }); + + it('returns zeros for zero-width input', () => { + expect(previewDimensions(0, 500, MAX)).toEqual({ width: 0, height: 0 }); + }); + + it('returns zeros for zero-height input', () => { + expect(previewDimensions(500, 0, MAX)).toEqual({ width: 0, height: 0 }); + }); + + it('returns zeros for zero maxSize', () => { + expect(previewDimensions(500, 500, 0)).toEqual({ width: 0, height: 0 }); + }); + + it('returns zeros for negative dimensions', () => { + expect(previewDimensions(-100, 500, MAX)).toEqual({ width: 0, height: 0 }); + }); + + it('handles extreme aspect ratios without overflow', () => { + const tall = previewDimensions(10, 10000, MAX); + expect(tall.height).toBe(800); + expect(tall.width).toBeLessThanOrEqual(800); + + const wide = previewDimensions(10000, 10, MAX); + expect(wide.width).toBe(800); + expect(wide.height).toBeLessThanOrEqual(800); + }); +}); + +describe('parseSvgDimensions', () => { + it('parses standard viewBox', () => { + const svg = ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 500, height: 300 }); + }); + + it('parses viewBox with negative offsets', () => { + const svg = ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 400, height: 600 }); + }); + + it('returns zeros when viewBox is missing', () => { + const svg = ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 0, height: 0 }); + }); + + it('returns zeros for malformed viewBox', () => { + const svg = ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 0, height: 0 }); + }); + + it('handles extra whitespace in viewBox', () => { + const svg = ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 800, height: 600 }); + }); + + it('handles viewBox with style attribute present', () => { + const svg = + ''; + expect(parseSvgDimensions(svg)).toEqual({ width: 500, height: 300 }); }); }); From 9a3978739807e4ab1244bbe78b1b35cdd13ef299 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 20 Feb 2026 18:53:47 +0000 Subject: [PATCH 3/3] server: pass only the constraining dimension to renderPng The engine gives width precedence when both width and height are non-zero, which means the height bound is silently ignored for portrait diagrams. For example, intrinsic 101x2000 with max 800 previously computed {width: 41, height: 800}, but the engine used width=41 and derived height=812, exceeding the limit. previewDimensions now returns only the constraining dimension as non-zero (the other as 0), letting the engine derive the unconstrained dimension from the aspect ratio. https://claude.ai/code/session_012WrnfoGCS23uqHEkhxyxFX --- src/server/render.ts | 20 +++++------ src/server/tests/render-preview.test.ts | 48 ++++++++++++------------- 2 files changed, 31 insertions(+), 37 deletions(-) diff --git a/src/server/render.ts b/src/server/render.ts index 57588a2cd..3d3618800 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -10,12 +10,12 @@ const RETINA_SCALE = 2; const MAX_PREVIEW_SIZE = MAX_PREVIEW_PX * RETINA_SCALE; // 800 /** - * Compute the width and height to pass to renderPng so that the - * larger dimension is clamped to `maxSize` and aspect ratio is - * preserved. + * Compute the single constraining dimension to pass to renderPng. * - * When the diagram is wider than tall, width is constrained. - * When taller than wide, height is constrained. + * Returns only the constraining dimension set to `maxSize`, with + * the other set to 0 so the engine derives it from the aspect ratio. + * This avoids the width-precedence bug where passing both non-zero + * causes the engine to ignore the height constraint. */ export function previewDimensions( svgWidth: number, @@ -26,13 +26,11 @@ export function previewDimensions( return { width: 0, height: 0 }; } if (svgWidth >= svgHeight) { - // Landscape or square: constrain width, derive height - const scale = maxSize / svgWidth; - return { width: maxSize, height: Math.ceil(svgHeight * scale) }; + // Landscape or square: constrain width, let the engine derive height + return { width: maxSize, height: 0 }; } - // Portrait: constrain height, derive width - const scale = maxSize / svgHeight; - return { width: Math.ceil(svgWidth * scale), height: maxSize }; + // Portrait: constrain height, let the engine derive width + return { width: 0, height: maxSize }; } /** diff --git a/src/server/tests/render-preview.test.ts b/src/server/tests/render-preview.test.ts index f59e3a182..4a11cb7d3 100644 --- a/src/server/tests/render-preview.test.ts +++ b/src/server/tests/render-preview.test.ts @@ -7,48 +7,44 @@ import { previewDimensions, parseSvgDimensions } from '../render'; describe('previewDimensions', () => { const MAX = 800; - it('constrains by width for landscape diagrams', () => { + it('constrains width for landscape diagrams', () => { const dims = previewDimensions(1000, 500, MAX); expect(dims.width).toBe(800); - expect(dims.height).toBe(400); + expect(dims.height).toBe(0); }); - it('constrains by height for portrait diagrams', () => { + it('constrains height for portrait diagrams', () => { const dims = previewDimensions(200, 1000, MAX); - expect(dims.width).toBe(160); + expect(dims.width).toBe(0); expect(dims.height).toBe(800); }); - it('constrains by width for square diagrams', () => { + it('constrains width for square diagrams', () => { const dims = previewDimensions(600, 600, MAX); expect(dims.width).toBe(800); - expect(dims.height).toBe(800); + expect(dims.height).toBe(0); }); - it('preserves aspect ratio for landscape', () => { + it('only one dimension is non-zero for landscape', () => { const dims = previewDimensions(1600, 900, MAX); - const inputRatio = 1600 / 900; - const outputRatio = dims.width / dims.height; - expect(Math.abs(inputRatio - outputRatio)).toBeLessThan(0.02); + expect(dims.width).toBe(MAX); + expect(dims.height).toBe(0); }); - it('preserves aspect ratio for portrait', () => { + it('only one dimension is non-zero for portrait', () => { const dims = previewDimensions(300, 1200, MAX); - const inputRatio = 300 / 1200; - const outputRatio = dims.width / dims.height; - expect(Math.abs(inputRatio - outputRatio)).toBeLessThan(0.02); - }); - - it('neither dimension exceeds maxSize for landscape', () => { - const dims = previewDimensions(2000, 500, MAX); - expect(dims.width).toBeLessThanOrEqual(MAX); - expect(dims.height).toBeLessThanOrEqual(MAX); + expect(dims.width).toBe(0); + expect(dims.height).toBe(MAX); }); - it('neither dimension exceeds maxSize for portrait', () => { - const dims = previewDimensions(100, 2000, MAX); - expect(dims.width).toBeLessThanOrEqual(MAX); - expect(dims.height).toBeLessThanOrEqual(MAX); + it('avoids the width-precedence bug for portrait', () => { + // Regression: passing both width and height caused the engine to + // ignore the height constraint (width takes precedence). + // e.g. 101x2000 → previewDimensions should return {0, 800} + // so the engine constrains by height, not width. + const dims = previewDimensions(101, 2000, MAX); + expect(dims.width).toBe(0); + expect(dims.height).toBe(800); }); it('returns zeros for zero-width input', () => { @@ -69,12 +65,12 @@ describe('previewDimensions', () => { it('handles extreme aspect ratios without overflow', () => { const tall = previewDimensions(10, 10000, MAX); + expect(tall.width).toBe(0); expect(tall.height).toBe(800); - expect(tall.width).toBeLessThanOrEqual(800); const wide = previewDimensions(10000, 10, MAX); expect(wide.width).toBe(800); - expect(wide.height).toBeLessThanOrEqual(800); + expect(wide.height).toBe(0); }); });