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..3d3618800 100644 --- a/src/server/render.ts +++ b/src/server/render.ts @@ -2,36 +2,63 @@ // 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'; -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 MAX_PREVIEW_PX = 400; +const RETINA_SCALE = 2; +const MAX_PREVIEW_SIZE = MAX_PREVIEW_PX * RETINA_SCALE; // 800 - const [svgString, viewbox] = renderSvgToString(project, 'main'); +/** + * Compute the single constraining dimension to pass to renderPng. + * + * 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, + 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, let the engine derive height + return { width: maxSize, height: 0 }; + } + // Portrait: constrain height, let the engine derive width + return { width: 0, height: maxSize }; +} - return new Promise((ok, error) => { - try { - const worker = new Worker(__dirname + '/render-worker.js', { - workerData: { - svgString, - viewbox, - }, - }); +/** + * 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] }; +} - worker.on('message', (result: Uint8Array) => { - ok(result); - }); - } catch (err) { - error(err); - } - }); +export async function renderToPNG(fileDoc: File): Promise { + const engineProject = await EngineProject.openProtobuf(fileDoc.getProjectContents_asU8()); + try { + 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 5f0c92217..a0eb364aa 100644 --- a/src/server/tests/render-model-preview.test.ts +++ b/src/server/tests/render-model-preview.test.ts @@ -6,10 +6,9 @@ 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'; +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'); @@ -19,11 +18,20 @@ 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 -> JSON -> DataModel -> SVG -> PNG -async function generatePreview( - modelName: string, -): Promise<{ svg: string; png: Uint8Array; viewbox: { width: number; height: number } }> { +// XMILE -> engine -> protobuf -> engine -> SVG (for dims) -> 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 +39,37 @@ 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, get SVG dimensions, 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 { + 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(); + } } describe('model preview rendering', () => { - it('population model text is actually rendered in PNG', async () => { - const { svg, viewbox } = await generatePreview('population'); + it('population model generates a valid PNG', async () => { + const png = await generatePreview('population'); + + expect(png).toBeInstanceOf(Uint8Array); + expect(png.length).toBeGreaterThan(100); - // Verify SVG has text content - expect(svg).toContain('>population<'); - expect(svg).toContain('>births<'); + // 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 + }); - // 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); + it('population preview is bounded by max preview size', async () => { + const png = await generatePreview('population'); + const dims = readPngDimensions(png); - // 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); + 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 12d548a7d..4a11cb7d3 100644 --- a/src/server/tests/render-preview.test.ts +++ b/src/server/tests/render-preview.test.ts @@ -2,30 +2,107 @@ // 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'; - -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('scales based on the larger viewBox dimension', async () => { - const viewbox = { width: 200, height: 1000 }; - const svg = ` - -`; - - const png = await renderToPNG(svg, viewbox); - const { width, height } = readPngDimensions(png); - - expect(width).toBe(160); - expect(height).toBe(800); +import { previewDimensions, parseSvgDimensions } from '../render'; + +describe('previewDimensions', () => { + const MAX = 800; + + it('constrains width for landscape diagrams', () => { + const dims = previewDimensions(1000, 500, MAX); + expect(dims.width).toBe(800); + expect(dims.height).toBe(0); + }); + + it('constrains height for portrait diagrams', () => { + const dims = previewDimensions(200, 1000, MAX); + expect(dims.width).toBe(0); + expect(dims.height).toBe(800); + }); + + it('constrains width for square diagrams', () => { + const dims = previewDimensions(600, 600, MAX); + expect(dims.width).toBe(800); + expect(dims.height).toBe(0); + }); + + it('only one dimension is non-zero for landscape', () => { + const dims = previewDimensions(1600, 900, MAX); + expect(dims.width).toBe(MAX); + expect(dims.height).toBe(0); + }); + + it('only one dimension is non-zero for portrait', () => { + const dims = previewDimensions(300, 1200, MAX); + expect(dims.width).toBe(0); + expect(dims.height).toBe(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', () => { + 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.width).toBe(0); + expect(tall.height).toBe(800); + + const wide = previewDimensions(10000, 10, MAX); + expect(wide.width).toBe(800); + expect(wide.height).toBe(0); + }); +}); + +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 }); }); }); 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" } ] }