diff --git a/src/whiteboard/shape-paths.ts b/src/whiteboard/shape-paths.ts
index 03c557f..cbb4817 100644
--- a/src/whiteboard/shape-paths.ts
+++ b/src/whiteboard/shape-paths.ts
@@ -1,5 +1,5 @@
import type { WNode } from './types.js';
-import { BORDER_WIDTH_MAP, r } from './utils.js';
+import { BORDER_WIDTH_MAP, r, withoutStrokeAttrs } from './utils.js';
// ─── Shape Path Registry ────────────────────────────────────────────────────
@@ -87,9 +87,12 @@ registerShapePath(['trapezoid', 'flow_chart_trapezoid'], (w, h, sa) => {
registerShapePath(['cylinder', 'flow_chart_cylinder', 'data_base'], (w, h, sa, n) => {
const ry = h * 0.1;
+ // 圆柱体侧面用 line 描边,中间矩形不能再继承 sa 里的 stroke,
+ // 否则会出现 stroke="..." stroke="none" 的非法重复属性。
+ const bodySa = withoutStrokeAttrs(sa);
return [
``,
- ``,
+ ``,
``,
``,
``,
diff --git a/src/whiteboard/utils.ts b/src/whiteboard/utils.ts
index 03c0fae..9e7e97d 100644
--- a/src/whiteboard/utils.ts
+++ b/src/whiteboard/utils.ts
@@ -179,6 +179,14 @@ export function styleAttrs (s?: WStyle): string {
return parts.join(' ');
}
+/** 从 styleAttrs 结果中剥离 stroke 相关属性,便于再单独指定 stroke */
+export function withoutStrokeAttrs (sa: string): string {
+ return sa
+ .replace(/\bstroke(?:-width|-opacity|-dasharray)?="[^"]*"/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
export function transformAttr (n: WNode): string {
const parts: string[] = [];
if (n.x != null || n.y != null) parts.push(`translate(${r(n.x ?? 0)},${r(n.y ?? 0)})`);
diff --git a/tests/whiteboard/shape-paths.test.ts b/tests/whiteboard/shape-paths.test.ts
index d63fa6a..96d1dee 100644
--- a/tests/whiteboard/shape-paths.test.ts
+++ b/tests/whiteboard/shape-paths.test.ts
@@ -83,6 +83,18 @@ describe('shape-paths: composite shapes with multiple elements', () => {
expect(out).toContain('stroke="#444"');
});
+ it('cylinder body rect does not redefine stroke attribute', () => {
+ const fn = getShapePath('cylinder')!;
+ const sa = 'fill="#dff5e5" stroke="#000000" stroke-width="2"';
+ const out = fn(100, 80, sa, blankNode({ style: { border_color: '#000000' } as any }));
+ const body = out.split('\n').find((line) => line.includes(' {
const fn = getShapePath('cylinder')!;
const out = fn(100, 80, SA, blankNode());
diff --git a/tests/whiteboard/utils.test.ts b/tests/whiteboard/utils.test.ts
index b1f5623..90eb9a2 100644
--- a/tests/whiteboard/utils.test.ts
+++ b/tests/whiteboard/utils.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
- r, esc, styleAttrs, transformAttr, walkNodes, filterNodes, gWrap, nodeDataAttrs,
+ r, esc, styleAttrs, withoutStrokeAttrs, transformAttr, walkNodes, filterNodes, gWrap, nodeDataAttrs,
BORDER_WIDTH_MAP, DASH_ARRAY_MAP,
} from '../../src/whiteboard/utils.js';
@@ -78,6 +78,21 @@ describe('styleAttrs', () => {
});
});
+describe('withoutStrokeAttrs', () => {
+ it('strips stroke and related attributes while keeping fill', () => {
+ expect(withoutStrokeAttrs('fill="#eee" stroke="#333"')).toBe('fill="#eee"');
+ expect(
+ withoutStrokeAttrs('fill="#dff5e5" stroke="#000" stroke-width="2" stroke-dasharray="8,4"'),
+ ).toBe('fill="#dff5e5"');
+ });
+
+ it('keeps fill-opacity', () => {
+ expect(withoutStrokeAttrs('fill="#f00" fill-opacity="0.5" stroke="#333"')).toBe(
+ 'fill="#f00" fill-opacity="0.5"',
+ );
+ });
+});
+
// ─── transformAttr ──────────────────────────────────────────────────────────
describe('transformAttr', () => {