clone

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

main
diagramzip / renderers / edge / src / languages / umlet.ts
umlet.ts 21.2K
1import { SaxesParser, type SaxesTagPlain } from 'saxes'2import { RenderError } from '../runtime/errors'34const MAX_SOURCE_BYTES = 262_1445const MAX_ELEMENTS = 1_0006const MAX_XML_NODES = 12_0007const MAX_ELEMENT_TEXT = 32_7688const MAX_TOTAL_TEXT = 262_1449const MAX_COORDINATE = 8_19210const MAX_OUTPUT_LENGTH = 4_194_30411const MARGIN = 201213interface XmlNode {14 name: string15 text: string16 children: XmlNode[]17}1819interface UmletElement {20 rawType: string21 kind: string22 x: number23 y: number24 width: number25 height: number26 panel: string27 additional: string28}2930interface Panel {31 controls: Map<string, string>32 lines: string[]33}3435const LEGACY_TYPES = new Map<string, string>([36 ['com.umlet.element.base.Relation', 'Relation'],37 ['com.umlet.element.base.Class', 'UMLClass'],38 ['com.umlet.element.base.UseCase', 'UMLUseCase'],39 ['com.umlet.element.base.Interface', 'UMLInterface'],40 ['com.umlet.element.base.Actor', 'UMLActor'],41 ['com.umlet.element.base.State', 'UMLState'],42 ['com.umlet.element.base.Object', 'UMLObject'],43 ['com.umlet.element.base.Timer', 'UMLTimer'],44 ['com.umlet.element.base.SpecialState', 'UMLSpecialState'],45 ['com.umlet.element.base.Note', 'UMLNote'],46 ['com.umlet.element.base.Package', 'UMLPackage'],47 ['com.umlet.element.base.Frame', 'UMLFrame'],48 ['com.umlet.element.base.Deployment', 'UMLDeployment'],49 ['com.umlet.element.base.Generic', 'UMLGeneric'],50 ['com.umlet.element.base.Hierarchy', 'UMLHierarchy'],51 ['com.umlet.element.base.Text', 'Text'],52 ['com.umlet.element.base.SequenceAllInOne', 'UMLSequenceAllInOne'],53 ['com.umlet.element.custom.State', 'CustomState'],54 ['com.umlet.element.custom.FinalState', 'CustomFinalState'],55 ['com.umlet.element.custom.InitialState', 'CustomInitialState'],56 ['com.umlet.element.custom.HistoryState', 'CustomHistoryState'],57 ['com.umlet.element.custom.ThreeWayRelation', 'CustomThreeWayRelation'],58])5960const STANDARD_IDS = new Set([61 'Relation', 'UMLClass', 'UMLUseCase', 'UMLInterface', 'UMLActor', 'UMLState',62 'UMLObject', 'UMLTimer', 'UMLSpecialState', 'UMLNote', 'UMLSyncBarHorizontal',63 'UMLSyncBarVertical', 'UMLPackage', 'UMLFrame', 'UMLDeployment', 'UMLGeneric',64 'UMLHierarchy', 'Text', 'PlotGrid', 'UMLSequenceAllInOne',65])6667const CONTROL_KEYS = new Set([68 'bg', 'fg', 'fontsize', 'halign', 'valign', 'lt', 'type', 'style', 'layer',69 'group', 'transparentselection',70])7172function fail(code: string, message: string): never {73 throw new RenderError(422, code, message)74}7576function child(node: XmlNode, name: string): XmlNode | undefined {77 return node.children.find(value => value.name.toLowerCase() === name)78}7980function content(node: XmlNode | undefined): string {81 if (!node) return ''82 return `${node.text}${node.children.map(content).join('')}`83}8485function integer(value: string, name: string): number {86 if (!/^-?\d+$/.test(value.trim())) fail('invalid_source', `UMLet ${name} must be an integer.`)87 const parsed = Number(value)88 if (!Number.isSafeInteger(parsed)) fail('invalid_source', `UMLet ${name} is outside the supported range.`)89 return parsed90}9192function parseXml(source: string): XmlNode {93 if (new TextEncoder().encode(source).byteLength > MAX_SOURCE_BYTES) {94 throw new RenderError(413, 'source_too_large', 'UMLet source may not exceed 256 KiB.')95 }96 if (/<!DOCTYPE|<!ENTITY/i.test(source)) fail('invalid_source', 'UMLet source may not contain a doctype or entity declaration.')97 let root: XmlNode | null = null98 const stack: XmlNode[] = []99 let nodes = 0100 let parseError: Error | null = null101 const parser = new SaxesParser({ xmlns: false })102 parser.on('doctype', () => { parseError = new Error('DOCTYPE is not allowed') })103 parser.on('processinginstruction', () => { parseError = new Error('Processing instructions are not allowed') })104 parser.on('opentag', (tag: SaxesTagPlain) => {105 if (++nodes > MAX_XML_NODES) {106 parseError = new Error('Too many XML nodes')107 return108 }109 const node: XmlNode = { name: tag.name, text: '', children: [] }110 const parent = stack.at(-1)111 if (parent) parent.children.push(node)112 else if (root === null) root = node113 else parseError = new Error('Multiple XML roots')114 stack.push(node)115 })116 const append = (value: string) => {117 const node = stack.at(-1)118 if (node) node.text += value119 else if (value.trim()) parseError = new Error('Text outside XML root')120 }121 parser.on('text', append)122 parser.on('cdata', append)123 parser.on('closetag', () => { stack.pop() })124 parser.on('error', error => { parseError = error })125 try { parser.write(source).close() } catch (error) { parseError = error instanceof Error ? error : new Error(String(error)) }126 if (parseError || !root) fail('invalid_source', 'UMLet source is not valid bounded XML.')127 return root128}129130export function parseUmlet(source: string): UmletElement[] {131 const root = parseXml(source)132 if (!['diagram', 'umlet_diagram'].includes(root.name.toLowerCase())) fail('invalid_source', 'UMLet source must contain a diagram or umlet_diagram root.')133 const nodes = root.children.filter(node => node.name.toLowerCase() === 'element')134 if (nodes.length === 0) fail('invalid_source', 'UMLet source contains no elements.')135 if (nodes.length > MAX_ELEMENTS) throw new RenderError(413, 'source_too_large', `UMLet source may not contain more than ${MAX_ELEMENTS} elements.`)136 let totalText = 0137 const elements = nodes.map(node => {138 const id = content(child(node, 'id')).trim()139 const type = content(child(node, 'type')).trim()140 const rawType = id || type141 if (!rawType) fail('invalid_source', 'Every UMLet element needs an id or type.')142 const kind = id && STANDARD_IDS.has(id) ? id : LEGACY_TYPES.get(type) ?? (type.startsWith('com.umlet.element.custom.') ? 'CustomFallback' : 'GenericFallback')143 const coordinates = child(node, 'coordinates')144 if (!coordinates) fail('invalid_source', `UMLet element ${rawType} has no coordinates.`)145 const x = integer(content(child(coordinates, 'x')), 'x coordinate')146 const y = integer(content(child(coordinates, 'y')), 'y coordinate')147 const width = integer(content(child(coordinates, 'w')), 'width')148 const height = integer(content(child(coordinates, 'h')), 'height')149 if (width < 1 || height < 1) fail('invalid_source', 'UMLet element dimensions must be positive.')150 if (Math.abs(x) > MAX_COORDINATE || Math.abs(y) > MAX_COORDINATE || width > MAX_COORDINATE || height > MAX_COORDINATE || Math.abs(x + width) > MAX_COORDINATE || Math.abs(y + height) > MAX_COORDINATE) {151 fail('invalid_source', `UMLet coordinates may not exceed ${MAX_COORDINATE}px.`)152 }153 const panel = content(child(node, 'panel_attributes'))154 const additional = content(child(node, 'additional_attributes'))155 if (panel.length > MAX_ELEMENT_TEXT || additional.length > MAX_ELEMENT_TEXT) fail('invalid_source', 'A UMLet element contains too much text or geometry data.')156 totalText += panel.length + additional.length157 return { rawType, kind, x, y, width, height, panel, additional }158 })159 if (totalText > MAX_TOTAL_TEXT) throw new RenderError(413, 'source_too_large', 'UMLet source contains too much element text.')160 return elements161}162163function escape(value: string): string {164 return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')165}166167function panel(value: string): Panel {168 const controls = new Map<string, string>()169 const lines: string[] = []170 for (const raw of value.replaceAll('\r', '').split('\n')) {171 const line = raw.trimEnd()172 const match = /^([a-z][a-z0-9_-]*)=(.*)$/i.exec(line.trim())173 if (match && CONTROL_KEYS.has(match[1]!.toLowerCase())) controls.set(match[1]!.toLowerCase(), match[2]!.trim())174 else if (line.trim() || lines.length) lines.push(line.trim())175 }176 while (lines.at(-1) === '') lines.pop()177 return { controls, lines }178}179180function color(value: string | undefined, fallback: string): string {181 if (!value) return fallback182 const normalized = value.trim()183 return /^(?:#[0-9a-f]{3,8}|[a-z]{1,20})$/i.test(normalized) ? normalized : fallback184}185186function fontSize(value: string | undefined): number {187 const parsed = Number(value)188 return Number.isFinite(parsed) && parsed >= 6 && parsed <= 72 ? parsed : 14189}190191function wrapLine(value: string, width: number, size: number): string[] {192 if (!value) return ['']193 const maximum = Math.max(4, Math.floor(width / (size * 0.58)))194 if (value.length <= maximum) return [value]195 const result: string[] = []196 let current = ''197 for (const word of value.split(/\s+/)) {198 if (word.length > maximum * 1.5) {199 if (current) { result.push(current); current = '' }200 for (let index = 0; index < word.length; index += maximum) result.push(word.slice(index, index + maximum))201 } else if (!current) current = word202 else if (`${current} ${word}`.length <= maximum) current += ` ${word}`203 else { result.push(current); current = word }204 }205 if (current) result.push(current)206 return result207}208209function textBlock(element: UmletElement, parsed: Panel, inset = 8, centered = false): string {210 let size = fontSize(parsed.controls.get('fontsize'))211 const available = Math.max(8, element.width - inset * 2)212 const makeLines = () => parsed.lines.flatMap(line => line === '--' ? ['--'] : wrapLine(line, available, size)).slice(0, 200)213 let lines = makeLines()214 const availableHeight = Math.max(8, element.height - inset * 2)215 if (lines.length * size * 1.25 > availableHeight) {216 size = Math.max(8, Math.min(size, availableHeight / Math.max(1, lines.length * 1.25)))217 lines = makeLines()218 }219 const lineHeight = size * 1.25220 const total = lines.length * lineHeight221 const align = parsed.controls.get('halign') ?? (centered ? 'center' : 'left')222 const anchor = align === 'right' ? 'end' : align === 'center' ? 'middle' : 'start'223 const x = align === 'right' ? element.x + element.width - inset : align === 'center' ? element.x + element.width / 2 : element.x + inset224 let y = parsed.controls.get('valign') === 'center' || centered225 ? element.y + Math.max(size, (element.height - total) / 2 + size)226 : element.y + inset + size227 const output: string[] = []228 for (const line of lines) {229 if (line === '--') {230 output.push(`<line x1="${element.x + inset}" y1="${y - size * 0.45}" x2="${element.x + element.width - inset}" y2="${y - size * 0.45}" class="separator"/>`)231 } else {232 output.push(`<text x="${x}" y="${y}" text-anchor="${anchor}" font-size="${size}">${escape(line)}</text>`)233 }234 y += lineHeight235 }236 return output.join('')237}238239function points(element: UmletElement): Array<[number, number]> {240 const values = element.additional.split(';').map(value => value.trim()).filter(Boolean)241 if (values.length < 4 || values.length % 2 !== 0 || values.some(value => !/^-?\d+(?:\.\d+)?$/.test(value))) {242 return [[element.x, element.y + element.height / 2], [element.x + element.width, element.y + element.height / 2]]243 }244 const parsed = values.map(Number)245 if (parsed.some(value => !Number.isFinite(value) || Math.abs(value) > MAX_COORDINATE)) {246 return [[element.x, element.y + element.height / 2], [element.x + element.width, element.y + element.height / 2]]247 }248 const result: Array<[number, number]> = []249 for (let index = 0; index < parsed.length; index += 2) result.push([element.x + parsed[index]!, element.y + parsed[index + 1]!])250 return result251}252253function relation(element: UmletElement, parsed: Panel): string {254 const relationPoints = points(element)255 const lineType = parsed.controls.get('lt') ?? '-'256 const markerStart = lineType.includes('<') ? ' marker-start="url(#arrow)"' : ''257 const markerEnd = lineType.includes('>') ? ' marker-end="url(#arrow)"' : ''258 const dash = lineType.includes('.') ? ' stroke-dasharray="2 5"' : lineType.includes('/') ? ' stroke-dasharray="8 5"' : ''259 const serialized = relationPoints.map(([x, y]) => `${x},${y}`).join(' ')260 const middle = relationPoints[Math.floor((relationPoints.length - 1) / 2)]!261 const labels = parsed.lines.filter(line => line !== '--')262 return `<polyline points="${serialized}" fill="none"${markerStart}${markerEnd}${dash}/>${labels.map((line, index) => `<text x="${middle[0] + 6}" y="${middle[1] - 6 + index * 17}" font-size="13">${escape(line)}</text>`).join('')}`263}264265function specialState(element: UmletElement, mode: string): string {266 const cx = element.x + element.width / 2267 const cy = element.y + element.height / 2268 const radius = Math.max(3, Math.min(element.width, element.height) / 2 - 2)269 if (mode === 'initial') return `<circle cx="${cx}" cy="${cy}" r="${radius}" class="solid"/>`270 if (mode === 'final') return `<circle cx="${cx}" cy="${cy}" r="${radius}"/><circle cx="${cx}" cy="${cy}" r="${Math.max(2, radius * 0.55)}" class="solid"/>`271 if (mode === 'history' || mode === 'history_shallow') return `<circle cx="${cx}" cy="${cy}" r="${radius}"/><text x="${cx}" y="${cy + 5}" text-anchor="middle" font-size="${Math.max(8, radius)}">H</text>`272 if (mode === 'history_deep') return `<circle cx="${cx}" cy="${cy}" r="${radius}"/><text x="${cx}" y="${cy + 5}" text-anchor="middle" font-size="${Math.max(8, radius)}">H*</text>`273 if (mode === 'termination') return `<line x1="${cx - radius}" y1="${cy - radius}" x2="${cx + radius}" y2="${cy + radius}"/><line x1="${cx + radius}" y1="${cy - radius}" x2="${cx - radius}" y2="${cy + radius}"/>`274 return `<path d="M ${cx} ${cy - radius} L ${cx + radius} ${cy} L ${cx} ${cy + radius} L ${cx - radius} ${cy} Z"/>`275}276277function generic(element: UmletElement, parsed: Panel, label?: string): string {278 const heading = label ? `<text x="${element.x + 7}" y="${element.y + 14}" font-size="10" class="fallback-label">${escape(label)}</text>` : ''279 const shifted = label ? { ...element, y: element.y + 10, height: Math.max(1, element.height - 10) } : element280 return `<rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" rx="3"${label ? ' stroke-dasharray="5 4"' : ''}/>${heading}${textBlock(shifted, parsed, 7)}`281}282283function draw(element: UmletElement): string {284 const parsed = panel(element.panel)285 const fill = color(parsed.controls.get('bg'), 'white')286 const stroke = color(parsed.controls.get('fg'), '#111827')287 const open = `<g fill="${fill}" stroke="${stroke}" color="${stroke}">`288 switch (element.kind) {289 case 'Relation': return `${open}${relation(element, parsed)}</g>`290 case 'CustomThreeWayRelation': {291 const cx = element.x + element.width / 2; const cy = element.y + element.height / 2292 return `${open}<path d="M ${cx} ${element.y} L ${cx} ${cy} M ${element.x} ${cy} L ${element.x + element.width} ${cy} M ${cx} ${cy} L ${cx} ${element.y + element.height}"/><circle cx="${cx}" cy="${cy}" r="2" class="solid"/></g>`293 }294 case 'CustomInitialState': return `${open}${specialState(element, 'initial')}</g>`295 case 'CustomFinalState': return `${open}${specialState(element, 'final')}</g>`296 case 'CustomHistoryState': return `${open}${specialState(element, 'history')}</g>`297 case 'UMLSpecialState': return `${open}${specialState(element, (parsed.controls.get('type') ?? 'decision').toLowerCase())}</g>`298 case 'UMLUseCase': return `${open}<ellipse cx="${element.x + element.width / 2}" cy="${element.y + element.height / 2}" rx="${element.width / 2}" ry="${element.height / 2}"/>${textBlock(element, parsed, 8, true)}</g>`299 case 'UMLActor': {300 const cx = element.x + element.width / 2; const top = element.y + 5; const unit = Math.min(element.width, element.height) / 5301 return `${open}<circle cx="${cx}" cy="${top + unit}" r="${unit * 0.55}"/><path d="M ${cx} ${top + unit * 1.55} L ${cx} ${top + unit * 3.1} M ${cx - unit} ${top + unit * 2.1} L ${cx + unit} ${top + unit * 2.1} M ${cx} ${top + unit * 3.1} L ${cx - unit} ${top + unit * 4.2} M ${cx} ${top + unit * 3.1} L ${cx + unit} ${top + unit * 4.2}"/>${textBlock({ ...element, y: element.y + element.height * 0.72, height: element.height * 0.28 }, parsed, 4, true)}</g>`302 }303 case 'UMLInterface': {304 const cx = element.x + element.width / 2; const radius = Math.min(element.width, element.height) * 0.18305 return `${open}<circle cx="${cx}" cy="${element.y + radius + 3}" r="${radius}"/><line x1="${cx}" y1="${element.y + radius * 2 + 3}" x2="${cx}" y2="${element.y + radius * 3 + 3}"/>${textBlock({ ...element, y: element.y + radius * 3, height: element.height - radius * 3 }, parsed, 4, true)}</g>`306 }307 case 'UMLNote': {308 const fold = Math.min(18, element.width / 4, element.height / 4)309 return `${open}<path d="M ${element.x} ${element.y} H ${element.x + element.width - fold} L ${element.x + element.width} ${element.y + fold} V ${element.y + element.height} H ${element.x} Z M ${element.x + element.width - fold} ${element.y} V ${element.y + fold} H ${element.x + element.width}"/>${textBlock(element, parsed)}</g>`310 }311 case 'UMLPackage': {312 const tab = Math.min(element.width * 0.45, 90); const tabHeight = Math.min(18, element.height * 0.25)313 return `${open}<path d="M ${element.x} ${element.y + tabHeight} V ${element.y} H ${element.x + tab} L ${element.x + tab + 8} ${element.y + tabHeight} H ${element.x + element.width} V ${element.y + element.height} H ${element.x} Z"/>${textBlock({ ...element, y: element.y + tabHeight, height: element.height - tabHeight }, parsed)}</g>`314 }315 case 'UMLDeployment': return `${open}<path d="M ${element.x + 8} ${element.y} H ${element.x + element.width} V ${element.y + element.height - 8} L ${element.x + element.width - 8} ${element.y + element.height} H ${element.x} V ${element.y + 8} Z M ${element.x} ${element.y + 8} L ${element.x + 8} ${element.y} M ${element.x + element.width - 8} ${element.y + element.height} L ${element.x + element.width} ${element.y + element.height - 8}"/>${textBlock(element, parsed)}</g>`316 case 'UMLSyncBarHorizontal': return `${open}<rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" class="solid"/></g>`317 case 'UMLSyncBarVertical': return `${open}<rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" class="solid"/></g>`318 case 'UMLTimer': {319 const cx = element.x + element.width / 2; const cy = element.y + element.height / 2; const r = Math.max(4, Math.min(element.width, element.height) / 2 - 3)320 return `${open}<circle cx="${cx}" cy="${cy}" r="${r}"/><path d="M ${cx} ${cy} V ${cy - r * 0.65} M ${cx} ${cy} L ${cx + r * 0.55} ${cy + r * 0.3}"/>${textBlock(element, parsed, 4, true)}</g>`321 }322 case 'UMLHierarchy': {323 const cx = element.x + element.width / 2; const mid = element.y + element.height / 2324 return `${open}<path d="M ${cx} ${element.y + 4} V ${mid} M ${element.x + 8} ${mid} H ${element.x + element.width - 8} M ${element.x + 8} ${mid} V ${element.y + element.height - 4} M ${element.x + element.width - 8} ${mid} V ${element.y + element.height - 4}"/>${textBlock(element, parsed, 4, true)}</g>`325 }326 case 'Text': return `${open}${textBlock(element, parsed, 0)}</g>`327 case 'CustomState':328 case 'UMLState': return `${open}<rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" rx="${Math.min(14, element.height / 3)}"/>${textBlock(element, parsed)}</g>`329 case 'UMLClass':330 case 'UMLObject':331 case 'UMLFrame':332 case 'UMLGeneric': return `${open}${generic(element, parsed)}</g>`333 case 'PlotGrid':334 case 'UMLSequenceAllInOne': return `${open}${generic(element, parsed, `approximated ${element.kind}`)}</g>`335 case 'CustomFallback': return `${open}${generic(element, parsed, `custom ${element.rawType.split('.').at(-1)}`)}</g>`336 default: return `${open}${generic(element, parsed, `unknown ${element.rawType.split('.').at(-1)}`)}</g>`337 }338}339340export function renderUmlet(source: string): string {341 const elements = parseUmlet(source)342 let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity343 for (const element of elements) {344 minX = Math.min(minX, element.x); minY = Math.min(minY, element.y)345 maxX = Math.max(maxX, element.x + element.width); maxY = Math.max(maxY, element.y + element.height)346 if (element.kind === 'Relation') {347 const relationPoints = points(element)348 for (const [pointX, pointY] of relationPoints) { minX = Math.min(minX, pointX); minY = Math.min(minY, pointY); maxX = Math.max(maxX, pointX); maxY = Math.max(maxY, pointY) }349 const labels = panel(element.panel).lines.filter(line => line !== '--')350 const middle = relationPoints[Math.floor((relationPoints.length - 1) / 2)]!351 maxX = Math.max(maxX, middle[0] + 12 + Math.max(0, ...labels.map(label => label.length * 7.5)))352 minY = Math.min(minY, middle[1] - 20)353 maxY = Math.max(maxY, middle[1] + labels.length * 18)354 }355 }356 const x = minX - MARGIN; const y = minY - MARGIN; const width = maxX - minX + MARGIN * 2; const height = maxY - minY + MARGIN * 2357 const body = elements.map(draw).join('')358 const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="${x} ${y} ${width} ${height}"><defs><marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 1 1 L 9 5 L 1 9" fill="none" stroke="context-stroke"/></marker></defs><style>g{stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}text{fill:currentColor;stroke:none;font-family:Arial,sans-serif}.solid{fill:currentColor}.separator{stroke:currentColor}.fallback-label{fill:#6b7280}</style>${body}</svg>`359 if (svg.length > MAX_OUTPUT_LENGTH) throw new RenderError(413, 'render_too_large', 'Rendered UMLet SVG is too large.')360 return svg361}