/** * 旧CanvasDocument schema v2的扩展字段校验与兼容类型入口。 * 处理边样式/贝塞尔链、AI配置、素材ID、子图端口和分区展示;版本2并非新版canonical documentFormatVersion。 */ import type { Edge, XYPosition } from "@xyflow/react"; import type {EdgeData,BezierKnot,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types"; export type {MarkerShape,EdgeMarker,BezierKnot,EdgeData,AIConfig,FramePort,FramePresentation} from "./canvas-bindings/compat-types"; /** 旧连线使用React Flow Edge外壳,持久data限制为兼容EdgeData。 */ export type CanvasEdgeModel = Edge; /** 请求旧快照接口使用的媒体类型,避免服务端用默认旧表示丢掉v2扩展字段。 */ export const CANVAS_ACCEPT = "application/vnd.foundry.canvas+json;version=2"; /** 旧节点/端口ID的短字符串语法,独立于服务端board/resource使用的UUID。 */ export const wireID = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9_-]{1,128}$/.test(value); /** 包含端点的有限数值范围检查,拒绝NaN与Infinity。 */ export const numberIn = (value: unknown, min: number, max: number): value is number => typeof value === "number" && Number.isFinite(value) && value >= min && value <= max; /** 位置/控制向量各轴限定在±100万,调用路径决定它表示绝对点还是向量。 */ export const pointValid = (value: unknown): value is XYPosition => !!value && typeof value === "object" && numberIn((value as XYPosition).x, -1e6, 1e6) && numberIn((value as XYPosition).y, -1e6, 1e6); /** 只允许已知自有键的对象外壳;必填字段与每字段值需由调用处另外校验。 */ export function fields(value: unknown, keys: string[]): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).every((key) => keys.includes(key)); } /** 所有v2校验失败统一抛invalid_canvas,不返回半份有效扩展。 */ function check(value: boolean) { if (!value) throw new Error("invalid_canvas"); } /** 字段缺失可通过,字段存在时必须满足校验;存在但undefined不等同于缺失。 */ function optional(object: Record, key: string, valid: (value: unknown) => boolean) { check(!(key in object) || valid(object[key])); } /** 构造封闭枚举校验器,不接受任意扩展字符串。 */ const choice = (...values: unknown[]) => (value: unknown) => values.includes(value); /** 颜色仅接六位十六进制,不能把任意CSS表达式带入旧边样式。 */ const color = (value: unknown) => typeof value === "string" && /^#[a-f\d]{6}$/i.test(value); /** 可选开关仍需真实boolean,不把字符串false当作关闭。 */ const bool = (value: unknown) => typeof value === "boolean"; /** 验证已知边扩展并structuredClone,与输入断开引用;undefined保留为无扩展。 */ export function decodeEdgeData(value: unknown): EdgeData | undefined { if (value === undefined) return undefined; check(fields(value, ["appearance", "animation", "path", "execution"])); const data = value as Record; // 限制线宽/透明度/箭头形状尺寸;样式不是直接透传任意SVG属性。 if (data.appearance !== undefined) { check(fields(data.appearance, ["color", "width", "opacity", "line", "head", "tail"])); const a = data.appearance as Record; optional(a, "color", color); optional(a, "width", (v) => numberIn(v, .5, 20)); optional(a, "opacity", (v) => numberIn(v, .05, 1)); optional(a, "line", choice("solid", "dashed", "dotted")); for (const key of ["head", "tail"]) if (a[key] !== undefined) { check(fields(a[key], ["type", "size", "filled"])); const marker = a[key] as Record; check(choice("none", "open-arrow", "triangle", "circle", "diamond", "square")(marker.type)); optional(marker, "size", (v) => numberIn(v, 4, 32)); optional(marker, "filled", bool); } } if (data.animation !== undefined) { check(fields(data.animation, ["mode", "effect", "direction", "durationMs", "color"])); const a = data.animation as Record; optional(a, "mode", choice("off", "always", "executing")); optional(a, "effect", choice("flow", "dash", "pulse")); optional(a, "direction", choice("forward", "reverse")); optional(a, "durationMs", (v) => numberIn(v, 250, 30000)); optional(a, "color", color); } if (data.execution !== undefined) { check(fields(data.execution, ["enabled"])); optional(data.execution as Record, "enabled", bool); } // 贝塞尔链最多64个结点,身份唯一、各点有界;不只检查path.type就信任控制数据。 if (data.path !== undefined) { check(fields(data.path, ["type", "startControl", "endControl", "knots"])); const path = data.path as Record; check(path.type === "bezier-chain" && Array.isArray(path.knots) && path.knots.length <= 64); optional(path, "startControl", pointValid); optional(path, "endControl", pointValid); const ids = new Set(); for (const knot of path.knots as BezierKnot[]) { check(fields(knot, ["id", "position", "in", "out", "mode"]) && wireID(knot.id) && !ids.has(knot.id) && pointValid(knot.position) && pointValid(knot.in) && pointValid(knot.out) && choice("corner", "smooth", "symmetric")(knot.mode)); ids.add(knot.id); // smooth要求切线反向共线,symmetric还要求两侧等长,允许零长度向量的退化情形。 if (knot.mode !== "corner") { const a = Math.hypot(knot.in.x, knot.in.y), b = Math.hypot(knot.out.x, knot.out.y); if (a && b) check(Math.abs(knot.in.x / a * knot.out.y / b - knot.in.y / a * knot.out.x / b) <= 1e-6 && knot.in.x / a * knot.out.x / b + knot.in.y / a * knot.out.y / b <= 0); if (knot.mode === "symmetric") check(Math.abs(a - b) <= 1e-6 * Math.max(a, b)); } } } return structuredClone(value) as EdgeData; } /** 仅schema2允许这些扩展,且能力受kind限制:图片资源ID、可生成节点AI、frame子图/端口/展示。 */ export function decodeNodeExtensions(data: Record, schema: number) { const extensions: { ai?: AIConfig; assetId?: string; subgraph?: { boardId: string }; ports?: FramePort[]; presentation?: FramePresentation } = {}; for (const key of ["ai", "assetId", "subgraph", "ports", "presentation"]) if (key in data) check(schema === 2); if (data.assetId !== undefined) { check(["image", "plain-image"].includes(String(data.kind)) && typeof data.assetId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.assetId)); extensions.assetId = data.assetId as string; } // AI提示按Unicode码点计长,parameters限制JSON序列化字节;这是配置,不是生成任务受理记录。 if (data.ai !== undefined) { check(["text", "script", "plain-text", "image", "plain-image"].includes(String(data.kind)) && fields(data.ai, ["enabled", "modelId", "prompt", "propagate", "parameters"])); const ai = data.ai as Record; optional(ai, "enabled", bool); optional(ai, "propagate", bool); optional(ai, "modelId", (v) => typeof v === "string" && v.length > 0 && v.length <= 256 && v.trim() === v); optional(ai, "prompt", (v) => typeof v === "string" && [...v].length <= 16000); optional(ai, "parameters", (v) => !!v && typeof v === "object" && !Array.isArray(v) && new TextEncoder().encode(JSON.stringify(v)).length <= 16384); extensions.ai = structuredClone(ai) as AIConfig; } if (["subgraph", "ports", "presentation"].some((key) => key in data)) check(data.kind === "frame"); if (data.subgraph !== undefined) { check(fields(data.subgraph, ["boardId"]) && typeof data.subgraph.boardId === "string" && /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i.test(data.subgraph.boardId)); extensions.subgraph = { boardId: (data.subgraph as { boardId: string }).boardId }; } // 分区端口限定方向、唯一短ID和内部node/handle引用形状;跨board引用是否存在由树/事务流程校验。 if (data.ports !== undefined) { check(Array.isArray(data.ports) && data.ports.length <= 4000); const ids = new Set(); for (const port of data.ports as FramePort[]) { check(fields(port, ["id", "direction", "nodeId", "handle"]) && wireID(port.id) && !ids.has(port.id) && wireID(port.nodeId) && choice("input", "output")(port.direction) && typeof port.handle === "string" && (/^(top|right|bottom|left)$/.test(port.handle) || /^port:[A-Za-z0-9_-]{1,128}$/.test(port.handle))); ids.add(port.id); } extensions.ports = structuredClone(data.ports) as FramePort[]; } // 折叠状态、内容比例和展开/折叠尺寸分别验证;不据此自动创建或迁移子图。 if (data.presentation !== undefined) { check(fields(data.presentation, ["collapsed", "contentScale", "expandedSize", "collapsedSize"])); const p = data.presentation as Record; optional(p, "collapsed", bool); optional(p, "contentScale", (v) => numberIn(v, .05, 4)); for (const key of ["expandedSize", "collapsedSize"]) optional(p, key, (v) => fields(v, ["width", "height"]) && numberIn(v.width, 32, 100000) && numberIn(v.height, 32, 100000)); extensions.presentation = structuredClone(p) as FramePresentation; } return extensions; } /** 只把固定Foundry项目的规范素材路径映射为同源代理,可选preview; * 内联、demo或其它地址原样返回,不把任意HTTPS资源都伪装成受管资源。 */ export function managedImageSource(src?: string, preview = false) { if (!src) return src; try { const url = new URL(src); const match = url.pathname.match(/^\/projects\/endoleap\/(workspaces\/[A-Za-z0-9_-]+\/assets\/[A-Za-z0-9_-]+)$/); if (url.origin === "https://api.foundry.entroia.com" && match) return "/api/foundry/" + match[1] + (preview ? "?variant=preview" : ""); } catch { /* inline and demo images are already browser-readable */ } return src; }