From 4b24c95100f06c97faf9b0bd06c8e20b43c92792 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:02:23 +0800 Subject: [PATCH 1/6] feat(ui): add bounded static SVG image and code previews Support archived SVG files and settled SVG/XML fences using a shared bounded sanitizer, lazy image presentation and caches. Preserve source access, sanitized downloads and raster-only model/avatar admission. This change is independent of ACP media publication and does not change the tool-result protocol. Generated-by: Codex --- .github/workflows/release-windows-check.yml | 1 + apps/desktop/artifact-budget.json | 1 + .../licenses/npm/THIRD_PARTY_NOTICES.txt | 17 ++ .../artifact-preview-registry.test.ts | 6 +- .../artifact-preview-registry-shell.tsx | 23 +- docs/architecture/svg-preview.zh-CN.md | 42 ++++ docs/astryx-surface-file-inventory.md | 6 +- docs/astryx-surface-file-inventory.paths | 2 + package-lock.json | 2 +- packages/cli/THIRD_PARTY_NOTICES.txt | 17 ++ packages/core/package.json | 4 +- .../core/src/__tests__/svg-preview.test.ts | 139 +++++++++++ packages/core/src/artifacts.ts | 17 +- packages/core/src/svg-preview.ts | 219 ++++++++++++++++++ .../src/__tests__/chat-image-delivery.test.ts | 2 +- .../runtime-host/src/assistant/bot/avatar.ts | 2 +- .../src/server/avatar-image-source.ts | 4 +- .../src/server/chat-image-source.ts | 9 +- .../runtime/src/__tests__/image-file.test.ts | 20 ++ .../runtime/src/filesystem-worker/protocol.ts | 2 +- packages/runtime/src/image-file.ts | 28 ++- packages/storage/src/artifact-store.ts | 18 +- .../ui/scripts/benchmark-session-media.mjs | 49 ++++ .../ui/scripts/fixtures/antigravity-core.mjs | 118 ++++++++++ .../scripts/markdown-images.browser.test.mjs | 141 ++++++++++- .../src/__tests__/attachment-image.test.tsx | 32 +++ .../ui/src/__tests__/markdown-body.test.ts | 56 ++++- packages/ui/src/artifact-preview-registry.ts | 4 +- packages/ui/src/attachment-image.tsx | 28 ++- packages/ui/src/index.ts | 4 + packages/ui/src/markdown-body.tsx | 137 ++++++----- packages/ui/src/markdown-image.tsx | 62 +---- packages/ui/src/media-image-source.ts | 52 +++++ packages/ui/src/session-media-image.tsx | 78 +++++++ packages/ui/src/shared-ui-copy.ts | 12 + packages/ui/src/svg-code-preview.tsx | 66 ++++++ 36 files changed, 1246 insertions(+), 174 deletions(-) create mode 100644 docs/architecture/svg-preview.zh-CN.md create mode 100644 packages/core/src/__tests__/svg-preview.test.ts create mode 100644 packages/core/src/svg-preview.ts create mode 100644 packages/ui/scripts/benchmark-session-media.mjs create mode 100644 packages/ui/scripts/fixtures/antigravity-core.mjs create mode 100644 packages/ui/src/media-image-source.ts create mode 100644 packages/ui/src/session-media-image.tsx create mode 100644 packages/ui/src/svg-code-preview.tsx diff --git a/.github/workflows/release-windows-check.yml b/.github/workflows/release-windows-check.yml index f6d426055c3..f842fcb4cc1 100644 --- a/.github/workflows/release-windows-check.yml +++ b/.github/workflows/release-windows-check.yml @@ -95,6 +95,7 @@ on: - 'packages/core/src/permission-profile-compiler.ts' - 'packages/core/src/sandbox-boundary.ts' - 'packages/core/src/serialized-byte-length.ts' + - 'packages/core/src/svg-preview.ts' - 'packages/core/src/windows-path.ts' - 'experiments/windows-sandbox/launcher/**' - 'experiments/windows-sandbox/*.ps1' diff --git a/apps/desktop/artifact-budget.json b/apps/desktop/artifact-budget.json index e3fb8839ea1..afa30390a0c 100644 --- a/apps/desktop/artifact-budget.json +++ b/apps/desktop/artifact-budget.json @@ -64,6 +64,7 @@ "node_modules/@vercel/oidc@3.2.0", "node_modules/@wecom/aibot-node-sdk@1.0.7", "node_modules/@workflow/serde@4.1.0", + "node_modules/@xmldom/xmldom@0.8.15", "node_modules/@xterm/addon-unicode11@0.9.0", "node_modules/@xterm/headless@6.0.0", "node_modules/agent-base@6.0.2", diff --git a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt index 276396fab5b..3af043a1ab3 100644 --- a/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt +++ b/apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt @@ -5948,6 +5948,23 @@ See the LICENSE file in the root of this repository. ================================================================================ +Package: @xmldom/xmldom@0.8.15 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/xmldom/xmldom.git + +--- LICENSE --- +Copyright 2019 - present Christopher J. Brody and other contributors, as listed in: https://github.com/xmldom/xmldom/graphs/contributors +Copyright 2012 - 2017 @jindw and other contributors, as listed in: https://github.com/jindw/xmldom/graphs/contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @xterm/addon-fit@0.11.0 Declared license: MIT Selected license: MIT diff --git a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts index 4ae9aa4b6ed..87f5e60ffd0 100644 --- a/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts +++ b/apps/desktop/src/main/__tests__/artifact-preview-registry.test.ts @@ -31,7 +31,7 @@ import { describe('artifact preview registry', () => { it('treats present MIME metadata as authoritative and rejects unsafe formats', () => { assert.deepEqual( - resolvePreviewKind({ name: 'tricky.png', kind: 'image', mimeType: 'image/svg+xml' }), + resolvePreviewKind({ name: 'tricky.png', kind: 'image', mimeType: 'text/html' }), { kind: 'unsupported', reason: 'mime_disallowed' }, ); }); @@ -77,11 +77,11 @@ describe('artifact preview registry', () => { { ok: true, base64: 'A'.repeat(3 * 1024 * 1024), - mimeType: 'image/svg+xml', + mimeType: 'text/html', }, 'oversize', ], - [{ ok: true, base64: 'AAAA', mimeType: 'image/svg+xml' }, 'mime_disallowed'], + [{ ok: true, base64: 'AAAA', mimeType: 'text/html' }, 'mime_disallowed'], ]; for (const [result, reason] of rejected) { const outcome = decideImageReadOutcome(result); diff --git a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx index bad400ef134..c5fa56d7143 100644 --- a/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx +++ b/apps/desktop/src/renderer/features/workbar/tools/artifacts/artifact-preview-registry-shell.tsx @@ -17,11 +17,11 @@ * under the License. */ -/** Renderer shell for capped raster previews and typed unsupported outcomes. */ +/** Renderer shell for bounded image previews and typed unsupported outcomes. */ import { useEffect, useState } from 'react'; import type { ArtifactDescriptor } from '@maka/core/artifacts'; -import { Button, useUiLocale } from '@maka/ui'; +import { Button, useUiLocale, mediaImageSource, MediaImageDisplay } from '@maka/ui'; import { Banner } from '@astryxdesign/core/Banner'; import { Spinner } from '@astryxdesign/core/Spinner'; import { @@ -152,7 +152,7 @@ export function UnsupportedArtifactPreview(props: { * - The post-load L2 decision (`decideImageReadOutcome`) runs * INSIDE the async, BEFORE `setState`. The hook's state union * therefore holds either a `loading` placeholder, an `image` - * branch carrying the verified base64, OR an `unsupported` + * branch carrying the verified image source, OR an `unsupported` * branch that carries NO base64. A 10MB oversize payload never * enters React state / DevTools snapshot. * - The component below is intentionally a thin switch over the @@ -190,10 +190,7 @@ function ImageArtifactPreview(props: { // result.state === 'image' — exhaustiveness check below. return (
- {props.record.name} +
); } @@ -216,9 +213,8 @@ function toPreviewInput(record: ArtifactDescriptor): ArtifactPreviewInput { * branch). That meant a 10MB oversize payload could land in React * state / DevTools before the L2 cap rejected it for rendering. The * fix replaces the broad union with this closed three-way one and - * runs the L2 decision INSIDE the async, so the only `base64` that - * ever reaches `setState` is one that has already passed - * `decideImageReadOutcome` (cap + MIME re-validation). + * runs the L2 decision INSIDE the async. The source passed to state + * has passed MIME/size validation and, for SVG, static preview conversion. * * Source/test gate (locked by `artifact-preview-registry.test.ts`): * the `unsupported` branch type signature MUST NOT include `base64`. @@ -228,7 +224,7 @@ function toPreviewInput(record: ArtifactDescriptor): ArtifactPreviewInput { */ type ImagePreviewLoadState = | { state: 'loading' } - | { state: 'image'; safeMime: string; base64: string } + | { state: 'image'; src: string; onError(): void } | { state: 'unsupported'; reason: Extract['reason']; @@ -254,7 +250,10 @@ function useImagePreviewLoad( // receives the post-decision shape. const outcome = decideImageReadOutcome(raw); if (outcome.kind === 'image') { - setState({ state: 'image', safeMime: outcome.safeMime, base64: outcome.base64 }); + const src = await mediaImageSource(raw); + if (cancelled) return; + if (!src) { setState({ state: 'unsupported', reason: 'read_failed' }); return; } + setState({ state: 'image', src, onError: () => setState({ state: 'unsupported', reason: 'read_failed' }) }); } else { setState({ state: 'unsupported', reason: outcome.reason }); } diff --git a/docs/architecture/svg-preview.zh-CN.md b/docs/architecture/svg-preview.zh-CN.md new file mode 100644 index 00000000000..16501aea655 --- /dev/null +++ b/docs/architecture/svg-preview.zh-CN.md @@ -0,0 +1,42 @@ + + +# SVG 静态预览 + +本变更补齐已有图片归档链路的 SVG 格式,以及 Markdown 中 SVG 代码块的显示,不修改 ACP 执行器或工具结果协议。原始文件保存为会话 Artifact,预览和下载使用处理后的静态 SVG。 + +## 显示策略 + +本地文件和下载内容共用 SVG 格式校验,原始文件保存为会话 Artifact。显示时从原始内容生成静态 SVG 预览,并通过图片元素加载,不注入应用 DOM。脚本、事件处理器、外部资源、动画、foreignObject、use 等不进入预览;允许基本几何、文本、渐变、受限行内样式和静态 class 样式规则。 + +完整的 `svg` 代码块,以及文档根元素为 `` 的 `xml` 代码块,在流式输出结束后提供预览、源码切换及静态预览下载。`xml` 的根元素候选检测不会加载解析器;真正显示前仍由共享 SVG 校验器验证完整文档。普通 XML 继续显示源码。代码块的原始源码由现有消息历史保存,下载按钮下载经过处理的 SVG。原文件仍保存在会话 Artifact 中,不改变现有产物列表的可见性规则。 + +显示支持与模型输入支持分开:SVG 不自动成为模型的视觉输入。现有 raster MIME 校验继续用于模型输入。 + +## 性能约束 + +- 普通文本不导入 SVG XML 解析器;前端解析模块使用动态 import。 +- 图片和 SVG 代码块在接近视口时才加载;流式 SVG 代码块只显示源码。 +- 单条正文自动预览最多 3 个 SVG,每个自动预览源码最多 64 Ki 字符,总计最多 128 Ki 字符,超出后显示显式预览按钮。预算在 Markdown 解析后的代码节点上按文档顺序计算,覆盖引用块和列表,`svg` 与 SVG 内容的 `xml` 共用预算;不改写消息源码。 +- SVG 最大 256 Ki 字符;在净化修改 DOM 之前,检查整棵输入树最多 4,000 个元素、8,000 个总节点、64 层元素深度,包括之后会被删除的元素、子树、注释和处理指令,避免逐个删除大量兄弟节点造成二次复杂度。总节点预算保留正常缩进空白的空间。静态 CSS 的注释和规则提取使用线性扫描,未闭合注释之后的内容不进入预览。 +- 会话图片读取合并并发请求;已读取图片缓存最多 32 项、8 Mi 字符,显式重试会重新读取。 +- SVG 转换缓存最多 16 项、1 Mi 字符,重复消费复用转换结果。 +## 局部性能基准 + +`node packages/ui/scripts/benchmark-session-media.mjs` 对比 Markdown 解析不带/带图表预算插件的成本。每种输入运行 7 轮、每轮 3,000 次,取中位数,覆盖普通正文、图片、代码、Mermaid、引用块 SVG 和 XML SVG。此基准不加载 XML 解析器、不生成图片,不代表真实长会话滚动帧率或应用整体启动性能。此前的源码预算扫描器已由 AST 插件替代,旧扫描器的性能数据不再代表当前实现。 diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index d61ef35c88e..650db7b4847 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.3` (195 component exports). Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding. -**Totals:** 347 files — blocker 0, reimplementation 0, polish 5, aligned 342. +**Totals:** 349 files — blocker 0, reimplementation 0, polish 5, aligned 344. ## Exclusions (explicit) @@ -327,7 +327,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/locale-context.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/maka-wordmark.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown-body.tsx` | ui-composition | CodeBlock, Link, Markdown | aligned — uses Astryx (CodeBlock, Link, Markdown) | aligned | -| `packages/ui/src/markdown-image.tsx` | ui-composition | Button, IconButton, Link, Spinner, Tooltip | aligned — uses Astryx (Button, IconButton, Link, Spinner, Tooltip) | aligned | +| `packages/ui/src/markdown-image.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown-math.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar) | aligned | @@ -358,6 +358,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/session-context-layer.tsx` | shell-chrome-or-panel | Button, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot, Text, Token, Tooltip | aligned — uses Astryx (Button, ButtonGroup, Icon, IconButton, LayoutHeader, MoreMenu, OverflowList, StatusDot) | aligned | | `packages/ui/src/session-history-list.tsx` | shell-chrome-or-panel | Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack | aligned — uses Astryx (Badge, Button, MoreMenu, SideNavItem, SideNavSection, StatusDot, VStack) | aligned | | `packages/ui/src/session-list-panel.tsx` | shell-chrome-or-panel | SegmentedControl, SegmentedControlItem, SideNav, Text | aligned — uses Astryx (SegmentedControl, SegmentedControlItem, SideNav, Text) | aligned | +| `packages/ui/src/session-media-image.tsx` | shell-chrome-or-panel | Button, IconButton, Link, Spinner, Tooltip | aligned — uses Astryx (Button, IconButton, Link, Spinner, Tooltip) | aligned | | `packages/ui/src/session-rail-context.tsx` | shell-chrome-or-panel | none | aligned — no raw controls; no Astryx JSX usage | aligned | | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | @@ -365,6 +366,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, Text, TextInput | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, Text) | aligned | | `packages/ui/src/status-label.tsx` | ui-composition | HStack, StatusDot, Text | aligned — uses Astryx (HStack, StatusDot, Text) | aligned | | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | +| `packages/ui/src/svg-code-preview.tsx` | ui-composition | Button, CodeBlock | aligned — uses Astryx (Button, CodeBlock) | aligned | | `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuItem, IconButton | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, IconButton) | aligned | | `packages/ui/src/toast.tsx` | ui-composition | AlertDialog, Button, HStack, LayerProvider, Text, VStack | aligned — uses Astryx (AlertDialog, Button, HStack, LayerProvider, Text, VStack) | aligned | | `packages/ui/src/tool-activity.tsx` | ui-composition | Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden | aligned — uses Astryx (Banner, Button, ChatToolCalls, List, ListItem, StatusDot, Text, VisuallyHidden) | aligned | diff --git a/docs/astryx-surface-file-inventory.paths b/docs/astryx-surface-file-inventory.paths index fcb5fc4b79f..1dda56fc6e7 100644 --- a/docs/astryx-surface-file-inventory.paths +++ b/docs/astryx-surface-file-inventory.paths @@ -327,6 +327,7 @@ packages/ui/src/search-modal.tsx packages/ui/src/session-context-layer.tsx packages/ui/src/session-history-list.tsx packages/ui/src/session-list-panel.tsx +packages/ui/src/session-media-image.tsx packages/ui/src/session-rail-context.tsx packages/ui/src/session-rename-dialog.tsx packages/ui/src/session-sidebar-nav.tsx @@ -334,6 +335,7 @@ packages/ui/src/skill-detail.tsx packages/ui/src/skills-panel.tsx packages/ui/src/status-label.tsx packages/ui/src/styles.css +packages/ui/src/svg-code-preview.tsx packages/ui/src/titlebar-session-identity.tsx packages/ui/src/toast.tsx packages/ui/src/tool-activity.tsx diff --git a/package-lock.json b/package-lock.json index 34867081cb9..17309f5c1e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6344,7 +6344,6 @@ "version": "0.8.15", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17439,6 +17438,7 @@ "version": "0.1.0", "license": "Apache-2.0", "dependencies": { + "@xmldom/xmldom": "^0.8.15", "intl-messageformat": "11.2.15", "marked": "18.0.11" } diff --git a/packages/cli/THIRD_PARTY_NOTICES.txt b/packages/cli/THIRD_PARTY_NOTICES.txt index 68eca268d26..6feca3aa7d2 100644 --- a/packages/cli/THIRD_PARTY_NOTICES.txt +++ b/packages/cli/THIRD_PARTY_NOTICES.txt @@ -2065,6 +2065,23 @@ See the LICENSE file in the root of this repository. ================================================================================ +Package: @xmldom/xmldom@0.8.15 +Declared license: MIT +Selected license: MIT +Repository: git://github.com/xmldom/xmldom.git + +--- LICENSE --- +Copyright 2019 - present Christopher J. Brody and other contributors, as listed in: https://github.com/xmldom/xmldom/graphs/contributors +Copyright 2012 - 2017 @jindw and other contributors, as listed in: https://github.com/jindw/xmldom/graphs/contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ + Package: @xterm/addon-unicode11@0.9.0 Declared license: MIT Selected license: MIT diff --git a/packages/core/package.json b/packages/core/package.json index 83b3ba5a835..aed37d6ea24 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -173,7 +173,8 @@ "./maka-wordmark": "./dist/maka-wordmark.js", "./test-only/async-primitives": "./dist/test-only/async-primitives.js", "./workhub-action-result": "./dist/workhub-action-result.js", - "./workhub-routing": "./dist/workhub-routing.js" + "./workhub-routing": "./dist/workhub-routing.js", + "./svg-preview": "./dist/svg-preview.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo src/model-metadata.generated.ts", @@ -184,6 +185,7 @@ "test:dist": "node --test \"dist/**/*.test.js\"" }, "dependencies": { + "@xmldom/xmldom": "^0.8.15", "intl-messageformat": "11.2.15", "marked": "18.0.11" } diff --git a/packages/core/src/__tests__/svg-preview.test.ts b/packages/core/src/__tests__/svg-preview.test.ts new file mode 100644 index 00000000000..fda2bcf5833 --- /dev/null +++ b/packages/core/src/__tests__/svg-preview.test.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { normalizeArtifactImagePreviewMime, normalizeMediaPreviewMime } from '../artifacts.js'; +import { prepareSvgPreview, SVG_PREVIEW_MAX_CHARS, SVG_PREVIEW_MAX_NODES } from '../svg-preview.js'; + +test('SVG display support does not expand model image admission', () => { + assert.equal(normalizeMediaPreviewMime('image/svg+xml'), 'image/svg+xml'); + assert.equal(normalizeMediaPreviewMime(undefined, 'drawing.svg'), 'image/svg+xml'); + assert.equal(normalizeArtifactImagePreviewMime('image/svg+xml'), null); +}); + +test('static SVG preserves geometry, text, gradients and safe inline styles', () => { + const source = + '你好 & Maka'; + const result = prepareSvgPreview(source); + assert.match(result, /linearGradient/); + assert.match(result, /url\(#paint\)/); + assert.match(result, /你好 & Maka/); + assert.match(result, /font-size:20px;fill:white/); + assert.match(prepareSvgPreview(''), /circle/); +}); + +test('SVG strips executable content, external dependencies and CSS escapes', () => { + const result = prepareSvgPreview( + '
html
', + ); + assert.doesNotMatch( + result, + /script|onload|foreignObject|https:| { + for (const source of [ + '', + ']>&x;', + '', + '', + '' + ''.repeat(66) + ''.repeat(66) + '', + '' + ''.repeat(4001) + '', + '' + ' '.repeat(SVG_PREVIEW_MAX_CHARS) + '', + ]) + assert.throws(() => prepareSvgPreview(source)); +}); + +test('static SVG class styles preserve generated diagram colors without external CSS', () => { + const result = prepareSvgPreview( + '', + ); + assert.match(result, /class="box"/); + assert.match(result, /\.box\{fill:#4355d9;stroke:white\}/); + assert.doesNotMatch(result, /https:|@import|background/); +}); + +test('static SVG styles preserve safe rules around comments and malformed rules', () => { + const result = prepareSvgPreview( + '', + ); + assert.match(result, /\.a\{fill:red\}/); + assert.match(result, /\.b\{stroke:blue\}/); + assert.match(result, /\.c\{fill:green\}/); + assert.doesNotMatch(result, /stray|outer|hidden|unterminated|leading/); +}); + +test('static SVG style parsing stays bounded for long malformed CSS', () => { + for (const css of ['x'.repeat(32000), '/*'.repeat(16000)]) { + const source = + '' + + ``.repeat(8) + + ''; + assert.ok(source.length < SVG_PREVIEW_MAX_CHARS); + const started = process.cpuUsage(); + const result = prepareSvgPreview(source); + const elapsed = process.cpuUsage(started); + // CPU time avoids wall-clock failures under load; the old scanner took seconds. + assert.ok( + elapsed.user + elapsed.system < 500_000, + 'Malformed CSS must not consume 500 ms of CPU', + ); + assert.doesNotMatch(result, /
- {resource.retry &&