feat(diagram): named styles and compact cells for shorter model output (#969)

The model defines reused styles once as <mxStyle name="..." value="..."/> and
refers to them by name, and writes shapes as one self-closing mxCell with
x, y, w, h and edges with source and target. style-classes.ts and
compact-cells.ts expand both back into standard draw.io XML after validation
and add draw.io's html=1 / whiteSpace=wrap defaults; the diagram shown to the
model is folded into the same notation. Prompts, tool descriptions and the MCP
drawing guide teach the notation with shared examples.

Measured on the five start-screen examples against main: gpt-6-luna 35% fewer
output tokens, Claude Opus 5.5 41% fewer. Two five-model review rounds fixed
quote-aware cell matching, attribute escaping, edge/vertex inference, root-id
and layer handling, and several auto-fix gaps.
This commit is contained in:
Dayuan Jiang
2026-10-10 21:58:21 +09:00
committed by GitHub
parent 498ee628f1
commit cd5352ca88
16 changed files with 1720 additions and 123 deletions
+196
View File
@@ -0,0 +1,196 @@
/**
* 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:
*
* <mxCell id="2" value="Start" style="step;" x="40" y="40" w="120" h="60"/>
* <mxCell id="5" style="down;" source="2" target="3"/>
*
* becomes
*
* <mxCell id="2" value="Start" style="step;" vertex="1" parent="1">
* <mxGeometry x="40" y="40" width="120" height="60" as="geometry"/>
* </mxCell>
* <mxCell id="5" style="down;" edge="1" parent="1" source="2" target="3">
* <mxGeometry relative="1" as="geometry"/>
* </mxCell>
*
* 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 =
/<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
// The cell's own geometry, as opposed to one inside its custom data
const OWN_GEOMETRY = /<mxGeometry\b(?:[^<>"']|"[^"]*"|'[^']*')*?\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, "&quot;")}"`
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 = '<mxGeometry relative="1" as="geometry"/>'
} else {
const get = (short: string, long: string, fallback: string) =>
byName.get(short) ?? byName.get(long) ?? fallback
geometry = `<mxGeometry${attr("x", get("x", "x", "0"))}${attr("y", get("y", "y", "0"))}${attr("width", get("w", "width", "120"))}${attr("height", get("h", "height", "60"))} as="geometry"/>`
}
}
return `<mxCell${written}>${geometry}${inner}</mxCell>`
})
}
/** Attributes of a single self-closing mxGeometry, or null for anything else */
function plainGeometry(body: string): Map<string, string> | null {
const trimmed = body.trim()
const m = trimmed.match(
/^<mxGeometry\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\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 `<mxCell${attributeText(kept)}${size}/>`
}
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 `<mxCell${attributeText(kept)}/>`
}
return block
})
}
+20 -17
View File
@@ -9,6 +9,7 @@
import {
indent,
STYLE_CLASS_EXAMPLE,
SWIMLANE_EXAMPLE,
TWO_EDGES_EXAMPLE,
WAYPOINT_EXAMPLE,
@@ -26,21 +27,17 @@ export const DRAWING_GUIDE = `# Draw.io drawing guide
- The preview page has History (it saves a snapshot before every AI change and can restore any of the last 20 versions) and Download. You can make changes freely; nothing is lost.
## The XML you send
Single page (create_new_diagram, add_page): send ONLY the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1".
Single page (create_new_diagram, add_page): send ONLY the named styles and the mxCell elements. The server adds <mxfile>, <mxGraphModel>, <root> and the root cells id="0" and id="1", expands named styles (see Styles), adds html=1 to every cell and whiteSpace=wrap to shapes, and fills in vertex, edge, parent="1" and the mxGeometry element. A shape is one self-closing mxCell with x, y, w and h; an edge is one with source and target. A cell with source or target is always an edge. Write parent only for a shape inside a container, and an mxGeometry element only for edge waypoints or for a separate label cell placed on an edge: <mxCell id="9" value="yes" style="edgeLabel;" parent="<edge id>" connectable="0"><mxGeometry x="-0.5" relative="1" as="geometry"/></mxCell>. An edge's own text simply goes in its value.
<mxCell id="2" value="Label" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1">
<mxGeometry x="100" y="100" width="120" height="60" as="geometry"/>
</mxCell>
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;html=1;" edge="1" parent="1" source="2" target="4">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="2" value="Label" style="rounded=1;" x="100" y="100" w="120" h="60"/>
<mxCell id="3" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.5;entryX=0;entryY=0.5;" source="2" target="4"/>
Several pages at once (create_new_diagram only): send a full <mxfile> with one <diagram id="..." name="..."> per page. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules (XML that breaks them is rejected):
1. All mxCell elements are siblings. NEVER nest an mxCell inside another mxCell.
2. Ids are unique within a page and start from "2" ("0" and "1" are the root cells).
3. parent="1" for top-level shapes, parent="<container id>" for shapes inside a container.
3. parent="<container id>" for shapes inside a container; top-level cells need no parent.
4. Edge source and target must reference existing cell ids.
5. Escape special characters in attribute values: &lt; for <, &gt; for >, &amp; for &, &quot; for ".
6. NEVER include XML comments (<!-- -->). draw.io strips them.
@@ -59,7 +56,7 @@ ${indent(SWIMLANE_EXAMPLE)}
## Edge routing rules
Rule 1: Never let two edges share a path. Two edges between the same nodes exit and enter at different points (exitY=0.3 for the first, exitY=0.7 for the second, not both 0.5).
Rule 2: For bidirectional connections (A to B and B to A), use opposite sides: A exits right (exitX=1) into the left of B (entryX=0); B exits left (exitX=0) into the right of A (entryX=1).
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;".
Rule 3: Always set exitX, exitY, entryX and entryY in the edge style, e.g. style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;".
Rule 4: Route edges AROUND shapes in the way. Before drawing an edge, find every shape between source and target; if one is in the path, add waypoints. Route diagonal connections along the outside of the diagram, not through the middle. Keep 20-30px clearance from shapes. An edge must never cross another shape's box.
Rule 5: Plan the layout first. Organize shapes into rows or columns following the flow, space them 150-200px apart so edges have room, and prefer one flow direction (left to right or top to bottom).
Rule 6: Use 2-3 waypoints for L-shaped or U-shaped paths. Each change of direction needs a waypoint, and segments should be horizontal or vertical.
@@ -80,27 +77,33 @@ Waypoints go inside <Array as="points"> in the edge geometry. Example: Hotfix (r
${indent(WAYPOINT_EXAMPLE)}
## Styles
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex, whiteSpace=wrap;html=1;
- Edges: endArrow=classic, block, open or none; startArrow=none or classic; curved=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center, left or right
Define each style used by several cells ONCE, as a named style before the cells, and use the name in the cells like a CSS class. A cell's style can combine a shape token, a name and overrides; later pairs win. Name only styles that two or more cells share; a style used by one cell stays inline. Names must not be draw.io's own style names: shapes such as text, ellipse, rhombus, swimlane, label, image, and colors such as blue, green, red, gray, yellow, orange, purple, pink. A definition applies to the call it is in: each create_new_diagram or add_page call defines the names it uses. The server expands the names, so the saved file is standard draw.io XML.
${indent(STYLE_CLASS_EXAMPLE)}
- NEVER write html=1 or whiteSpace=wrap: the server adds html=1 to every cell and whiteSpace=wrap to shapes. Labels are HTML: use &lt;br&gt; for a line break and &lt;b&gt; for bold, never \\n; a literal < or > in a label is written &amp;lt; or &amp;gt;.
- Do NOT repeat what draw.io already uses. For a plain shape: rounded=0, align=center, verticalAlign=middle, fontSize=12, strokeWidth=1, fillColor=#ffffff, strokeColor=#000000, fontColor=#000000. For an edge: endArrow=classic, strokeColor=#000000. Writing one of them is right only when it overrides what a name or the shape sets: an edge is rounded by default, so rounded=0 on an edge is a real setting, and a text cell is left/top aligned by default, so there align=center or verticalAlign=middle are real settings.
- Shapes: rounded=1, fillColor=#hex, strokeColor=#hex
- Edges: endArrow=block, open or none; startArrow=classic; curved=1; dashed=1; edgeStyle=orthogonalEdgeStyle
- Text: fontSize=14, fontStyle=1 (bold), align=center or right
- Animated connectors: add flowAnimation=1 to the edge style.
## Minimal style
When the user asks for a minimal, plain, black-and-white or unstyled diagram, use these rules instead of the styles above:
- No fillColor, strokeColor, rounded, fontSize, fontStyle or hex colors.
- Shapes use style "whiteSpace=wrap;html=1;", edges use "html=1;endArrow=classic;".
- Containers that hold other shapes use "whiteSpace=wrap;html=1;fillColor=none;" so they do not cover their children.
- Shapes have no style, or only the shape (ellipse, rhombus); edges have edgeStyle=orthogonalEdgeStyle plus the exit and entry points from the routing rules, nothing else.
- Containers that hold other shapes use "fillColor=none;" so they do not cover their children.
- Keep at least 50px between elements, and follow all edge routing rules strictly.
## Editing with edit_diagram
- update replaces a cell: send the complete mxCell including mxGeometry, with the same id as cell_id.
- update replaces a cell: send the complete mxCell in the same compact form (x, y, w, h for a shape), with the same id as cell_id. Named styles are not available here: write each cell's complete style.
- add inserts a new cell with a new id. One cell per operation.
- delete removes a cell. Its children and every edge connected to it are deleted too, so give only the container's id.
- All-or-nothing: if any operation fails, nothing is applied. A rejected call includes the current XML of the page; rebuild your operations on it and retry.
- If the diagram is large, change it with edit_diagram instead of redrawing it.
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"40\\" y=\\"40\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}
{"operations": [{"operation": "update", "cell_id": "3", "new_xml": "<mxCell id=\\"3\\" value=\\"New Label\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}
{"page_name": "CNN", "operations": [{"operation": "add", "cell_id": "conv-1", "new_xml": "<mxCell id=\\"conv-1\\" value=\\"Conv\\" x=\\"40\\" y=\\"40\\" w=\\"120\\" h=\\"60\\"/>"}]}
{"page_index": 1, "operations": [{"operation": "delete", "cell_id": "5"}]}
Pages: list_pages shows every page's id, name and index. edit_diagram, get_diagram and export_diagram take an optional page_id, page_name or page_index; without one they use the first page.
+22 -1
View File
@@ -7,11 +7,13 @@
* so the model never builds on a half-applied edit.
*/
import { defaultLayerOf, expandCompactCells } from "./compact-cells.ts"
import {
applyDiagramOperations,
type DiagramOperation,
} from "./diagram-operations.ts"
import { type PageSelector, projectPage } from "./pages.ts"
import { addDefaultStyles, edgeIdsOf } from "./style-classes.ts"
import { validateAndFixXml, validateMxCellStructure } from "./xml-validation.ts"
export type EditOutcome =
@@ -48,11 +50,22 @@ export function editDiagram(
const fixes: string[] = []
const prepared: DiagramOperation[] = []
// Edges already on the page, so a label added to one is not wrapped, and
// the layer a compact cell without a parent goes on
const page = targetPageXml(xml, selector)
const edges = edgeIdsOf(xml)
const layer = defaultLayerOf(page)
for (const op of operations) {
if (op.operation === "delete" || !op.new_xml) {
prepared.push(op)
continue
}
if (/<mxStyle\b/i.test(op.new_xml)) {
errors.push(
`${op.operation} ${op.cell_id}: named styles (mxStyle) are not available in edit_diagram; write the cell's complete style`,
)
continue
}
// Checked before validation: several cells fail the strict parser
// with a misleading "only one root" syntax error.
if (countTopLevelElements(op.new_xml) > 1) {
@@ -71,7 +84,15 @@ export function editDiagram(
if (check.fixed) {
fixes.push(`${op.cell_id}: ${check.fixes.join(", ")}`)
}
prepared.push({ ...op, new_xml: check.fixed ?? op.new_xml })
// The defaults the model is told not to write (style-classes.ts),
// added after the fixes so a repaired cell gets them too
prepared.push({
...op,
new_xml: addDefaultStyles(
expandCompactCells(check.fixed ?? op.new_xml, layer),
edges,
),
})
}
if (errors.length > 0) return { ok: false, errors, pageError: false }
+34 -11
View File
@@ -23,6 +23,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import open from "open"
import { z } from "zod"
import { expandCompactCells, foldCells } from "./compact-cells.ts"
import type { DiagramOperation } from "./diagram-operations.ts"
import { installDomPolyfill } from "./dom.ts"
import { DRAWING_GUIDE } from "./drawing-guide.ts"
@@ -48,7 +49,11 @@ import {
} from "./http-server.ts"
import { parseDrawioFileContent } from "./load-diagram.ts"
import { log } from "./logger.ts"
import { prepareNewDiagram, reservedIdError } from "./new-diagram.ts"
import {
prepareNewDiagram,
reservedIdError,
takeStyleDefinitions,
} from "./new-diagram.ts"
import {
addPageToDoc,
deletePageFromDoc,
@@ -66,6 +71,7 @@ import {
} from "./pages.ts"
import { Autosaver, defaultDataDir, expandHome } from "./persistence.ts"
import { getShapeLibrary, SHAPE_LIBRARY_LIST } from "./shape-library.ts"
import { addDefaultStyles, applyStyleClasses } from "./style-classes.ts"
import { validateAndFixXml } from "./xml-validation.ts"
// DOMParser/XMLSerializer globals for the XML helpers (Node has neither)
@@ -336,11 +342,11 @@ Before using icon shapes (AWS, Azure, GCP, Kubernetes, Cisco...), call get_shape
Accepted xml:
1) Only the mxCell elements of one page (recommended). The server adds <mxfile>, <mxGraphModel>, <root> and the root cells "0" and "1":
<mxCell id="2" value="Shape" style="rounded=1;whiteSpace=wrap;html=1;" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="60" as="geometry"/></mxCell>
<mxCell id="2" value="Shape" style="rounded=1;" x="40" y="40" w="120" h="60"/>
2) A bare <mxGraphModel> with <root> (one page).
3) A full <mxfile> with one or more <diagram> pages. Every page's <root> must start with <mxCell id="0"/><mxCell id="1" parent="0"/>.
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent="1" for top-level shapes, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600.`,
Rules: cells are siblings (never nested), ids are unique per page and start from "2", parent only for shapes inside a container, no XML comments, and shapes stay within x 0 to 800 and y 0 to 600. A style used by several cells is defined once with <mxStyle name="..." value="..."/> before the cells and used by name (see the drawing guide); html=1 and whiteSpace=wrap are added automatically.`,
inputSchema: {
xml: z
.string()
@@ -589,10 +595,10 @@ registerWriteTool(
"- add: Add a new cell. Provide cell_id (new unique id within the page) and new_xml. One cell per operation.\n" +
"- update: Replace an existing cell by its id. Provide cell_id and complete new_xml.\n" +
"- delete: Remove a cell by its id. Only cell_id is needed. Its children and connected edges are deleted too, so give only a container's id.\n\n" +
"For add/update, new_xml must be a complete mxCell element including mxGeometry. No XML comments. " +
"For add/update, new_xml is the complete mxCell in the compact form (a shape with x, y, w, h; an edge with source and target). No XML comments. " +
'Every " inside new_xml must be escaped as \\" in the JSON.\n\n' +
"Example - Add a rectangle on the default (first) page:\n" +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=0;\\" vertex=\\"1\\" parent=\\"1\\"><mxGeometry x=\\"100\\" y=\\"100\\" width=\\"120\\" height=\\"60\\" as=\\"geometry\\"/></mxCell>"}]}\n\n' +
'{"operations": [{"operation": "add", "cell_id": "rect-1", "new_xml": "<mxCell id=\\"rect-1\\" value=\\"Hello\\" style=\\"rounded=1;\\" x=\\"100\\" y=\\"100\\" w=\\"120\\" h=\\"60\\"/>"}]}\n\n' +
"Example - Delete a cell on the default page:\n" +
'{"operations": [{"operation": "delete", "cell_id": "rect-1"}]}',
inputSchema: {
@@ -700,7 +706,7 @@ registerWriteTool(
content: [
{
type: "text",
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\n${next}`,
text: `Error: ${reason}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\n${next}`,
},
],
isError: true,
@@ -720,7 +726,7 @@ registerWriteTool(
log.warn(`Edit rejected: ${outcome.errors.join("; ")}`)
const text = outcome.pageError
? `Error: ${outcome.errors[0]}`
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${targetPageXml(currentSession.xml, pageSelector)}\n\nFix the operations against this XML and retry.`
: `Error: No changes were made because ${outcome.errors.length} operation(s) failed:\n${outcome.errors.map((e) => `- ${e}`).join("\n")}\n\nCurrent XML of ${describeSelector(pageSelector)}:\n\n${foldCells(targetPageXml(currentSession.xml, pageSelector))}\n\nFix the operations against this XML and retry.`
return {
content: [{ type: "text", text }],
isError: true,
@@ -869,7 +875,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Current diagram XML:\n\n${session.xml}\n\n${pageList}${staleNote}`,
text: `Current diagram XML:\n\n${foldCells(session.xml)}\n\n${pageList}${staleNote}`,
},
],
}
@@ -906,7 +912,7 @@ server.registerTool(
content: [
{
type: "text",
text: `Page ${projection.index} ("${projection.name}"):\n\n${projection.xml}\n\n${pageList}${staleNote}${otherPagesNote}`,
text: `Page ${projection.index} ("${projection.name}"):\n\n${foldCells(projection.xml)}\n\n${pageList}${staleNote}${otherPagesNote}`,
},
],
}
@@ -1587,14 +1593,28 @@ registerWriteTool(
// If caller provided XML, validate it before splicing it in so we
// never get a half-broken mxfile written to the session.
const reserved = xml && reservedIdError(xml)
// Named style definitions come out first and are expanded on the
// validated XML, like prepareNewDiagram
const {
classes,
xml: startXml,
error: styleError,
} = takeStyleDefinitions(xml ?? "")
if (styleError) {
return {
content: [{ type: "text", text: `Error: ${styleError}` }],
isError: true,
}
}
const reserved = startXml && reservedIdError(startXml)
if (reserved) {
return {
content: [{ type: "text", text: `Error: ${reserved}` }],
isError: true,
}
}
let cleanXml: string | undefined = xml && wrapCellsInModel(xml)
let cleanXml: string | undefined =
startXml && wrapCellsInModel(startXml)
if (cleanXml) {
const { valid, error, fixed, fixes } =
validateAndFixXml(cleanXml)
@@ -1615,6 +1635,9 @@ registerWriteTool(
isError: true,
}
}
cleanXml = addDefaultStyles(
applyStyleClasses(expandCompactCells(cleanXml), classes),
)
}
let info
+62 -9
View File
@@ -2,8 +2,16 @@
* A whole new diagram written by the model, for the create_new_diagram tool
* and the web app's display_diagram tool.
*/
import { normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
import { expandCompactCells } from "./compact-cells.ts"
import { hasCells, normalizeToMxfile, wrapCellsInModel } from "./pages.ts"
import {
addDefaultStyles,
applyStyleClasses,
readStyleClasses,
type StyleClasses,
} from "./style-classes.ts"
import { readAttributes } from "./xml-attributes.ts"
import { getXmlSyntaxError } from "./xml-syntax.ts"
import { validateAndFixXml } from "./xml-validation.ts"
export type NewDiagram =
@@ -29,11 +37,15 @@ export function reservedIdError(input: string): string | null {
)
const id = attrs.get("id")
if (id !== "0" && id !== "1") continue
// A wrapper's id is its cell's; an mxCell counts as a shape or edge
// A wrapper's id is its cell's; an mxCell counts as a shape or edge,
// also when written compactly (a size, or a connection)
if (
tag !== "mxCell" ||
attrs.get("vertex") === "1" ||
attrs.get("edge") === "1"
attrs.get("edge") === "1" ||
["x", "y", "w", "h", "width", "height", "source", "target"].some(
(a) => attrs.has(a),
)
) {
return 'Cell ids "0" and "1" are the root cells, which are added automatically. Give shapes and edges ids starting at "2".'
}
@@ -42,23 +54,64 @@ export function reservedIdError(input: string): string | null {
}
/**
* Bare cells get the wrapper and root cells first, since the strict parser
* rejects several top-level elements. Then the XML is validated and
* auto-fixed while it is still a bare model, where duplicate ids are
* renamed, and finally turned into an <mxfile>.
* The named style definitions are taken out first (style-classes.ts). Bare
* cells then get the wrapper and root cells, since the strict parser rejects
* several top-level elements. Then the XML is validated and auto-fixed while
* it is still a bare model, where duplicate ids are renamed. Compact cells
* (compact-cells.ts) are expanded, the names expanded and the default styles
* added on the fixed XML, so repaired cells get them too, and finally it is
* turned into an <mxfile>.
*/
/**
* Take the named style definitions out of the model's XML (style-classes.ts).
* Returns the error for the model when a definition never closed or when
* nothing but definitions was sent.
*/
export function takeStyleDefinitions(input: string): {
classes: StyleClasses
xml: string
error: string | null
} {
const { classes, xml } = readStyleClasses(input)
let error: string | null = null
if (/<mxStyle\b/i.test(xml)) {
error =
'A named style definition is not closed. Write it as <mxStyle name="..." value="..."/> before the cells.'
} else if (classes.size > 0 && !hasCells(xml)) {
error =
"Only named style definitions were sent, no cells. Send the mxCell elements after the definitions."
}
return { classes, xml, error }
}
export function prepareNewDiagram(
input: string,
page: { pageId?: string; pageName?: string } = {},
): NewDiagram {
const reserved = reservedIdError(input)
const {
classes,
xml: cells,
error: styleError,
} = takeStyleDefinitions(input)
if (styleError) return { ok: false, error: styleError }
const reserved = reservedIdError(cells)
if (reserved) return { ok: false, error: reserved }
let xml = wrapCellsInModel(input)
let xml = wrapCellsInModel(cells)
const { valid, error, fixed, fixes } = validateAndFixXml(xml)
if (fixed) xml = fixed
if (!valid) {
return { ok: false, error: `XML validation failed - ${error}` }
}
xml = addDefaultStyles(applyStyleClasses(expandCompactCells(xml), classes))
// The rewrites copy attribute values as written; a check that they
// produced well-formed XML, in case one of them ever does not
const rewriteError = getXmlSyntaxError(xml)
if (rewriteError) {
return {
ok: false,
error: `XML validation failed after expanding the cells - ${rewriteError}`,
}
}
const normalized = normalizeToMxfile(xml, page)
if (!normalized) {
return {
+232
View File
@@ -0,0 +1,232 @@
/**
* Named styles, written once and used by name like CSS classes.
*
* The model defines a style before the cells and refers to it from any
* number of cells, so a style string is not repeated in every cell:
*
* <mxStyle name="blue" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
* <mxCell id="2" value="A" style="rounded=1;blue;" vertex="1" parent="1">...</mxCell>
* <mxCell id="3" value="B" style="rhombus;blue;fontSize=14;" vertex="1" parent="1">...</mxCell>
*
* draw.io resolves a style token without "=" through its stylesheet, which
* is not saved in the file, so the names are expanded here and the saved
* XML is plain draw.io XML. A name without a definition is left as it is:
* draw.io ignores it, unless its own stylesheet knows the name (text,
* ellipse, label, blue, green, ...), which then keeps its meaning.
*
* html=1 and whiteSpace=wrap, which draw.io itself puts on every new shape,
* are added here too, so the model never has to write them.
*
* The callers validate and auto-fix the XML first and rewrite the styles
* after, so a repaired cell (quotes written as entities, a lowercase tag)
* gets its defaults too, and the rewrite only ever sees proper attributes.
*/
import { readAttributes } from "./xml-attributes.ts"
export type StyleClasses = Map<string, string>
// <mxStyle name="..." value="..."/> with the whitespace after it, also when
// written as <mxStyle ...> or <mxStyle ...></mxStyle>; quoted values may hold ">"
const STYLE_DEFINITION =
/<mxStyle\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*\/?>(?:\s*<\/mxStyle>)?\s*/gi
// An mxCell opening tag; the attributes stop before a closing "/" so a
// self-closing cell keeps its "/>"
const CELL_TAG = /<mxCell\b((?:[^<>"']|"[^"]*"|'[^']*')*?)\s*(\/?)>/g
// draw.io reads keys and tokens as written, " html" is not "html", so the
// checks are exact too
/** Whether the style sets the key (as `key=` at the start or after a ";") */
const hasKey = (style: string, key: string) =>
new RegExp(`(^|;)${key}=`).test(style)
/** Whether the style holds this exact token */
const hasToken = (style: string, token: string) =>
style.split(";").includes(token)
/**
* Replace the names among the tokens with their definitions. The tokens are
* kept exactly as written, empty ones included: a leading ";" is draw.io's
* "no default style" marker, and an entity such as &quot; ends in ";" too.
* A name whose definition is empty is dropped, so it does not leave such a
* marker behind.
*/
function applyToStyle(style: string, classes: StyleClasses): string {
const out: string[] = []
for (const token of style.split(";")) {
const definition = token.includes("=")
? undefined
: classes.get(token.trim())
if (definition === undefined) out.push(token)
else if (definition !== "") out.push(definition)
}
return out.join(";")
}
/**
* A definition's value goes into a style attribute unchecked by the XML
* validator (the definitions are taken out before it runs), so the two
* characters that would break the attribute are escaped here, the way the
* validator repairs them in cells.
*/
function escapeForAttribute(value: string): string {
return value
.replace(
/&(?!(?:lt|gt|amp|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/g,
"&amp;",
)
.replace(/</g, "&lt;")
}
/**
* The definitions, resolved against each other, and the XML without them.
* A definition may use names defined before or after it; a name using
* itself stays a bare token.
*/
export function readStyleClasses(xml: string): {
classes: StyleClasses
xml: string
} {
const classes: StyleClasses = new Map()
const rest = xml.replace(STYLE_DEFINITION, (_match, attrText: string) => {
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
// "name" may end in the ";" the model is used to; "style" is the
// attribute the model may reach for instead of "value"
const name = attrs.get("name")?.trim().replace(/;+$/, "")
const value = attrs.get("value") ?? attrs.get("style")
if (name && value !== undefined) {
// Without the trailing ";", so a name followed by ";" in a cell
// expands to "...;" and not to "...;;"
classes.set(name, escapeForAttribute(value).replace(/;+$/, ""))
}
return ""
})
// Two passes resolve names used by other definitions, in either order
for (let pass = 0; pass < 2; pass++) {
for (const [name, value] of classes) {
const others = new Map(classes)
others.delete(name)
classes.set(name, applyToStyle(value, others))
}
}
return { classes, xml: rest }
}
/**
* Rewrite the style of every mxCell. The callback returns the new style, or
* undefined to leave the cell alone. A cell without a style attribute gets
* one when the callback returns a style for it.
*/
function rewriteCellStyles(
xml: string,
rewrite: (
style: string | undefined,
attrs: Map<string, string>,
) => string | undefined,
): string {
return xml.replace(CELL_TAG, (tag, attrText: string, selfClose: string) => {
const attributes = readAttributes(attrText)
const attrs = new Map(attributes.map((a) => [a.name, a.value]))
const style = attributes.find((a) => a.name === "style")
const next = rewrite(style?.value, attrs)
if (next === undefined || next === style?.value) return tag
// The value is copied from attributes as written, so only a quote
// from a single-quoted source needs escaping
const written = `style="${next.replace(/"/g, "&quot;")}"`
if (!style) {
return `<mxCell${attrText} ${written}${selfClose}>`
}
// Keep the attribute where it was, with the whitespace before it
const space =
attrText.slice(style.start, style.end).match(/^\s*/)?.[0] ?? " "
return `<mxCell${attrText.slice(0, style.start)}${space}${written}${attrText.slice(style.end)}${selfClose}>`
})
}
/** Expand the names in every cell's style; the overrides after a name still win */
export function applyStyleClasses(xml: string, classes: StyleClasses): string {
if (classes.size === 0) return xml
return rewriteCellStyles(xml, (style) =>
style === undefined ? undefined : applyToStyle(style, classes),
)
}
/** The ids of the edges in the XML, whose child vertices are edge labels */
export function edgeIdsOf(xml: string): Set<string> {
const ids = new Set<string>()
for (const [, attrText] of xml.matchAll(CELL_TAG)) {
const attrs = new Map(
readAttributes(attrText).map((a) => [a.name, a.value]),
)
const id = attrs.get("id")
if (id && attrs.get("edge") === "1") ids.add(id)
}
return ids
}
/**
* Whether the label sits outside the shape, as with library icons (their
* styles position the label below) and images, so wrapping it to the
* shape's width would be wrong.
*/
function hasLabelOutside(style: string): boolean {
return (
hasKey(style, "verticalLabelPosition") ||
hasKey(style, "labelPosition") ||
hasKey(style, "image") ||
hasToken(style, "shape=image")
)
}
/**
* draw.io's own defaults for a new shape: html=1 (so labels may hold <br>
* and <b>) and whiteSpace=wrap. Wrapping is not added to shapes whose label
* sits outside (icons, images), to text that sizes itself (autosize=1), to
* edge labels (their label box has no width of its own) or next to html=0
* (draw.io renders a wrapping label as HTML). Edges get html=1. Root cells
* and cells that already set a key are left alone.
*
* An edit adds cells to a page the XML does not show, so the caller passes
* the ids of the edges already on it (edgeIdsOf), or new edge labels would
* be wrapped like shapes.
*/
export function addDefaultStyles(
xml: string,
knownEdgeIds: Iterable<string> = [],
): string {
const edges = new Set([...knownEdgeIds, ...edgeIdsOf(xml)])
return rewriteCellStyles(xml, (style, attrs) => {
// A cell marked as both is drawn as an edge
const isEdge = attrs.get("edge") === "1"
const isVertex = attrs.get("vertex") === "1" && !isEdge
if (!isVertex && !isEdge) return undefined
const current = style ?? ""
const isEdgeLabel =
hasToken(current, "edgeLabel") ||
attrs.get("connectable") === "0" ||
edges.has(attrs.get("parent") ?? "")
const added: string[] = []
if (
isVertex &&
!hasKey(current, "whiteSpace") &&
!hasLabelOutside(current) &&
!isEdgeLabel &&
!hasToken(current, "autosize=1") &&
!hasToken(current, "html=0")
) {
added.push("whiteSpace=wrap")
}
if (!hasKey(current, "html")) added.push("html=1")
if (added.length === 0) return undefined
const separator = current === "" || current.endsWith(";") ? "" : ";"
return `${current}${separator}${added.join(";")};`
})
}
/** Expand the named styles of a model's XML and add the default styles */
export function expandStyles(xml: string): string {
const { classes, xml: cells } = readStyleClasses(xml)
return addDefaultStyles(applyStyleClasses(cells, classes))
}
+17 -22
View File
@@ -3,30 +3,16 @@
* prompt and tool descriptions, so both teach the model the same thing.
*/
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="40" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step1" value="Step 1" style="rounded=1;" vertex="1" parent="lane1">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="lane2" value="Backend" style="swimlane;" vertex="1" parent="1">
<mxGeometry x="280" y="40" width="200" height="200" as="geometry"/>
</mxCell>
<mxCell id="step2" value="Step 2" style="rounded=1;" vertex="1" parent="lane2">
<mxGeometry x="20" y="60" width="160" height="40" as="geometry"/>
</mxCell>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;endArrow=classic;" edge="1" parent="1" source="step1" target="step2">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`
export const SWIMLANE_EXAMPLE = `<mxCell id="lane1" value="Frontend" style="swimlane;" x="40" y="40" w="200" h="200"/>
<mxCell id="step1" value="Step 1" style="rounded=1;" parent="lane1" x="20" y="60" w="160" h="40"/>
<mxCell id="lane2" value="Backend" style="swimlane;" x="280" y="40" w="200" h="200"/>
<mxCell id="step2" value="Step 2" style="rounded=1;" parent="lane2" x="20" y="60" w="160" h="40"/>
<mxCell id="edge1" style="edgeStyle=orthogonalEdgeStyle;" source="step1" target="step2"/>`
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;endArrow=classic;" edge="1" parent="1" source="a" target="b">
<mxGeometry relative="1" as="geometry"/>
</mxCell>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;endArrow=classic;" edge="1" parent="1" source="b" target="a">
<mxGeometry relative="1" as="geometry"/>
</mxCell>`
export const TWO_EDGES_EXAMPLE = `<mxCell id="e1" value="A to B" style="edgeStyle=orthogonalEdgeStyle;exitX=1;exitY=0.3;entryX=0;entryY=0.3;" source="a" target="b"/>
<mxCell id="e2" value="B to A" style="edgeStyle=orthogonalEdgeStyle;exitX=0;exitY=0.7;entryX=1;entryY=0.7;" source="b" target="a"/>`
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;endArrow=classic;" edge="1" parent="1" source="hotfix" target="main">
export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=0;entryX=1;entryY=0.5;" edge="1" parent="1" source="hotfix" target="main">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="750" y="80"/>
@@ -35,6 +21,15 @@ export const WAYPOINT_EXAMPLE = `<mxCell id="hotfix_to_main" style="edgeStyle=or
</mxGeometry>
</mxCell>`
/** Named styles defined once and used by name, like CSS classes */
export const STYLE_CLASS_EXAMPLE = `<mxStyle name="step" value="fillColor=#dae8fc;strokeColor=#6c8ebf;"/>
<mxStyle name="down" value="edgeStyle=orthogonalEdgeStyle;exitX=0.5;exitY=1;entryX=0.5;entryY=0;"/>
<mxCell id="2" value="Start" style="rounded=1;step;" x="40" y="40" w="120" h="60"/>
<mxCell id="3" value="Check" style="rhombus;step;fontStyle=1;" x="40" y="160" w="120" h="80"/>
<mxCell id="4" value="Done" style="rounded=1;step;" x="40" y="300" w="120" h="60"/>
<mxCell id="5" style="down;" source="2" target="3"/>
<mxCell id="6" style="down;" source="3" target="4"/>`
/** Indent every line, for an indented code block in Markdown */
export const indent = (text: string, prefix = " ") =>
text
+78 -13
View File
@@ -334,22 +334,25 @@ function checkEntityReferences(xml: string): string | null {
/** Check for nested mxCell tags using regex */
function checkNestedMxCells(xml: string): string | null {
const cellTagPattern = /<\/?mxCell[^>]*>/g
// Quoted values may hold ">", so the tag ends at the first ">" outside them
const cellTagPattern = /<\/?mxCell\b(?:[^<>"']|"[^"]*"|'[^']*')*>/g
const cellStack: number[] = []
let cellMatch
while ((cellMatch = cellTagPattern.exec(xml)) !== null) {
const tag = cellMatch[0]
const nested =
"Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
const isLabelOrGeometry =
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
if (tag.startsWith("</mxCell>")) {
if (cellStack.length > 0) cellStack.pop()
} else if (!tag.endsWith("/>")) {
const isLabelOrGeometry =
/\sas\s*=\s*["'](valueLabel|geometry)["']/.test(tag)
if (!isLabelOrGeometry) {
cellStack.push(cellMatch.index)
if (cellStack.length > 1) {
return "Invalid XML: Found nested mxCell tags. Cells should be siblings, not nested inside other mxCell elements."
}
}
} else if (tag.endsWith("/>")) {
// A self-closing cell inside an open one is nested too (or the
// open one lacks its </mxCell>; the auto-fix sorts that out)
if (cellStack.length > 0 && !isLabelOrGeometry) return nested
} else if (!isLabelOrGeometry) {
cellStack.push(cellMatch.index)
if (cellStack.length > 1) return nested
}
}
return null
@@ -556,6 +559,19 @@ export function validateMxCellStructure(
* @param xml - The XML string to fix
* @returns Object with fixed XML and list of fixes applied
*/
/**
* A compact cell's last attribute is a number, and the model sometimes
* drops its closing quote: h="112/>. Only a cell whose earlier attributes
* are all properly quoted is touched, so a label that happens to contain
* such text is left alone.
*/
export function repairQuoteBeforeSlash(xml: string): string {
return xml.replace(
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)(\s+(?:x|y|w|h|width|height)="-?\d+(?:\.\d+)?)\s*\/>/g,
'<mxCell$1$2"/>',
)
}
export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
let fixed = xml
const fixes: string[] = []
@@ -657,6 +673,29 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
fixes.push("Fixed malformed attribute quotes")
}
// 6b. Missing closing quote on the last attribute of a compact cell
// (h="112/>), see repairQuoteBeforeSlash
const quoted = repairQuoteBeforeSlash(fixed)
if (quoted !== fixed) {
fixed = quoted
fixes.push("Added a missing closing quote before />")
}
// 6c. A compact cell written without the slash: <mxCell ... h="60">
// followed by the next cell, a definition or the end. Only cells that
// carry a size or a connection, so an open container stays open.
const slashless =
/<mxCell\b((?:\s+[\w:.-]+="[^"]*")*)\s*>(?=\s*(?:<mxCell\b|<mxStyle\b|$))/g
const closed = fixed.replace(slashless, (tag, attrText: string) =>
/\s(?:x|y|w|h|width|height|source|target)="/.test(attrText)
? `<mxCell${attrText}/>`
: tag,
)
if (closed !== fixed) {
fixed = closed
fixes.push("Closed compact cells written without the slash")
}
// 7. Fix malformed closing tags
const malformedClosingTag = /<\/([a-zA-Z][a-zA-Z0-9]*)\s*\/>/g
if (malformedClosingTag.test(fixed)) {
@@ -987,16 +1026,39 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
/<mxCell\s/.test(trimmed) &&
!trimmed.endsWith("/>") &&
!trimmed.endsWith("</mxCell>")
// A self-closing (compact) cell is the next sibling too
const isSelfClosingCell =
/<mxCell\s/.test(trimmed) && trimmed.endsWith("/>")
const isCloseCell = trimmed === "</mxCell>"
// The last cell's closing tag may share its line with the wrapper's
const startsWithClose = !isCloseCell && trimmed.startsWith("</mxCell>")
if (isOpenCell) {
if (isOpenCell || isSelfClosingCell) {
if (cellDepth > 0) {
const indent = line.match(/^(\s*)/)?.[1] || ""
newLines.push(indent + "</mxCell>")
trueNestedFixed++
pendingCloseRemoval++
// The open cell's own </mxCell> is removed later, when there
// is one before the next open cell; a cell that simply lacks
// its closing tag has none to remove
const nextOpen = lines2.findIndex(
(l, j) =>
j > i &&
/<mxCell\s/.test(l.trim()) &&
!l.trim().endsWith("/>"),
)
const nextClose = lines2.findIndex(
(l, j) => j > i && l.trim().startsWith("</mxCell>"),
)
if (
isOpenCell ||
(nextClose !== -1 &&
(nextOpen === -1 || nextClose < nextOpen))
) {
pendingCloseRemoval++
}
}
cellDepth = 1
cellDepth = isOpenCell ? 1 : 0
newLines.push(line)
} else if (isCloseCell) {
if (pendingCloseRemoval > 0) {
@@ -1005,6 +1067,9 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
cellDepth = Math.max(0, cellDepth - 1)
newLines.push(line)
}
} else if (startsWithClose && pendingCloseRemoval > 0) {
pendingCloseRemoval--
newLines.push(line.replace("</mxCell>", ""))
} else {
newLines.push(line)
}