clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / src / adapters / bpmn.ts
bpmn.ts 9.3K
1import { SaxesParser, type SaxesTagPlain } from 'saxes'2import { RenderError } from '../runtime/errors'3import type { EngineId, RendererAdapter, RenderRequest } from '../runtime/types'4import { edgeResult } from './types'56const VERSION = 'diagramzip-bpmn-svg@1'7const MAX_SOURCE = 512 * 10248const MAX_NODES = 20_0009const MAX_COORD = 32_76810const MAX_OUTPUT = 4 * 1024 * 10241112type Node = { name: string; attrs: Record<string, string>; text: string; children: Node[] }13type Box = { x: number; y: number; w: number; h: number }14type Shape = Box & { id: string; type: string; name: string; label?: Box }15type Edge = { id: string; type: string; points: { x: number; y: number }[]; name: string; label?: Box }1617const local = (name: string) => name.includes(':') ? name.slice(name.indexOf(':') + 1) : name18const esc = (value: string) => value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&apos;')19const attr = (node: Node, key: string) => node.attrs[key] ?? node.attrs[Object.keys(node.attrs).find(k => local(k) === key) ?? '']20const child = (node: Node, name: string) => node.children.find(value => local(value.name) === name)2122function fail(message: string, code = 'invalid_source'): never { throw new RenderError(422, code, message) }23function number(value: string | undefined, name: string): number {24 if (!value || !/^-?(?:\d+\.?\d*|\.\d+)$/.test(value.trim())) fail(`BPMN ${name} must be a finite number.`)25 const result = Number(value)26 if (!Number.isFinite(result) || Math.abs(result) > MAX_COORD) fail(`BPMN ${name} is outside the supported range.`)27 return result28}29function parseXml(source: string): Node {30 if (new TextEncoder().encode(source).byteLength > MAX_SOURCE) throw new RenderError(413, 'source_too_large', 'BPMN source may not exceed 512 KiB.')31 if (/<!DOCTYPE|<!ENTITY/i.test(source)) fail('BPMN source may not contain a doctype or entity declaration.')32 let root: Node | null = null; const stack: Node[] = []; let nodes = 0; let error: unknown33 const parser = new SaxesParser({ xmlns: false })34 parser.on('doctype', () => { error = new Error('DOCTYPE is not allowed') })35 parser.on('processinginstruction', () => { error = new Error('Processing instructions are not allowed') })36 parser.on('opentag', (tag: SaxesTagPlain) => {37 if (++nodes > MAX_NODES) { error = new Error('Too many XML nodes'); return }38 const attrs: Record<string, string> = {}39 for (const [key, value] of Object.entries(tag.attributes)) attrs[key] = typeof value === 'string' ? value : String((value as { value?: string }).value ?? '')40 const node: Node = { name: tag.name, attrs, text: '', children: [] }; const parent = stack.at(-1)41 if (parent) parent.children.push(node); else if (root) error = new Error('Multiple XML roots'); else root = node42 stack.push(node)43 })44 const append = (value: string) => { const node = stack.at(-1); if (node) node.text += value; else if (value.trim()) error = new Error('Text outside root') }45 parser.on('text', append); parser.on('cdata', append); parser.on('closetag', () => { stack.pop() }); parser.on('error', value => { error = value })46 try { parser.write(source).close() } catch (value) { error = value }47 if (error || !root) fail('BPMN source is not valid bounded XML.')48 return root49}5051function box(node: Node, allowEmpty = false): Box {52 const x = number(attr(node, 'x'), 'x coordinate'); const y = number(attr(node, 'y'), 'y coordinate')53 const w = number(attr(node, 'width'), 'width'); const h = number(attr(node, 'height'), 'height')54 if (allowEmpty ? w < 0 || h < 0 : w <= 0 || h <= 0) fail(`BPMN DI dimensions must be ${allowEmpty ? 'non-negative' : 'positive'}.`)55 return { x, y, w, h }56}5758export function parseBpmn(source: string): { shapes: Shape[]; edges: Edge[]; width: number; height: number } {59 const root = parseXml(source); if (local(root.name) !== 'definitions') fail('BPMN source must have a definitions root.')60 const semantic = new Map<string, { type: string; name: string }>(); const di = new Map<string, Node>(); const labels = new Map<string, Box>()61 const walk = (node: Node) => {62 const name = local(node.name); const id = attr(node, 'id')63 if (id && (name !== 'BPMNShape' && name !== 'BPMNEdge' && name !== 'BPMNLabel')) semantic.set(id, { type: name, name: attr(node, 'name') ?? '' })64 if (id && (name === 'BPMNShape' || name === 'BPMNEdge')) di.set(id, node)65 for (const value of node.children) walk(value)66 }67 // A second traversal keeps the DI parent id while remaining independent of XML prefixes.68 const collect = (node: Node, parentDi?: string) => {69 const name = local(node.name); const id = attr(node, 'id'); const element = attr(node, 'bpmnElement')70 if (id && (name === 'BPMNShape' || name === 'BPMNEdge')) di.set(id, node)71 if (name === 'BPMNLabel' && parentDi) { const b = child(node, 'Bounds'); if (b) labels.set(parentDi, box(b, true)) }72 const next = id && (name === 'BPMNShape' || name === 'BPMNEdge') ? id : parentDi73 for (const value of node.children) collect(value, next)74 }75 walk(root); collect(root)76 const shapes: Shape[] = []; const edges: Edge[] = []77 for (const [diId, node] of di) {78 const element = attr(node, 'bpmnElement'); if (!element) continue79 const sem = semantic.get(element); if (!sem) continue80 if (local(node.name) === 'BPMNShape') shapes.push({ ...box(child(node, 'Bounds') ?? fail(`BPMN shape ${diId} has no bounds.`)), id: element, type: sem.type, name: sem.name, label: labels.get(diId) })81 else {82 const points = node.children.filter(value => local(value.name) === 'waypoint').map(value => ({ x: number(attr(value, 'x'), 'waypoint x'), y: number(attr(value, 'y'), 'waypoint y') }))83 if (points.length < 2) fail(`BPMN edge ${diId} needs at least two waypoints.`)84 edges.push({ id: element, type: sem.type, points, name: sem.name, label: labels.get(diId) })85 }86 }87 if (!shapes.length) fail('BPMN diagram contains no rendered shapes.')88 const maxX = Math.max(...shapes.map(s => s.x + s.w), ...edges.flatMap(e => e.points.map(p => p.x)), 1)89 const maxY = Math.max(...shapes.map(s => s.y + s.h), ...edges.flatMap(e => e.points.map(p => p.y)), 1)90 return { shapes, edges, width: maxX + 20, height: maxY + 20 }91}9293function label(value: string, b: Box, anchor = 'middle'): string {94 if (!value) return ''95 const lines = value.split(/\r?\n/).slice(0, 8); const x = anchor === 'start' ? b.x + 4 : b.x + b.w / 296 return `<text x="${x}" y="${b.y + 14}" text-anchor="${anchor}" font-family="Arial,sans-serif" font-size="12" fill="#111827">${lines.map((line, i) => `<tspan x="${x}" dy="${i ? 14 : 0}">${esc(line.slice(0, 240))}</tspan>`).join('')}</text>`97}98function shapeSvg(shape: Shape): string {99 const { x, y, w, h } = shape; const t = shape.type.toLowerCase(); const stroke = '#334155'; const fill = '#fff'100 let body: string101 if (t.includes('event')) body = `<circle cx="${x + w / 2}" cy="${y + h / 2}" r="${Math.min(w, h) / 2 - 2}" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`102 else if (t.includes('gateway')) body = `<path d="M${x + w / 2} ${y}L${x + w} ${y + h / 2}L${x + w / 2} ${y + h}L${x} ${y + h / 2}Z" fill="${fill}" stroke="${stroke}" stroke-width="2"/>`103 else if (t === 'participant' || t === 'lane') body = `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="#f8fafc" stroke="${stroke}"/><path d="M${x + 28} ${y}V${y + h}" stroke="${stroke}"/>`104 else if (t.includes('dataobject')) body = `<path d="M${x} ${y}H${x + w - 12}L${x + w} ${y + 12}V${y + h}H${x}Z" fill="${fill}" stroke="${stroke}"/><path d="M${x + w - 12} ${y}V${y + 12}H${x + w}" fill="none" stroke="${stroke}"/>`105 else body = `<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${t.includes('subprocess') ? 2 : 8}" fill="${fill}" stroke="${stroke}"${t.includes('subprocess') ? ' stroke-dasharray="6 4"' : ''}/>`106 const b = shape.label ?? { x, y: y + h / 2 - 10, w, h: 20 }107 return `<g data-bpmn-id="${esc(shape.id)}">${body}${label(shape.name, b, shape.type.toLowerCase() === 'participant' || shape.type.toLowerCase() === 'lane' ? 'start' : 'middle')}</g>`108}109export function renderBpmn(source: string): string {110 const diagram = parseBpmn(source); const defs = '<defs><marker id="bpmn-arrow" markerWidth="10" markerHeight="10" refX="9" refY="4" orient="auto"><path d="M0 0L9 4L0 8Z" fill="#334155"/></marker></defs>'111 const edges = diagram.edges.map(edge => { const first = edge.points[0]!; return `<g data-bpmn-id="${esc(edge.id)}"><polyline points="${edge.points.map(p => `${p.x},${p.y}`).join(' ')}" fill="none" stroke="#334155" stroke-width="1.5" marker-end="url(#bpmn-arrow)"${edge.type.toLowerCase() === 'association' ? ' stroke-dasharray="5 4"' : ''}/>${label(edge.name, edge.label ?? { x: first.x, y: first.y, w: 80, h: 20 })}</g>` }).join('')112 const body = `<svg xmlns="http://www.w3.org/2000/svg" width="${diagram.width}" height="${diagram.height}" viewBox="0 0 ${diagram.width} ${diagram.height}">${defs}${edges}${diagram.shapes.map(shapeSvg).join('')}</svg>`113 if (body.length > MAX_OUTPUT) throw new RenderError(413, 'output_too_large', 'BPMN SVG output exceeds 4 MiB.')114 return body115}116117export const bpmnAdapter: RendererAdapter = {118 id: 'bpmn' as EngineId, runtime: 'edge-js', version: VERSION,119 render(request: RenderRequest, signal: AbortSignal) {120 if (signal.aborted) throw signal.reason121 return Promise.resolve(edgeResult('bpmn' as EngineId, VERSION, renderBpmn(request.source)))122 },123}