-
{lang || "text"}
-
- {headerAction}
-
);
}
From a2104628c318c337b44c1776dd926139166e956f Mon Sep 17 00:00:00 2001
From: mengnanxyyyy <308822704+mengnanxyyyy@users.noreply.github.com>
Date: Mon, 3 Aug 2026 01:41:55 +0800
Subject: [PATCH 6/6] =?UTF-8?q?fix:=20Mermaid=20=E6=9F=A5=E7=9C=8B?=
=?UTF-8?q?=E5=99=A8=E5=AF=B9=E5=90=AB=20
=20=E6=A0=87=E7=AD=BE?=
=?UTF-8?q?=E7=9A=84=E5=9B=BE=E8=A1=A8=E9=AB=98=E5=BA=A6=E9=94=99=E4=B9=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 根因: mermaid 将多行节点标签输出为裸
(HTML 语法, XML 非法),
DOMParser 的 image/svg+xml 严格解析失败, 回退 800×600 死尺寸,
导致查看器高度虚高、图表下方大片空白
- 修复三层防护:
1. 预处理将
归一化为
, 使 SVG 成为严格合法 XML
2. DOMParser 改用 text/html 模式解析(容忍 HTML 风格内容)
3. 新增正则兜底 prepareSvgFallback: 无 DOMParser 或解析失败时
从 viewBox/width/height 提取真实尺寸, 并清理固定尺寸约束
- 新增 6 个测试覆盖: 正则兜底尺寸提取、
归一化、HTML 解析路径、
解析失败回退
---
components/ZoomPanViewer.test.mjs | 123 ++++++++++++++++++++++++++++++
components/ZoomPanViewer.tsx | 65 ++++++++++++++--
2 files changed, 181 insertions(+), 7 deletions(-)
create mode 100644 components/ZoomPanViewer.test.mjs
diff --git a/components/ZoomPanViewer.test.mjs b/components/ZoomPanViewer.test.mjs
new file mode 100644
index 000000000..b030b429d
--- /dev/null
+++ b/components/ZoomPanViewer.test.mjs
@@ -0,0 +1,123 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { createJiti } from "jiti";
+
+const jiti = createJiti(import.meta.url, {
+ jsx: { runtime: "automatic" },
+ tsconfigPaths: true,
+});
+const { prepareSvgForZoomPan } = await jiti.import("./ZoomPanViewer.tsx");
+
+// Realistic mermaid v11 output: viewBox + width="100%" + max-width style,
+// with
inside a foreignObject label (XML-invalid, HTML-valid).
+const MERMAID_SVG = `
`;
+
+test("regex fallback extracts viewBox size and strips fixed-size constraints", () => {
+ const r = prepareSvgForZoomPan(MERMAID_SVG);
+
+ assert.equal(r.width, 1027.65625);
+ assert.equal(r.height, 350);
+ assert.doesNotMatch(r.html, /max-width/);
+ assert.doesNotMatch(r.html, /width="100%"/);
+ assert.match(r.html, /viewBox="0 0 1027\.65625 350"/);
+});
+
+test("bare
is normalized to
so the SVG is well-formed XML", () => {
+ const r = prepareSvgForZoomPan(MERMAID_SVG);
+
+ assert.doesNotMatch(r.html, /
访谈/);
+});
+
+test("regex fallback reads width/height attributes when viewBox is missing", () => {
+ const svg = `
`;
+ const r = prepareSvgForZoomPan(svg);
+
+ assert.equal(r.width, 640);
+ assert.equal(r.height, 480);
+});
+
+test("regex fallback returns 800x600 when no size info exists", () => {
+ const r = prepareSvgForZoomPan("
");
+
+ assert.equal(r.width, 800);
+ assert.equal(r.height, 600);
+});
+
+test("DOMParser path parses with text/html (tolerates
) and cleans the svg", () => {
+ class FakeEl {
+ constructor(attrs) {
+ this.attrs = { ...attrs };
+ this.style = { maxWidth: attrs._styleMaxWidth ?? "" };
+ delete this.attrs._styleMaxWidth;
+ }
+ getAttribute(name) {
+ return name in this.attrs ? this.attrs[name] : null;
+ }
+ setAttribute(name, value) {
+ this.attrs[name] = value;
+ }
+ removeAttribute(name) {
+ delete this.attrs[name];
+ }
+ get outerHTML() {
+ const attrs = Object.entries(this.attrs)
+ .map(([k, v]) => ` ${k}="${v}"`)
+ .join("");
+ const style = this.style.maxWidth ? ` style="max-width:${this.style.maxWidth}"` : "";
+ return `
`;
+ }
+ }
+ class FakeDOMParser {
+ static lastMime = null;
+ parseFromString(str, mime) {
+ FakeDOMParser.lastMime = mime;
+ const match = /