/** * Compact cells: the short way the model writes shapes and edges. * * A shape is one self-closing mxCell with its position and size as x, y, w * and h attributes; an edge is one with source and target. The fixed parts * of draw.io's XML are left out and filled in here: * * * * * becomes * * * * * * * * * The long form stays accepted, and anything beyond the plain case (edge * waypoints, a label on an edge, a parent other than the layer) is written * the long way, so a cell may mix both: compact attributes with an explicit * parent, for example. A cell is an edge when it says so or connects a * source or target, a shape when it says so, has a size or has a geometry. * * foldCells is the reverse, for the diagram shown to the model: it writes * the plain shapes and edges compactly and leaves everything else as it is, * so the model reads the notation it is asked to write. Folding then * expanding gives the same cells back. */ import { readAttributes, type TagAttribute } from "./xml-attributes.ts" // A whole mxCell: self-closing, or with its children (cells never nest) const CELL_BLOCK = /"']|"[^"]*"|'[^']*')*?)\s*(?:\/>|>([\s\S]*?)<\/mxCell>)/g // The cell's own geometry, as opposed to one inside its custom data const OWN_GEOMETRY = /"']|"[^"]*"|'[^']*')*?\bas="geometry"/ // The four compact attributes and the names draw.io uses for them const SIZE_ATTRS = new Map([ ["x", "x"], ["y", "y"], ["w", "width"], ["h", "height"], ["width", "width"], ["height", "height"], ]) const isSize = (name: string) => SIZE_ATTRS.has(name) // Attributes a cell is written with, in draw.io's usual order const FIRST = ["id", "value", "style"] const LAST = ["vertex", "edge", "parent", "source", "target"] // A value read from a single-quoted attribute may hold a double quote const attr = (name: string, value: string) => ` ${name}="${value.replace(/"/g, """)}"` const attributeText = (attrs: TagAttribute[]) => attrs.map((a) => attr(a.name, a.value)).join("") /** The id of the page's first layer (a cell whose parent is "0"), or "1" */ export function defaultLayerOf(xml: string): string { for (const [, attrText] of xml.matchAll(CELL_BLOCK)) { const attrs = new Map( readAttributes(attrText).map((a) => [a.name, a.value]), ) if (attrs.get("parent") === "0" && attrs.get("id")) { return attrs.get("id") ?? "1" } } return "1" } /** * Turn compact shapes and edges into standard draw.io cells. A cell with no * parent goes on `layer`, the page's first layer ("1" on a new page). */ export function expandCompactCells(xml: string, layer = "1"): string { return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => { const attrs = readAttributes(attrText) const byName = new Map(attrs.map((a) => [a.name, a.value])) const size = attrs.filter((a) => isSize(a.name)) const inner = body ?? "" const hasGeometry = OWN_GEOMETRY.test(inner) const isEdge = byName.get("edge") === "1" || (byName.get("vertex") !== "1" && (byName.has("source") || byName.has("target"))) const isVertex = !isEdge && (byName.get("vertex") === "1" || size.length > 0 || hasGeometry) // Root cells and anything else the model wrote in full are left alone if (!isEdge && !isVertex) return block const complete = size.length === 0 && byName.has(isEdge ? "edge" : "vertex") && byName.has("parent") && hasGeometry if (complete) return block // The compact attributes become the geometry; when the cell has its // own geometry already they stay as written, in case they mean // something else to whoever wrote them const consumed = !hasGeometry && isVertex const rest = attrs.filter( (a) => !FIRST.includes(a.name) && !LAST.includes(a.name) && !(consumed && isSize(a.name)) && !(isEdge && isSize(a.name)), ) // Rebuilt in the usual order: id, value, style, the rest, then the // flags and the connections const ordered: [string, string][] = [ ...FIRST.flatMap((n): [string, string][] => byName.has(n) ? [[n, byName.get(n) ?? ""]] : [], ), ...rest.map((a): [string, string] => [a.name, a.value]), [isEdge ? "edge" : "vertex", "1"], ["parent", byName.get("parent") ?? layer], ...["source", "target"].flatMap((n): [string, string][] => byName.has(n) ? [[n, byName.get(n) ?? ""]] : [], ), ] const written = ordered.map(([n, v]) => attr(n, v)).join("") let geometry = "" if (!hasGeometry) { if (isEdge) { geometry = '' } else { const get = (short: string, long: string, fallback: string) => byName.get(short) ?? byName.get(long) ?? fallback geometry = `` } } return `${geometry}${inner}` }) } /** Attributes of a single self-closing mxGeometry, or null for anything else */ function plainGeometry(body: string): Map | null { const trimmed = body.trim() const m = trimmed.match( /^"']|"[^"]*"|'[^']*')*?)\s*\/>$/, ) if (!m) return null const attrs = new Map(readAttributes(m[1]).map((a) => [a.name, a.value])) // Anything odd, such as a wrong "as", stays as written so folding and // expanding give the same cells back return attrs.get("as") === "geometry" ? attrs : null } /** * Write plain shapes and edges compactly, for the diagram shown to the * model. A shape is plain when its only child is an mxGeometry with x, y, * width and height; an edge when its only child is the relative geometry. * Both must name their parent, since expanding fills in the layer for a * missing one. Everything else, including wrapped cells' extra data and * edge labels, is left as written. */ export function foldCells(xml: string, layer = "1"): string { return xml.replace(CELL_BLOCK, (block, attrText: string, body?: string) => { if (body === undefined) return block const attrs = readAttributes(attrText) const byName = new Map(attrs.map((a) => [a.name, a.value])) if (attrs.some((a) => isSize(a.name))) return block if (!byName.has("parent")) return block const geometry = plainGeometry(body) if (!geometry) return block const geometryKeys = [...geometry.keys()] .filter((k) => k !== "as") .sort() const kept = attrs.filter( (a) => a.name !== "vertex" && a.name !== "edge" && !(a.name === "parent" && a.value === layer), ) if (byName.get("vertex") === "1" && byName.get("edge") !== "1") { if (geometryKeys.join() !== "height,width,x,y") return block const size = `${attr("x", geometry.get("x") ?? "")}${attr("y", geometry.get("y") ?? "")}${attr("w", geometry.get("width") ?? "")}${attr("h", geometry.get("height") ?? "")}` return `` } if (byName.get("edge") === "1" && byName.get("vertex") !== "1") { if ( geometryKeys.join() !== "relative" || geometry.get("relative") !== "1" ) { return block } if (!byName.has("source") || !byName.has("target")) return block return `` } return block }) }