From 55cdb45e735feb8e9f394b3e0b0836c54c3f4eef Mon Sep 17 00:00:00 2001 From: guangtao Date: Thu, 5 Nov 2026 20:17:29 +0800 Subject: [PATCH 1/3] Encode PNG and WebP with Sharp and select the smaller raster --- .github/workflows/render.yml | 2 +- README.org | 4 +- bun.lock | 64 ++++++++++++++++++- .../10.146-taste-selection-and-reuse.org | 31 +++++++++ package.json | 1 + packages/render/README.org | 45 +++++++++++++ packages/render/THIRD-PARTY.org | 5 ++ packages/render/examples/library-case.ts | 1 + packages/render/examples/overviews.ts | 1 + packages/render/src/export.ts | 24 +++++-- packages/render/src/png.ts | 23 +++++++ packages/render/src/webp.ts | 20 ++++++ packages/render/test/export.browser.ts | 34 ++++++++++ packages/render/test/png.test.ts | 38 +++++++++++ packages/render/test/webp.test.ts | 20 ++++++ 15 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 packages/render/src/png.ts create mode 100644 packages/render/src/webp.ts create mode 100644 packages/render/test/png.test.ts create mode 100644 packages/render/test/webp.test.ts diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index a274a1f..2a0115d 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -54,4 +54,4 @@ jobs: cd "$render_consumer" bun init -y bun add "@deterministic-engineering/render@git+https://github.com/hardenedlinux/deterministic-engineering.git#$RENDER_REVISION" - bun -e 'import metadata from "@deterministic-engineering/render/package.json"; import {compileModel} from "@deterministic-engineering/render"; if(metadata.version!==process.env.RENDER_EXPECTED_VERSION)throw new Error("Wrong Git package version"); const compiled=await compileModel({"consumer.d2":"Library -> Consumer"},"consumer.d2"); if(compiled.model.relations.length!==1)throw new Error("Git-installed worker did not compile the consumer model");' + bun -e 'import metadata from "@deterministic-engineering/render/package.json"; import {compileModel} from "@deterministic-engineering/render"; import {encodeWebP} from "@deterministic-engineering/render/export"; if(metadata.version!==process.env.RENDER_EXPECTED_VERSION)throw new Error("Wrong Git package version"); const compiled=await compileModel({"consumer.d2":"Library -> Consumer"},"consumer.d2"); if(compiled.model.relations.length!==1)throw new Error("Git-installed worker did not compile the consumer model"); const webp=await encodeWebP(Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADklEQVQImWP4DwUMMAYAj4IP8cvlVgcAAAAASUVORK5CYII=","base64")); if(Buffer.from(webp.bytes).toString("ascii",8,12)!=="WEBP")throw new Error("Git-installed native WebP encoder failed");' diff --git a/README.org b/README.org index 86f6049..382be22 100644 --- a/README.org +++ b/README.org @@ -124,7 +124,9 @@ measure actual browser text/fonts/images and return artifacts plus rendering fac The package reuses D2 model compilation, ELK.js scene layout/routing, parse5 HTML parsing, Rough.js options/primitives, XYFlow connector paths and Playwright export. It has no -interactive whiteboard or React runtime. A node diagram or swimlane is one example, +interactive whiteboard or React runtime. Sharp encodes lossless PNG/WebP; +=preferredRaster= selects the smaller actual raster with its MIME type, while SVG +remains the vector output. Lossy compression is an explicit export choice. A node diagram or swimlane is one example, not the framework's entire diagram vocabulary. ** Real Library / Profile / Scenario Case diff --git a/bun.lock b/bun.lock index 5a9c091..e0578b4 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,4 @@ // SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors -// // SPDX-License-Identifier: LGPL-2.1-or-later { @@ -16,6 +15,7 @@ "elkjs": "0.12.1", "parse5": "8.0.1", "roughjs": "4.6.6", + "sharp": "0.35.5", "valibot": "1.2.0", "web-worker": "1.5.0", }, @@ -45,12 +45,68 @@ "@connectrpc/connect-web": ["@connectrpc/connect-web@2.1.2", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.2" } }, "sha512-1tfaK85MU+gJjwwmL31d2rzdf0XCYX99chZf63uG89SGBUd4XuZ4ZzhGo2u79TPXOE6nLIZQ2okrpyey42PYdg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@fontsource/inter": ["@fontsource/inter@5.3.0", "", {}, "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g=="], "@fontsource/kalam": ["@fontsource/kalam@5.3.0", "", {}, "sha512-FDkVoDfPDCSN/eO81FOYtUI67eMgpVBJmjBtAqSL0PmEhJyeFY2biC+CR+yEttksgXR9+swpFdYmQTyjwBq80Q=="], "@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="], + "@npmcli/agent": ["@npmcli/agent@4.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" } }, "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg=="], "@npmcli/fs": ["@npmcli/fs@5.0.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og=="], @@ -175,6 +231,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "elkjs": ["elkjs@0.12.1", "", {}, "sha512-IhMYKHE4S2vtx0QrhBP3lYOIKDeIiJXBrdq9MGYkuB8ZHqhrBnHNfSUndjXFewrX1CPK8chwq+2NA0K0hliYuw=="], "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], @@ -247,6 +305,8 @@ "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], "socks": ["socks@2.8.10", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ=="], @@ -255,6 +315,8 @@ "ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], + "tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="], + "tuf-js": ["tuf-js@4.1.0", "", { "dependencies": { "@tufjs/models": "4.1.0", "debug": "^4.4.3", "make-fetch-happen": "^15.0.1" } }, "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ=="], "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], diff --git a/docs/10-19-design/10-architecture/10.146-taste-selection-and-reuse.org b/docs/10-19-design/10-architecture/10.146-taste-selection-and-reuse.org index 75226d2..039bbe6 100644 --- a/docs/10-19-design/10-architecture/10.146-taste-selection-and-reuse.org +++ b/docs/10-19-design/10-architecture/10.146-taste-selection-and-reuse.org @@ -175,3 +175,34 @@ mixed artifacts must reject; re-rendering cannot stand in for recovery acceptanc Neither vendor, manual receipts, an additional publisher nor a Release URL is used. The upstream and downstream CI results qualify their respective revisions separately; a release tag alone grants neither model publication nor Aitia review authority. + + +* Image compression and measured format choice + +Sharp (pinned in root package.json) owns native PNG/WebP encoding after the existing +browser screenshot. Both default paths are lossless. The export returns compatible +PNG, optional WebP and a preferredRaster selected by actual encoded byte count; +format and MIME accompany the selected bytes. Native encoder settings and final +hashes are retained in the existing receipt. Models, D2/SysML identity, Questions, +fonts, dimensions and SVG composition do not select a hardcoded image format. +A host may disable WebP, bypass PNG re-encoding or explicitly opt into lossy +WebP/palette PNG. It must publish with the correct extension and actual receipt. +There is no new image publisher, file watcher, optimizer subprocess or global +resource configuration. Browser CI keeps its two workers. + +Local corpus observation (2026-11-05): 38 existing repository PNGs totalled +15,313,994 bytes. Default lossless PNG retained 15,010,083 bytes (1.98% reduction); +lossless WebP produced 7,874,400 bytes (48.58% reduction). RGBA decode comparison +was identical for every original/WebP pair in this corpus. Explicit palette PNG +(quality 95, effort 7) produced 6,002,680 bytes (60.80% reduction), with a nonzero +mean absolute channel error; it must not be described as lossless. Ordered local +encoding totals were 10.61/19.28/14.10 seconds respectively, not an isolated or +cross-platform throughput benchmark. Source images were not replaced in-place. +These results guide a per-image choice, not a universal WebP or PNG preference. + +Controls cover exact RGBA/dimensions/partial-alpha preservation, original retention +when PNG grows, explicit codec bypass, invalid/corrupted inputs, lossy opt-in, +real browser/region hashes and MIME/byte selection. SVG remains a separate vector +output: embedded fonts and zoom semantics make bytes alone an insufficient reason +to replace it. Lossless WebP may canonicalize invisible colour under alpha zero; +no pixel-perfect hidden-colour guarantee is inferred beyond the measured corpus. diff --git a/package.json b/package.json index 0f1e637..7fceb9b 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "elkjs": "0.12.1", "parse5": "8.0.1", "roughjs": "4.6.6", + "sharp": "0.35.5", "valibot": "1.2.0", "web-worker": "1.5.0" }, diff --git a/packages/render/README.org b/packages/render/README.org index 3f0c9bb..63b5a0c 100644 --- a/packages/render/README.org +++ b/packages/render/README.org @@ -292,3 +292,48 @@ IPC fault fixtures test protocol admission only; they are not alternate compiler The child keeps IPC referenced through worker termination and send acknowledgement, then disconnects. Removing the last message listener before that point can make Bun exit before delivering the result, and must not be used as a cleanup shortcut. + + +* Raster encoding and format choice + +The export effect uses pinned Sharp/libvips for PNG and WebP encoding. The default +PNG is losslessly recompressed with compressionLevel 9 and adaptiveFiltering true; +if this grows the file the original browser PNG is retained. Default WebP is also +lossless (effort 4). =preferredRaster= returns the smaller generated raster with +its actual format, MIME type and bytes. The caller uses that extension/MIME; WebP +bytes are never returned under the PNG property. =png=, =regionImages= and =svg= +retain their existing roles. Regions use the same PNG compression options. + +#+begin_src typescript +const image = await exportFigure(figure, browser); +const {format, mimeType, bytes} = image.preferredRaster; +// Write bytes to `overview.${format}` or send with mimeType. + +// Explicit compatibility/performance choice: skip WebP encoding. +await exportFigure(figure, browser, {webp: false}); +// Explicit smaller, lossy alternatives; never implied by a quality value alone. +await exportFigure(figure, browser, { + webp: {lossless: false, quality: 95, smartSubsample: true}, +}); +await exportFigure(figure, browser, { + pngCompression: {palette: true, quality: 95, effort: 7}, +}); +// Preserve the exact browser PNG bytes if required. +await exportFigure(figure, browser, {pngCompression: false, webp: false}); +#+end_src + +Sharp owns codec validation and implementation. Unsupported controls refuse; a +quality/quantization control requires explicit lossy WebP or palette PNG selection. +No resizing, global Sharp concurrency/cache mutation, or external optimizer process +is added. Errors propagate through the existing browser-context cleanup boundary. +Receipts bind final PNG/WebP/region hashes, native encoder parameters and input/ +output byte counts. The model fingerprint still identifies model/composition; +encoding choices belong to the export receipt. Lossless WebP can discard invisible +RGB beneath fully transparent pixels; alpha and visible content are preserved. + +SVG remains an independent vector deliverable for zoomable documentation. Its +embedded fonts, standalone behaviour and editability differ from raster formats; +it is not silently substituted into the raster interface based on size alone. +Maintained Case/overview exports write PNG plus WebP for comparison in the ignored +output directory. Hosts choose which artifact to publish, avoiding duplicate +committed assets and retaining existing source/publication checks. diff --git a/packages/render/THIRD-PARTY.org b/packages/render/THIRD-PARTY.org index c2c4c77..aa2455b 100644 --- a/packages/render/THIRD-PARTY.org +++ b/packages/render/THIRD-PARTY.org @@ -17,3 +17,8 @@ not font software. License texts accompany this package in LICENSE and LICENSE.O The export API accepts explicit artifact attribution from the publishing project; it never assumes ownership of downstream diagrams. The repository's examples supply the actual project and font notices before computing output fingerprints. + +Sharp is maintained by its upstream contributors under Apache-2.0 and uses libvips +and platform codec binaries with their respective upstream notices. They are +installed through declared package dependencies, not copied or relicensed here. +See https://sharp.pixelplumbing.com/ and the installed sharp/@img package notices. diff --git a/packages/render/examples/library-case.ts b/packages/render/examples/library-case.ts index 42e7c1c..47930b8 100644 --- a/packages/render/examples/library-case.ts +++ b/packages/render/examples/library-case.ts @@ -55,6 +55,7 @@ try { const image = await exportFigure(figure, browser, {scale: 2, regions: [region],attribution:attributionFor(figure)}); for (const file of sourcePaths) if (await readFile(path.join(root, file), 'utf8') !== sources[file]) throw new Error(`Source changed during export: ${file}`); await writeFile(path.join(output, `${name}.html`), image.html); + if(image.webp)await writeFile(path.join(output, `${name}.webp`), image.webp); await writeFile(path.join(output, `${name}.png`), image.png); await writeFile(path.join(output, `${name}-detail.png`), image.regionImages[region]!); await writeFile(path.join(output, `${name}.json`), JSON.stringify({status: 'design explanation / source observations only', ...image.receipt, sources: figure.sourceSHA256, observations}, null, 2)); diff --git a/packages/render/examples/overviews.ts b/packages/render/examples/overviews.ts index d6aa058..715b82a 100644 --- a/packages/render/examples/overviews.ts +++ b/packages/render/examples/overviews.ts @@ -28,6 +28,7 @@ try{for(const name of names){ if(minimumTextSize!==undefined&&typeof minimumTextSize!=='number')throw new Error('Invalid project minimumTextSize'); const result=await exportFigure(figure,browser,{scale:1.5,minimumTextSize,attribution:attributionFor(figure)}); for(const[file,source]of Object.entries(figure.sourceSHA256)) { const {createHash}=await import('node:crypto');if(createHash('sha256').update(await readFile(path.join(root,file))).digest('hex')!==source)throw new Error('Stale overview source: '+file); } + if(result.webp)await writeFile(path.join(output,outputName+'.webp'),result.webp); await writeFile(path.join(output,outputName+'.html'),result.html);await writeFile(path.join(output,outputName+'.png'),result.png); for(const[index,svg]of result.svg.entries())await writeFile(path.join(output,outputName+(index===0?'':`-scene-${index}`)+'.svg'),svg); await writeFile(path.join(output,outputName+'.json'),JSON.stringify({...result.receipt,sources:figure.sourceSHA256},null,2)); diff --git a/packages/render/src/export.ts b/packages/render/src/export.ts index d2c9981..1defea2 100644 --- a/packages/render/src/export.ts +++ b/packages/render/src/export.ts @@ -2,6 +2,10 @@ // // SPDX-License-Identifier: LGPL-2.1-or-later +import {encodeWebP,type WebPCompression,type WebPCompressionReceipt} from './webp'; +export {encodeWebP,type WebPCompression,type WebPCompressionReceipt} from './webp'; +import {compressPNG,type PNGCompression,type PNGCompressionReceipt} from './png'; +export {compressPNG,type PNGCompression,type PNGCompressionReceipt} from './png'; import {layoutScenes, extractSceneSVGs, type SceneLayoutReceipt} from './scene-export'; import {getSmoothStepPath, Position} from '@xyflow/system'; import rough from 'roughjs'; @@ -21,14 +25,20 @@ export function attributionComment(attribution:ArtifactAttribution):string { return attribution.copyright.map(value=>'SPDX-FileCopyrightText: '+value).join('\n')+'\nSPDX-License-Identifier: '+attribution.license; } // REUSE-IgnoreEnd -export interface FigureExportOptions { readonly attribution?:ArtifactAttribution; readonly scale?: number; readonly regions?: readonly string[]; readonly minimumTextSize?:number; readonly transparent?:boolean; } +export interface FigureExportOptions { readonly attribution?:ArtifactAttribution; readonly scale?: number; readonly regions?: readonly string[]; readonly minimumTextSize?:number; readonly transparent?:boolean; readonly pngCompression?:PNGCompression|false; readonly webp?:WebPCompression|false; } export interface FigureImage { readonly png: Uint8Array; + readonly webp?:Uint8Array; + /** Smallest generated raster; the format always matches its bytes. */ + readonly preferredRaster:{readonly format:"png"|"webp";readonly mimeType:"image/png"|"image/webp";readonly bytes:Uint8Array}; readonly svg: readonly string[]; readonly regionImages: Readonly>; /** Fully materialized, offline HTML, including embedded fonts and measured sketch strokes. */ readonly html: string; readonly receipt: { + readonly webp?:{readonly encoding:WebPCompressionReceipt;readonly sha256:string}; + readonly preferredRaster:"png"|"webp"; + readonly pngCompression:{readonly figure:PNGCompressionReceipt;readonly regions:Readonly>}; readonly inputSHA256: string; readonly composedHTMLSHA256: string; readonly htmlSHA256: string; readonly pngSHA256: string; /** Effective SVG text size in logical CSS pixels, after scene scaling. */ readonly canvas:{readonly background:string;readonly transparent:boolean}; @@ -282,14 +292,20 @@ export async function exportFigure(figure: ComposedFigure, browser: Browser, opt const svg = await extractSceneSVGs(page,options.transparent??false,annotation); const renderedHTML = await page.content(); const html = annotation ? renderedHTML.replace(/^(]*>)/i,'$1\n\n') : renderedHTML; - const png = await page.locator('[data-figure]').screenshot({type: 'png', animations: 'disabled',omitBackground:options.transparent??false}); + const rawPNG = await page.locator('[data-figure]').screenshot({type: 'png', animations: 'disabled',omitBackground:options.transparent??false}); + const compressed=await compressPNG(rawPNG,options.pngCompression),png=compressed.bytes; + const webp=options.webp===false?undefined:await encodeWebP(rawPNG,options.webp); + const preferredRaster=webp&&webp.bytes.byteLength = {}; + const regionCompression:Record={}; for (const name of options.regions ?? []) { if (!/^[a-zA-Z0-9_-]+$/.test(name) || !(name in report.bounds)) throw new Error(`Unknown requested figure region: ${name}`); - regionImages[name] = await page.locator(`[data-region="${name}"]`).screenshot({type: 'png', animations: 'disabled',omitBackground:options.transparent??false}); + const rawRegion = await page.locator(`[data-region="${name}"]`).screenshot({type: 'png', animations: 'disabled',omitBackground:options.transparent??false}); + const region=await compressPNG(rawRegion,options.pngCompression); + regionImages[name]=region.bytes;regionCompression[name]=region.receipt; } const buffer = Buffer.from(png); - return {png, html, svg, regionImages, receipt: {attribution:options.attribution,observation:report.observation,canvas:{background:options.transparent?'transparent':report.canvasBackground,transparent:options.transparent??false},readability:{...report.readability,requiredMinimum:options.minimumTextSize},inputSHA256: figure.inputSHA256, scenes, svgSHA256:svg.map(svg=>digest(svg)), composedHTMLSHA256: figure.htmlSHA256, dependencies: packageInfo.dependencies, regionSHA256: Object.fromEntries(Object.entries(regionImages).map(([name, bytes]) => [name, digest(bytes)])), htmlSHA256: digest(html), drawing: figure.drawing.mode === "sketch" ? `roughjs@${packageInfo.dependencies.roughjs}` : "precise", routing: scenes.length ? `elkjs@${packageInfo.dependencies.elkjs}` : connectors.length ? `xyflow@${packageInfo.dependencies["@xyflow/system"]}` : "none", assetSHA256, pngSHA256: digest(png), browser: browser.version(), + return {png,webp:webp?.bytes,preferredRaster, html, svg, regionImages, receipt: {webp:webp?{encoding:webp.receipt,sha256:digest(webp.bytes)}:undefined,preferredRaster:preferredRaster.format,pngCompression:{figure:compressed.receipt,regions:regionCompression},attribution:options.attribution,observation:report.observation,canvas:{background:options.transparent?'transparent':report.canvasBackground,transparent:options.transparent??false},readability:{...report.readability,requiredMinimum:options.minimumTextSize},inputSHA256: figure.inputSHA256, scenes, svgSHA256:svg.map(svg=>digest(svg)), composedHTMLSHA256: figure.htmlSHA256, dependencies: packageInfo.dependencies, regionSHA256: Object.fromEntries(Object.entries(regionImages).map(([name, bytes]) => [name, digest(bytes)])), htmlSHA256: digest(html), drawing: figure.drawing.mode === "sketch" ? `roughjs@${packageInfo.dependencies.roughjs}` : "precise", routing: scenes.length ? `elkjs@${packageInfo.dependencies.elkjs}` : connectors.length ? `xyflow@${packageInfo.dependencies["@xyflow/system"]}` : "none", assetSHA256, pngSHA256: digest(png), browser: browser.version(), width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20), scale, bounds: report.bounds}}; } finally { await context.close(); } } diff --git a/packages/render/src/png.ts b/packages/render/src/png.ts new file mode 100644 index 0000000..1689787 --- /dev/null +++ b/packages/render/src/png.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors +// SPDX-License-Identifier: LGPL-2.1-or-later + +import sharp, {type PngOptions} from 'sharp'; +import * as v from 'valibot'; +import packageInfo from '../../../package.json' with {type:'json'}; +type LosslessPNG = Pick; +/** Quantization is explicit; defaults preserve decoded pixels. Native sharp owns encoding. */ +export type PNGCompression = LosslessPNG & ({readonly palette?:false}|({readonly palette:true}&Pick)); +const Options=v.strictObject({compressionLevel:v.optional(v.pipe(v.number(),v.integer(),v.minValue(0),v.maxValue(9))),adaptiveFiltering:v.optional(v.boolean()),palette:v.optional(v.boolean()),quality:v.optional(v.number()),colours:v.optional(v.number()),dither:v.optional(v.number()),effort:v.optional(v.number())}); +export interface PNGCompressionReceipt { + readonly encoder:string;readonly options:PNGCompression|false; + readonly inputBytes:number;readonly outputBytes:number;readonly selected:'original'|'compressed'; +} +/** CPU-only encoding effect; no file IO, global sharp settings or format substitution. */ +export async function compressPNG(input:Uint8Array,options:PNGCompression|false={}):Promise<{bytes:Uint8Array;receipt:PNGCompressionReceipt}>{ + const parsed=options===false?false:v.parse(Options,options); + if(parsed!==false&&!parsed.palette&&[parsed.quality,parsed.colours,parsed.dither,parsed.effort].some(value=>value!==undefined))throw new Error('Palette controls require palette: true'); + const settings:PNGCompression|false=parsed===false?false:{compressionLevel:9,adaptiveFiltering:true,...parsed} as PNGCompression; + const candidate=settings===false?input:await sharp(input).png(settings).toBuffer(); + const smaller=candidate.byteLength & ( + {readonly lossless?:true}|({readonly lossless:false}&Pick)); +const Options=v.strictObject({lossless:v.optional(v.boolean()),effort:v.optional(v.number()),quality:v.optional(v.number()),alphaQuality:v.optional(v.number()),smartSubsample:v.optional(v.boolean())}); +export interface WebPCompressionReceipt { + readonly encoder:string;readonly options:WebPCompression;readonly inputBytes:number;readonly outputBytes:number; +} +export async function encodeWebP(input:Uint8Array,options:WebPCompression={}):Promise<{bytes:Uint8Array;receipt:WebPCompressionReceipt}>{ + const parsed=v.parse(Options,options); + if(parsed.lossless!==false&&[parsed.quality,parsed.alphaQuality,parsed.smartSubsample].some(value=>value!==undefined))throw new Error('Lossy controls require lossless: false'); + const settings:WebPCompression={lossless:true,effort:4,...parsed} as WebPCompression; + const bytes=await sharp(input).webp(settings).toBuffer(); + return{bytes,receipt:{encoder:'sharp@'+packageInfo.dependencies.sharp,options:settings,inputBytes:input.byteLength,outputBytes:bytes.byteLength}}; +} diff --git a/packages/render/test/export.browser.ts b/packages/render/test/export.browser.ts index a27d817..7bf8b10 100644 --- a/packages/render/test/export.browser.ts +++ b/packages/render/test/export.browser.ts @@ -85,3 +85,37 @@ test('vertical connector sides are declared rather than forced into a horizontal expect(output.receipt.routing).toContain('xyflow@'); expect(output.html).toContain('data-source-position="bottom"'); await expect(exportFigure(figure('unknown'), browser)).rejects.toThrow('Invalid declared connector position'); }); + +test('PNG compression preserves real browser pixels and region hashes',async({browser})=>{ + const {default:sharp}=await import('sharp'); + const {digest}=await import('../src/source/catalog'); + const figure=composeFigure(input,normal),baseline=await exportFigure(figure,browser,{regions:['diagram'],transparent:true,pngCompression:false}); + const optimized=await exportFigure(figure,browser,{regions:['diagram'],transparent:true}); + for(const [before,after] of [[baseline.png,optimized.png],[baseline.regionImages.diagram!,optimized.regionImages.diagram!]] as const){ + const original=await sharp(before).ensureAlpha().raw().toBuffer({resolveWithObject:true}); + const compressed=await sharp(after).ensureAlpha().raw().toBuffer({resolveWithObject:true}); + expect(compressed).toEqual(original);expect(after.byteLength).toBeLessThanOrEqual(before.byteLength); + } + expect(optimized.receipt.pngSHA256).toBe(digest(optimized.png)); + expect(optimized.receipt.regionSHA256.diagram).toBe(digest(optimized.regionImages.diagram!)); + expect(optimized.receipt.pngCompression.figure.outputBytes).toBe(optimized.png.byteLength); + expect(optimized.receipt.pngCompression.regions.diagram!.outputBytes).toBe(optimized.regionImages.diagram!.byteLength); + const contexts=browser.contexts().length; + await expect(exportFigure(figure,browser,{pngCompression:{compressionLevel:10}})).rejects.toThrow(); + expect(browser.contexts().length).toBe(contexts); +}); + +test('preferred raster chooses actual smaller PNG/WebP with matching hashes and MIME',async({browser})=>{ + const {default:sharp}=await import('sharp'),{digest}=await import('../src/source/catalog'); + const figure=composeFigure(input,normal),image=await exportFigure(figure,browser); + expect(image.webp).toBeDefined(); + expect((await sharp(image.webp!).metadata()).format).toBe('webp'); + expect(await sharp(image.webp!).raw().toBuffer()).toEqual(await sharp(image.png).raw().toBuffer()); + expect(image.receipt.webp!.sha256).toBe(digest(image.webp!)); + const selected=image.webp!.byteLengthsharp(input).ensureAlpha().raw().toBuffer({resolveWithObject:true}); +test('lossless compression preserves RGBA pixels, dimensions and transparency',async()=>{ + const pixels=Buffer.alloc(512*256*4); + for(let i=0;i{ + const source=await sharp({create:{width:1,height:1,channels:4,background:'#ffffff00'}}).png({compressionLevel:9}).toBuffer(); + const result=await compressPNG(source,{compressionLevel:0}); + expect(result.bytes).toEqual(source);expect(result.receipt.selected).toBe('original'); + const bypass=await compressPNG(source,false);expect(bypass.bytes).toBe(source);expect(bypass.receipt.encoder).toBe('browser'); +}); +test('invalid controls, implicit quantization and corrupted PNG fail without fallback',async()=>{ + const source=await sharp({create:{width:4,height:4,channels:3,background:'white'}}).png().toBuffer(); + for(const options of [{compressionLevel:-1},{compressionLevel:10},{compressionLevel:1.5},{adaptiveFiltering:'yes'},{palette:'yes'},{quality:95}]){ + // Untyped downstream callers must not enable lossy quantization through this API. + await expect(compressPNG(source,options as never)).rejects.toThrow(); + } + await expect(compressPNG(new Uint8Array([1,2,3]))).rejects.toThrow(); +}); + +test('declared high-quality palette compression preserves canvas and alpha channel',async()=>{ + const source=await sharp({create:{width:512,height:256,channels:4,background:'#24608c80'}}).png({compressionLevel:0}).toBuffer(); + const result=await compressPNG(source,{palette:true,quality:95,effort:7}); + expect(result.bytes.byteLength).toBeLessThan(source.byteLength); + expect(result.receipt.options).toMatchObject({palette:true,quality:95,effort:7}); + const image=await sharp(result.bytes).metadata();expect(image.width).toBe(512);expect(image.height).toBe(256);expect(image.hasAlpha).toBe(true); +}); diff --git a/packages/render/test/webp.test.ts b/packages/render/test/webp.test.ts new file mode 100644 index 0000000..396b352 --- /dev/null +++ b/packages/render/test/webp.test.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors +// SPDX-License-Identifier: LGPL-2.1-or-later + +import {test,expect} from 'bun:test'; +import sharp from 'sharp'; +import {encodeWebP} from '../src/export'; +test('lossless WebP preserves real decoded colour, dimensions and partial alpha',async()=>{ + const input=await sharp({create:{width:320,height:160,channels:4,background:'#24608c80'}}).png().toBuffer(); + const encoded=await encodeWebP(input),decoded=await sharp(encoded.bytes).ensureAlpha().raw().toBuffer({resolveWithObject:true}); + const original=await sharp(input).ensureAlpha().raw().toBuffer({resolveWithObject:true}); + expect(decoded).toEqual(original);expect(encoded.receipt.options).toEqual({lossless:true,effort:4}); + expect((await sharp(encoded.bytes).metadata()).format).toBe('webp'); +}); +test('lossy WebP is explicit and native invalid settings/input refuse',async()=>{ + const input=await sharp({create:{width:320,height:160,channels:4,background:'white'}}).png().toBuffer(); + const encoded=await encodeWebP(input,{lossless:false,quality:95,smartSubsample:true}); + expect(encoded.receipt.options).toMatchObject({lossless:false,quality:95}); + for(const options of [{quality:95},{lossless:true,smartSubsample:true},{lossless:false,quality:101},{effort:99},{unknown:true}])await expect(encodeWebP(input,options as never)).rejects.toThrow(); + await expect(encodeWebP(new Uint8Array([1,2]))).rejects.toThrow(); +}); From 19e1e17af6e22df5bdf68af9abacdc18684dd147 Mon Sep 17 00:00:00 2001 From: guangtao Date: Thu, 5 Nov 2026 20:17:59 +0800 Subject: [PATCH 2/3] Reject foreign formats before retaining original PNG bytes --- packages/render/src/png.ts | 4 +++- packages/render/test/png.test.ts | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/render/src/png.ts b/packages/render/src/png.ts index 1689787..a6eb1c8 100644 --- a/packages/render/src/png.ts +++ b/packages/render/src/png.ts @@ -17,7 +17,9 @@ export async function compressPNG(input:Uint8Array,options:PNGCompression|false= const parsed=options===false?false:v.parse(Options,options); if(parsed!==false&&!parsed.palette&&[parsed.quality,parsed.colours,parsed.dither,parsed.effort].some(value=>value!==undefined))throw new Error('Palette controls require palette: true'); const settings:PNGCompression|false=parsed===false?false:{compressionLevel:9,adaptiveFiltering:true,...parsed} as PNGCompression; - const candidate=settings===false?input:await sharp(input).png(settings).toBuffer(); + const encoder=sharp(input); + if((await encoder.metadata()).format!=='png')throw new Error('Expected PNG input'); + const candidate=settings===false?input:await encoder.png(settings).toBuffer(); const smaller=candidate.byteLength{ From e38c868f0a7446e81b62a2d9a62b41dd046b4a2f Mon Sep 17 00:00:00 2001 From: guangtao Date: Thu, 5 Nov 2026 20:19:47 +0800 Subject: [PATCH 3/3] Compare actual raster formats without invalidating published overview sources --- .github/workflows/render.yml | 1 + package.json | 3 +- packages/render/README.org | 5 ++-- packages/render/examples/library-case.ts | 1 - packages/render/examples/overviews.ts | 1 - packages/render/examples/raster-comparison.ts | 29 +++++++++++++++++++ 6 files changed, 35 insertions(+), 5 deletions(-) create mode 100644 packages/render/examples/raster-comparison.ts diff --git a/.github/workflows/render.yml b/.github/workflows/render.yml index 2a0115d..29a5379 100644 --- a/.github/workflows/render.yml +++ b/.github/workflows/render.yml @@ -44,6 +44,7 @@ jobs: - run: bun run example:compare - run: bun run example:overviews - run: bun run example:design + - run: bun run example:images - name: Consume the reviewed Git package through Bun env: RENDER_REVISION: ${{ github.event.pull_request.head.sha || github.sha }} diff --git a/package.json b/package.json index 7fceb9b..12ed475 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "example:case": "bun packages/render/examples/library-case.ts", "example:compare": "bun packages/render/examples/comparison.ts", "example:overviews": "bun packages/render/examples/overviews.ts", - "example:design": "bun packages/render/examples/design-choices.ts" + "example:design": "bun packages/render/examples/design-choices.ts", + "example:images": "bun packages/render/examples/raster-comparison.ts" }, "dependencies": { "@fontsource/inter": "5.3.0", diff --git a/packages/render/README.org b/packages/render/README.org index 63b5a0c..3a21f9e 100644 --- a/packages/render/README.org +++ b/packages/render/README.org @@ -334,6 +334,7 @@ RGB beneath fully transparent pixels; alpha and visible content are preserved. SVG remains an independent vector deliverable for zoomable documentation. Its embedded fonts, standalone behaviour and editability differ from raster formats; it is not silently substituted into the raster interface based on size alone. -Maintained Case/overview exports write PNG plus WebP for comparison in the ignored -output directory. Hosts choose which artifact to publish, avoiding duplicate +=bun run example:images= writes PNG/WebP and source-bound size/fidelity observations +for actual repository figures under =.cache/render/raster-comparison/=. It also +reports existing SVG sizes separately. Hosts choose which artifact to publish, avoiding duplicate committed assets and retaining existing source/publication checks. diff --git a/packages/render/examples/library-case.ts b/packages/render/examples/library-case.ts index 47930b8..42e7c1c 100644 --- a/packages/render/examples/library-case.ts +++ b/packages/render/examples/library-case.ts @@ -55,7 +55,6 @@ try { const image = await exportFigure(figure, browser, {scale: 2, regions: [region],attribution:attributionFor(figure)}); for (const file of sourcePaths) if (await readFile(path.join(root, file), 'utf8') !== sources[file]) throw new Error(`Source changed during export: ${file}`); await writeFile(path.join(output, `${name}.html`), image.html); - if(image.webp)await writeFile(path.join(output, `${name}.webp`), image.webp); await writeFile(path.join(output, `${name}.png`), image.png); await writeFile(path.join(output, `${name}-detail.png`), image.regionImages[region]!); await writeFile(path.join(output, `${name}.json`), JSON.stringify({status: 'design explanation / source observations only', ...image.receipt, sources: figure.sourceSHA256, observations}, null, 2)); diff --git a/packages/render/examples/overviews.ts b/packages/render/examples/overviews.ts index 715b82a..d6aa058 100644 --- a/packages/render/examples/overviews.ts +++ b/packages/render/examples/overviews.ts @@ -28,7 +28,6 @@ try{for(const name of names){ if(minimumTextSize!==undefined&&typeof minimumTextSize!=='number')throw new Error('Invalid project minimumTextSize'); const result=await exportFigure(figure,browser,{scale:1.5,minimumTextSize,attribution:attributionFor(figure)}); for(const[file,source]of Object.entries(figure.sourceSHA256)) { const {createHash}=await import('node:crypto');if(createHash('sha256').update(await readFile(path.join(root,file))).digest('hex')!==source)throw new Error('Stale overview source: '+file); } - if(result.webp)await writeFile(path.join(output,outputName+'.webp'),result.webp); await writeFile(path.join(output,outputName+'.html'),result.html);await writeFile(path.join(output,outputName+'.png'),result.png); for(const[index,svg]of result.svg.entries())await writeFile(path.join(output,outputName+(index===0?'':`-scene-${index}`)+'.svg'),svg); await writeFile(path.join(output,outputName+'.json'),JSON.stringify({...result.receipt,sources:figure.sourceSHA256},null,2)); diff --git a/packages/render/examples/raster-comparison.ts b/packages/render/examples/raster-comparison.ts new file mode 100644 index 0000000..ecf37ea --- /dev/null +++ b/packages/render/examples/raster-comparison.ts @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: 2026 hardenedlinux Linux Team and Contributors +// SPDX-License-Identifier: LGPL-2.1-or-later + +import {readFile,readdir,mkdir,writeFile} from 'node:fs/promises'; +import {createHash} from 'node:crypto'; +import {fileURLToPath} from 'node:url'; +import {join} from 'node:path'; +import sharp from 'sharp'; +import {compressPNG,encodeWebP} from '@deterministic-engineering/render/export'; +const root=fileURLToPath(new URL('../../../',import.meta.url)); +const directory='docs/90-99-views/90-architecture-views',output=join(root,'.cache/render/raster-comparison'); +const hash=(bytes:Uint8Array)=>createHash('sha256').update(bytes).digest('hex'); +await mkdir(output,{recursive:true}); +const rows=[]; +for(const file of (await readdir(join(root,directory))).filter(file=>file.endsWith('.png')).sort()){ + const input=await readFile(join(root,directory,file)); + const pngStart=performance.now(),png=await compressPNG(input),pngMS=performance.now()-pngStart; + const webpStart=performance.now(),webp=await encodeWebP(input),webpMS=performance.now()-webpStart; + // Verify the actual decoded corpus rather than infer fidelity from size. + const raw=async(bytes:Uint8Array)=>sharp(bytes).ensureAlpha().raw().toBuffer(); + const original=await raw(input); + if(!original.equals(await raw(png.bytes))||!original.equals(await raw(webp.bytes)))throw new Error('Decoded corpus pixels differ: '+file); + await writeFile(join(output,file),png.bytes);await writeFile(join(output,file.replace(/\.png$/,'.webp')),webp.bytes); + const svgPath=join(root,directory,file.replace(/\.png$/,'.svg')); + const svg=await Bun.file(svgPath).exists()?await readFile(svgPath):undefined; + rows.push({source:directory+'/'+file,sourceSHA256:hash(input),originalBytes:input.length,png:{...png.receipt,sha256:hash(png.bytes),milliseconds:pngMS},webp:{...webp.receipt,sha256:hash(webp.bytes),milliseconds:webpMS},svg:svg?{bytes:svg.length,sha256:hash(svg)}:undefined,preferredRaster:webp.bytes.lengthn+r.originalBytes,0),pngBytes:rows.reduce((n,r)=>n+r.png.outputBytes,0),webpBytes:rows.reduce((n,r)=>n+r.webp.outputBytes,0),output}));