mirror of
https://github.com/DayuanJiang/next-ai-draw-io.git
synced 2026-10-08 18:57:47 +08:00
fix(mcp-server): reject text between tags, which draw.io cannot open
draw.io reads any text inside a page as compressed page data, so a stray text node makes the whole page fail with an atob error. gpt-5-mini sends new cells with a literal "\n" between the tags; the edit card said Complete while draw.io showed the error and kept the old diagram. Validation now reports text between tags, and auto-fix turns a literal \n, \t or \r between tags into whitespace. Other text goes back to the model as an error. The compressed data directly under <diagram> is fine.
This commit is contained in:
@@ -404,6 +404,21 @@ function findOrphanMxPoints(
|
||||
* <mxPoint>s. Used for XML the model wrote, not for files or browser state.
|
||||
* @returns null if valid, error message string if invalid
|
||||
*/
|
||||
/** The first non-blank text under el, skipping a page's compressed data */
|
||||
function findTextBetweenTags(el: Element | null): string | null {
|
||||
if (!el) return null
|
||||
for (const node of Array.from(el.childNodes)) {
|
||||
if (node.nodeType === 1) {
|
||||
const text = findTextBetweenTags(node as Element)
|
||||
if (text) return text
|
||||
} else if (node.nodeType === 3 && el.tagName !== "diagram") {
|
||||
const text = node.textContent?.trim()
|
||||
if (text) return text.slice(0, 40)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateMxCellStructure(
|
||||
xml: string,
|
||||
opts: { strict?: boolean } = {},
|
||||
@@ -429,6 +444,15 @@ export function validateMxCellStructure(
|
||||
return `Invalid XML: Found nested mxCell (id="${id}"). Cells should be siblings, not nested inside other mxCell elements.`
|
||||
}
|
||||
}
|
||||
|
||||
// draw.io reads any text inside a page as compressed page data and
|
||||
// then fails to open the page
|
||||
if (!doc.querySelector("parsererror")) {
|
||||
const text = findTextBetweenTags(doc.documentElement)
|
||||
if (text) {
|
||||
return `Invalid XML: Found text "${text}" between tags. Labels belong in the value attribute; remove any other text between tags.`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"[validateMxCellStructure] DOMParser threw unexpected error, falling back to regex validation:",
|
||||
@@ -542,6 +566,15 @@ export function autoFixXml(xml: string): { fixed: string; fixes: string[] } {
|
||||
fixes.push("Fixed JSON-escaped XML")
|
||||
}
|
||||
|
||||
// 0b. Literal \n, \t or \r between tags, from escaping the XML twice
|
||||
const unescaped = fixed.replace(/>(?:\s|\\[nrt])+</g, (gap) =>
|
||||
gap.replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\r/g, ""),
|
||||
)
|
||||
if (unescaped !== fixed) {
|
||||
fixed = unescaped
|
||||
fixes.push("Replaced literal \\n between tags with line breaks")
|
||||
}
|
||||
|
||||
// 1. Remove CDATA wrapper
|
||||
if (/^\s*<!\[CDATA\[/.test(fixed)) {
|
||||
fixed = fixed.replace(/^\s*<!\[CDATA\[/, "").replace(/\]\]>\s*$/, "")
|
||||
|
||||
@@ -110,6 +110,19 @@ describe("editDiagram", () => {
|
||||
expect(out.ok).toBe(true)
|
||||
})
|
||||
|
||||
it("fixes a literal \\n between tags, as gpt-5-mini sends it", () => {
|
||||
const newXml = `<mxCell id="c" value="Reset password" vertex="1" parent="1">\\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\\n</mxCell>`
|
||||
const out = editDiagram(
|
||||
DOC,
|
||||
[{ operation: "add", cell_id: "c", new_xml: newXml }],
|
||||
{},
|
||||
)
|
||||
expect(out.ok).toBe(true)
|
||||
if (!out.ok) return
|
||||
expect(out.xml).toContain('value="Reset password"')
|
||||
expect(out.xml).not.toContain("\\n")
|
||||
})
|
||||
|
||||
it("reports a missing page as a page-level error", () => {
|
||||
const out = editDiagram(DOC, [{ operation: "delete", cell_id: "a" }], {
|
||||
page_id: "nope",
|
||||
|
||||
@@ -212,7 +212,7 @@ describe("autoFixXml keeps valid tags", () => {
|
||||
|
||||
it("removes a stray <a> without touching <Array> waypoints", () => {
|
||||
const edge = `<mxCell id="e" edge="1" parent="1"><mxGeometry relative="1" as="geometry"><Array as="points"><mxPoint x="1" y="2"/></Array></mxGeometry></mxCell>`
|
||||
const r = validateAndFixXml(model(`<a>x</a>${edge}`))
|
||||
const r = validateAndFixXml(model(`<a></a>${edge}`))
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toContain('<Array as="points">')
|
||||
expect(r.fixed).toContain('<mxPoint x="1" y="2"/>')
|
||||
@@ -277,3 +277,34 @@ describe("validateAndFixXml strict checks", () => {
|
||||
expect(r.fixed).toContain('<mxPoint x="1" y="2"/>')
|
||||
})
|
||||
})
|
||||
|
||||
describe("text between tags", () => {
|
||||
// draw.io reads any text inside a page as compressed data, so the whole
|
||||
// page fails to open with an atob error
|
||||
it("turns a literal \\n between tags into a line break", () => {
|
||||
const cell = `<mxCell id="3" value="Reset password" vertex="1" parent="1">\\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\\n</mxCell>`
|
||||
const r = validateAndFixXml(cell)
|
||||
expect(r.valid).toBe(true)
|
||||
expect(r.fixed).toBe(
|
||||
`<mxCell id="3" value="Reset password" vertex="1" parent="1">\n <mxGeometry x="0" y="0" width="80" height="40" as="geometry"/>\n</mxCell>`,
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects other text between tags", () => {
|
||||
const r = validateAndFixXml(
|
||||
model(
|
||||
`<mxCell id="3" vertex="1" parent="1">Reset password<mxGeometry as="geometry"/></mxCell>`,
|
||||
),
|
||||
)
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.error).toMatch(/Reset password/)
|
||||
})
|
||||
|
||||
it("accepts a compressed page", () => {
|
||||
expect(
|
||||
validateAndFixXml(
|
||||
`<mxfile><diagram id="p" name="P">dZHBDoIwDIafhjtsGPWM6MkTB8/LVmBxrGQMQZ/eLRuIUS/bv/VfmybF</diagram></mxfile>`,
|
||||
).valid,
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user