clone

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

main
diagramzip / renderers / edge / src / adapters / plantuml-family.ts
plantuml-family.ts 5.1K
1import { parseHTML } from 'linkedom'2// @ts-expect-error The pinned TeaVM browser artifact is vendored JavaScript.3import plantumlViz from '../../artifacts/plantuml-family/viz-global.cjs'4import vizModule from '../../artifacts/plantuml-family/viz.wasm'5// @ts-expect-error The pinned TeaVM browser artifact is vendored JavaScript.6import { renderToString } from '../../artifacts/plantuml-family/plantuml.js'7import { RenderError } from '../runtime/errors'8import type { RendererAdapter } from '../runtime/types'9import { edgeResult } from './types'10import { lowerC4 } from './c4-lowerer'1112const VERSION = 'plantuml@1.2026.6/edge-wasm-1'13const MAX_SOURCE_BYTES = 256 * 102414const MAX_OUTPUT_BYTES = 4 * 1024 * 102415const THEMES = new Set([16 'amiga', 'black-knight', 'bluegray', 'blueprint', 'cerulean-outline', 'cerulean',17 'crt-amber', 'crt-green', 'cyborg-outline', 'cyborg', 'hacker', 'hacker-hold',18 'lightgray', 'materia-outline', 'materia', 'metal', 'mimeograph', 'minty', 'plain',19 'resume-light', 'sandstone', 'silver', 'sketchy-outline', 'sketchy', 'spacelab',20 'superhero-outline', 'superhero', 'united',21])2223const DOM = parseHTML('<html><head></head><body></body></html>') as any24const document = DOM.document25const window = DOM.window as any26const globals = globalThis as typeof globalThis & Record<string, unknown>27globals.document = document28globals.window = window29globals.self = globalThis30globals.Viz = plantumlViz31globals.__DIAGRAMZIP_PLANTUML_VIZ_MODULE = vizModule32window.Viz = plantumlViz33globals.XMLSerializer = class { serializeToString(node: unknown) { return String(node) } }34window.SVGElement.prototype.getBBox = function () { return { x: 0, y: 0, width: 100, height: 20 } }35const createElement = document.createElement.bind(document)36document.createElement = (tagName: string) => {37 const element = createElement(tagName)38 if (tagName === 'canvas') {39 element.getContext = () => ({ font: '', measureText: (text: string) => ({ width: text.length * 8 }) }) as never40 }41 return element42}43window._measureCanvas = document.createElement('canvas')44window._measureCtx = window._measureCanvas.getContext('2d')4546let renderTail: Promise<void> = Promise.resolve()47function withRenderLock<T>(operation: () => Promise<T>): Promise<T> {48 const previous = renderTail49 let release!: () => void50 renderTail = new Promise(resolve => { release = resolve })51 return previous.then(async () => { try { return await operation() } finally { release() } })52}5354function prepareSource(source: string, engine: 'plantuml' | 'c4plantuml', options: Record<string, string>): string {55 const c4Include = /!include\s+(?:<C4\/)?C4_(Context|Container|Component|Deployment|Dynamic|Sequence)(?:\.puml)?(?:>)?/i56 const sourceWithoutC4 = source.replace(c4Include, '')57 if (/^\s*!(?:include|includeurl|includesub|import|load|theme)\b/im.test(sourceWithoutC4)) {58 throw new RenderError(400, 'unsupported_directive', 'PlantUML resource and theme directives are disabled in the edge renderer.')59 }60 if (/^\s*!include\s+(?:https?:|file:|\/)/im.test(sourceWithoutC4)) {61 throw new RenderError(400, 'unsupported_include', 'Remote and filesystem PlantUML resources are disabled in the edge renderer.')62 }63 let prepared = source.trim()64 if (!prepared.startsWith('@start')) prepared = `@startuml\n${prepared}\n@enduml`65 if (engine === 'c4plantuml') {66 prepared = lowerC4(prepared)67 }68 const theme = options.theme?.trim()69 if (theme) {70 if (!THEMES.has(theme)) throw new RenderError(400, 'invalid_options', `Unsupported PlantUML theme: ${theme}.`)71 prepared = prepared.replace(/(^@start[^\n]*\n)/i, `$1!theme ${theme}\n`)72 }73 return prepared74}7576export const plantumlFamilyAdapter: RendererAdapter = {77 id: 'plantuml', runtime: 'edge-wasm', version: VERSION,78 async render(request, signal) {79 if (signal.aborted) throw signal.reason80 const sourceBytes = new TextEncoder().encode(request.source).byteLength81 if (sourceBytes > MAX_SOURCE_BYTES) throw new RenderError(413, 'source_too_large', 'PlantUML source exceeds the 256 KiB limit.')82 for (const key of Object.keys(request.options)) if (!['theme', 'no-metadata'].includes(key)) throw new RenderError(400, 'unsupported_options', `Unsupported PlantUML option: ${key}.`)83 const source = prepareSource(request.source, request.engine as 'plantuml' | 'c4plantuml', request.options)84 return withRenderLock(() => new Promise((resolve, reject) => {85 if (signal.aborted) return reject(signal.reason)86 renderToString(source.split(/\r?\n/), (svg: string) => {87 if (/(?:Syntax Error\?|Error line \d+)/i.test(svg)) return reject(new RenderError(422, 'render_failed', 'PlantUML returned a syntax-error SVG.'))88 if (new TextEncoder().encode(svg).byteLength > MAX_OUTPUT_BYTES) return reject(new RenderError(413, 'render_too_large', 'PlantUML output exceeds the 4 MiB limit.'))89 const body = request.options['no-metadata'] !== undefined ? svg.replace(/<\?plantuml-src[^?]*\?>/g, '') : svg90 resolve(edgeResult(request.engine as never, VERSION, body, 'edge-wasm'))91 }, (error: string) => reject(new RenderError(422, 'render_failed', String(error).slice(0, 500))))92 }))93 },94}