2025-12-06 12:46:40 +09:00
import { type ClassValue , clsx } from "clsx"
import * as pako from "pako"
2025-03-19 06:04:06 +00:00
import { twMerge } from "tailwind-merge"
export function cn ( . . . inputs : ClassValue [ ] ) {
2025-12-06 12:46:40 +09:00
return twMerge ( clsx ( inputs ) )
2025-03-19 06:04:06 +00:00
}
2025-03-22 15:45:49 +00:00
2025-08-31 20:52:04 +09:00
/ * *
* Format XML string with proper indentation and line breaks
* @param xml - The XML string to format
* @param indent - The indentation string ( default : ' ' )
* @returns Formatted XML string
* /
2025-12-06 12:46:40 +09:00
export function formatXML ( xml : string , indent : string = " " ) : string {
let formatted = ""
let pad = 0
// Remove existing whitespace between tags
xml = xml . replace ( />\s*</g , "><" ) . trim ( )
// Split on tags
const tags = xml . split ( /(?=<)|(?<=>)/g ) . filter ( Boolean )
tags . forEach ( ( node ) = > {
if ( node . match ( /^<\/\w/ ) ) {
// Closing tag - decrease indent
pad = Math . max ( 0 , pad - 1 )
formatted += indent . repeat ( pad ) + node + "\n"
} else if ( node . match ( /^<\w[^>]*[^/]>.*$/ ) ) {
// Opening tag
formatted += indent . repeat ( pad ) + node
// Only add newline if next item is a tag
const nextIndex = tags . indexOf ( node ) + 1
if ( nextIndex < tags . length && tags [ nextIndex ] . startsWith ( "<" ) ) {
formatted += "\n"
if ( ! node . match ( /^<\w[^>]*\/>$/ ) ) {
pad ++
}
}
} else if ( node . match ( /^<\w[^>]*\/>$/ ) ) {
// Self-closing tag
formatted += indent . repeat ( pad ) + node + "\n"
} else if ( node . startsWith ( "<" ) ) {
// Other tags (like <?xml)
formatted += indent . repeat ( pad ) + node + "\n"
} else {
// Text content
formatted += node
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
} )
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
return formatted . trim ( )
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
/ * *
2025-03-25 08:56:24 +00:00
* Efficiently converts a potentially incomplete XML string to a legal XML string by closing any open tags properly .
* Additionally , if an < mxCell > tag does not have an mxGeometry child ( e . g . < mxCell id = "3" > ) ,
* it removes that tag from the output .
2025-12-07 00:40:19 +09:00
* Also removes orphaned < mxPoint > elements that aren 't inside <Array> or don' t have proper 'as' attribute .
2025-03-22 15:45:49 +00:00
* @param xmlString The potentially incomplete XML string
2025-03-25 08:56:24 +00:00
* @returns A legal XML string with properly closed tags and removed incomplete mxCell elements .
2025-03-22 15:45:49 +00:00
* /
export function convertToLegalXml ( xmlString : string ) : string {
2025-12-06 12:46:40 +09:00
// This regex will match either self-closing <mxCell .../> or a block element
// <mxCell ...> ... </mxCell>. Unfinished ones are left out because they don't match.
const regex = /<mxCell\b[^>]*(?:\/>|>([\s\S]*?)<\/mxCell>)/g
let match : RegExpExecArray | null
let result = "<root>\n"
while ( ( match = regex . exec ( xmlString ) ) !== null ) {
// match[0] contains the entire matched mxCell block
2025-12-07 00:40:19 +09:00
let cellContent = match [ 0 ]
// Remove orphaned <mxPoint> elements that are directly inside <mxGeometry>
// without an 'as' attribute (like as="sourcePoint", as="targetPoint")
// and not inside <Array as="points">
// These cause "Could not add object mxPoint" errors in draw.io
// First check if there's an <Array as="points"> - if so, keep all mxPoints inside it
const hasArrayPoints = /<Array\s+as="points">/ . test ( cellContent )
if ( ! hasArrayPoints ) {
// Remove mxPoint elements without 'as' attribute
cellContent = cellContent . replace (
/<mxPoint\b[^>]*\/>/g ,
( pointMatch ) = > {
// Keep if it has an 'as' attribute
if ( /\sas=/ . test ( pointMatch ) ) {
return pointMatch
}
// Remove orphaned mxPoint
return ""
} ,
)
}
2025-12-06 12:46:40 +09:00
// Indent each line of the matched block for readability.
2025-12-07 00:40:19 +09:00
const formatted = cellContent
2025-12-06 12:46:40 +09:00
. split ( "\n" )
. map ( ( line ) = > " " + line . trim ( ) )
2025-12-07 00:40:19 +09:00
. filter ( ( line ) = > line . trim ( ) ) // Remove empty lines from removed mxPoints
2025-12-06 12:46:40 +09:00
. join ( "\n" )
result += formatted + "\n"
}
result += "</root>"
2025-03-22 15:45:49 +00:00
2025-12-06 12:46:40 +09:00
return result
}
2025-03-25 08:56:24 +00:00
/ * *
* Replace nodes in a Draw . io XML diagram
* @param currentXML - The original Draw . io XML string
* @param nodes - The XML string containing new nodes to replace in the diagram
* @returns The updated XML string with replaced nodes
* /
export function replaceNodes ( currentXML : string , nodes : string ) : string {
2025-12-06 12:46:40 +09:00
// Check for valid inputs
if ( ! currentXML || ! nodes ) {
throw new Error ( "Both currentXML and nodes must be provided" )
2025-03-25 08:56:24 +00:00
}
2025-12-06 12:46:40 +09:00
try {
// Parse the XML strings to create DOM objects
const parser = new DOMParser ( )
const currentDoc = parser . parseFromString ( currentXML , "text/xml" )
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
// Handle nodes input - if it doesn't contain <root>, wrap it
let nodesString = nodes
if ( ! nodes . includes ( "<root>" ) ) {
nodesString = ` <root> ${ nodes } </root> `
}
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
const nodesDoc = parser . parseFromString ( nodesString , "text/xml" )
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
// Find the root element in the current document
let currentRoot = currentDoc . querySelector ( "mxGraphModel > root" )
if ( ! currentRoot ) {
// If no root element is found, create the proper structure
const mxGraphModel =
currentDoc . querySelector ( "mxGraphModel" ) ||
currentDoc . createElement ( "mxGraphModel" )
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
if ( ! currentDoc . contains ( mxGraphModel ) ) {
currentDoc . appendChild ( mxGraphModel )
}
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
currentRoot = currentDoc . createElement ( "root" )
mxGraphModel . appendChild ( currentRoot )
}
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
// Find the root element in the nodes document
const nodesRoot = nodesDoc . querySelector ( "root" )
if ( ! nodesRoot ) {
throw new Error (
"Invalid nodes: Could not find or create <root> element" ,
)
}
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
// Clear all existing child elements from the current root
while ( currentRoot . firstChild ) {
currentRoot . removeChild ( currentRoot . firstChild )
}
// Ensure the base cells exist
const hasCell0 = Array . from ( nodesRoot . childNodes ) . some (
( node ) = >
node . nodeName === "mxCell" &&
( node as Element ) . getAttribute ( "id" ) === "0" ,
)
const hasCell1 = Array . from ( nodesRoot . childNodes ) . some (
( node ) = >
node . nodeName === "mxCell" &&
( node as Element ) . getAttribute ( "id" ) === "1" ,
)
// Copy all child nodes from the nodes root to the current root
Array . from ( nodesRoot . childNodes ) . forEach ( ( node ) = > {
const importedNode = currentDoc . importNode ( node , true )
currentRoot . appendChild ( importedNode )
} )
// Add default cells if they don't exist
if ( ! hasCell0 ) {
const cell0 = currentDoc . createElement ( "mxCell" )
cell0 . setAttribute ( "id" , "0" )
currentRoot . insertBefore ( cell0 , currentRoot . firstChild )
}
if ( ! hasCell1 ) {
const cell1 = currentDoc . createElement ( "mxCell" )
cell1 . setAttribute ( "id" , "1" )
cell1 . setAttribute ( "parent" , "0" )
// Insert after cell0 if possible
const cell0 = currentRoot . querySelector ( 'mxCell[id="0"]' )
2025-12-06 16:18:26 +09:00
if ( cell0 ? . nextSibling ) {
2025-12-06 12:46:40 +09:00
currentRoot . insertBefore ( cell1 , cell0 . nextSibling )
} else {
currentRoot . appendChild ( cell1 )
}
}
2025-03-25 08:56:24 +00:00
2025-12-06 12:46:40 +09:00
// Convert the modified DOM back to a string
const serializer = new XMLSerializer ( )
return serializer . serializeToString ( currentDoc )
} catch ( error ) {
throw new Error ( ` Error replacing nodes: ${ error } ` )
}
2025-03-27 06:45:38 +00:00
}
2025-12-04 13:26:06 +09:00
/ * *
* Create a character count dictionary from a string
* Used for attribute - order agnostic comparison
* /
function charCountDict ( str : string ) : Map < string , number > {
2025-12-06 12:46:40 +09:00
const dict = new Map < string , number > ( )
for ( const char of str ) {
dict . set ( char , ( dict . get ( char ) || 0 ) + 1 )
}
return dict
2025-12-04 13:26:06 +09:00
}
/ * *
* Compare two strings by character frequency ( order - agnostic )
* /
function sameCharFrequency ( a : string , b : string ) : boolean {
2025-12-06 12:46:40 +09:00
const trimmedA = a . trim ( )
const trimmedB = b . trim ( )
if ( trimmedA . length !== trimmedB . length ) return false
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
const dictA = charCountDict ( trimmedA )
const dictB = charCountDict ( trimmedB )
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
if ( dictA . size !== dictB . size ) return false
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
for ( const [ char , count ] of dictA ) {
if ( dictB . get ( char ) !== count ) return false
}
return true
2025-12-04 13:26:06 +09:00
}
2025-08-31 20:52:04 +09:00
/ * *
* Replace specific parts of XML content using search and replace pairs
* @param xmlContent - The original XML string
* @param searchReplacePairs - Array of { search : string , replace : string } objects
* @returns The updated XML string with replacements applied
* /
export function replaceXMLParts (
2025-12-06 12:46:40 +09:00
xmlContent : string ,
searchReplacePairs : Array < { search : string ; replace : string } > ,
2025-08-31 20:52:04 +09:00
) : string {
2025-12-06 12:46:40 +09:00
// Format the XML first to ensure consistent line breaks
let result = formatXML ( xmlContent )
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
for ( const { search , replace } of searchReplacePairs ) {
// Also format the search content for consistency
const formattedSearch = formatXML ( search )
const searchLines = formattedSearch . split ( "\n" )
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
// Split into lines for exact line matching
const resultLines = result . split ( "\n" )
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
// Remove trailing empty line if exists (from the trailing \n in search content)
if ( searchLines [ searchLines . length - 1 ] === "" ) {
searchLines . pop ( )
}
2025-08-31 20:52:04 +09:00
2025-12-07 00:40:16 +09:00
// Always search from the beginning - pairs may not be in document order
const startLineNum = 0
2025-08-31 20:52:04 +09:00
2025-12-07 00:40:16 +09:00
// Try to find match using multiple strategies
2025-12-06 12:46:40 +09:00
let matchFound = false
let matchStartLine = - 1
let matchEndLine = - 1
// First try: exact match
for (
let i = startLineNum ;
i <= resultLines . length - searchLines . length ;
i ++
) {
let matches = true
for ( let j = 0 ; j < searchLines . length ; j ++ ) {
if ( resultLines [ i + j ] !== searchLines [ j ] ) {
matches = false
break
}
}
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
if ( matches ) {
matchStartLine = i
matchEndLine = i + searchLines . length
matchFound = true
break
}
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
// Second try: line-trimmed match (fallback)
if ( ! matchFound ) {
for (
let i = startLineNum ;
i <= resultLines . length - searchLines . length ;
i ++
) {
let matches = true
for ( let j = 0 ; j < searchLines . length ; j ++ ) {
const originalTrimmed = resultLines [ i + j ] . trim ( )
const searchTrimmed = searchLines [ j ] . trim ( )
if ( originalTrimmed !== searchTrimmed ) {
matches = false
break
}
}
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
if ( matches ) {
matchStartLine = i
matchEndLine = i + searchLines . length
matchFound = true
break
}
}
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
// Third try: substring match as last resort (for single-line XML)
if ( ! matchFound ) {
// Try to find as a substring in the entire content
const searchStr = search . trim ( )
const resultStr = result
const index = resultStr . indexOf ( searchStr )
if ( index !== - 1 ) {
// Found as substring - replace it
result =
resultStr . substring ( 0 , index ) +
replace . trim ( ) +
resultStr . substring ( index + searchStr . length )
// Re-format after substring replacement
result = formatXML ( result )
continue // Skip the line-based replacement below
}
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
// Fourth try: character frequency match (attribute-order agnostic)
// This handles cases where the model generates XML with different attribute order
if ( ! matchFound ) {
for (
let i = startLineNum ;
i <= resultLines . length - searchLines . length ;
i ++
) {
let matches = true
for ( let j = 0 ; j < searchLines . length ; j ++ ) {
if (
! sameCharFrequency ( resultLines [ i + j ] , searchLines [ j ] )
) {
matches = false
break
}
}
2025-12-04 13:26:06 +09:00
2025-12-06 12:46:40 +09:00
if ( matches ) {
matchStartLine = i
matchEndLine = i + searchLines . length
matchFound = true
break
}
}
2025-12-04 13:26:06 +09:00
}
2025-12-06 12:46:40 +09:00
// Fifth try: Match by mxCell id attribute
// Extract id from search pattern and find the element with that id
if ( ! matchFound ) {
const idMatch = search . match ( /id="([^"]+)"/ )
if ( idMatch ) {
const searchId = idMatch [ 1 ]
// Find lines that contain this id
for ( let i = startLineNum ; i < resultLines . length ; i ++ ) {
if ( resultLines [ i ] . includes ( ` id=" ${ searchId } " ` ) ) {
// Found the element with matching id
// Now find the extent of this element (it might span multiple lines)
let endLine = i + 1
const line = resultLines [ i ] . trim ( )
// Check if it's a self-closing tag or has children
if ( ! line . endsWith ( "/>" ) ) {
// Find the closing tag or the end of the mxCell block
let depth = 1
while ( endLine < resultLines . length && depth > 0 ) {
const currentLine = resultLines [ endLine ] . trim ( )
if (
currentLine . startsWith ( "<" ) &&
! currentLine . startsWith ( "</" ) &&
! currentLine . endsWith ( "/>" )
) {
depth ++
} else if ( currentLine . startsWith ( "</" ) ) {
depth --
}
endLine ++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
2025-12-04 22:56:59 +09:00
}
}
}
2025-12-07 00:40:16 +09:00
// Sixth try: Match by value attribute (label text)
// Extract value from search pattern and find elements with that value
if ( ! matchFound ) {
const valueMatch = search . match ( /value="([^"]*)"/ )
if ( valueMatch ) {
const searchValue = valueMatch [ 0 ] // Use full match like value="text"
for ( let i = startLineNum ; i < resultLines . length ; i ++ ) {
if ( resultLines [ i ] . includes ( searchValue ) ) {
// Found element with matching value
let endLine = i + 1
const line = resultLines [ i ] . trim ( )
if ( ! line . endsWith ( "/>" ) ) {
let depth = 1
while ( endLine < resultLines . length && depth > 0 ) {
const currentLine = resultLines [ endLine ] . trim ( )
if (
currentLine . startsWith ( "<" ) &&
! currentLine . startsWith ( "</" ) &&
! currentLine . endsWith ( "/>" )
) {
depth ++
} else if ( currentLine . startsWith ( "</" ) ) {
depth --
}
endLine ++
}
}
matchStartLine = i
matchEndLine = endLine
matchFound = true
break
}
}
}
}
// Seventh try: Normalized whitespace match
// Collapse all whitespace and compare
if ( ! matchFound ) {
const normalizeWs = ( s : string ) = > s . replace ( /\s+/g , " " ) . trim ( )
const normalizedSearch = normalizeWs ( search )
for (
let i = startLineNum ;
i <= resultLines . length - searchLines . length ;
i ++
) {
// Build a normalized version of the candidate lines
const candidateLines = resultLines . slice (
i ,
i + searchLines . length ,
)
2025-12-07 01:39:09 +09:00
const normalizedCandidate = normalizeWs (
candidateLines . join ( " " ) ,
)
2025-12-07 00:40:16 +09:00
if ( normalizedCandidate === normalizedSearch ) {
matchStartLine = i
matchEndLine = i + searchLines . length
matchFound = true
break
}
}
}
2025-12-06 12:46:40 +09:00
if ( ! matchFound ) {
throw new Error (
` Search pattern not found in the diagram. The pattern may not exist in the current structure. ` ,
)
}
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
// Replace the matched lines
const replaceLines = replace . split ( "\n" )
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
// Remove trailing empty line if exists
if ( replaceLines [ replaceLines . length - 1 ] === "" ) {
replaceLines . pop ( )
}
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
// Perform the replacement
const newResultLines = [
. . . resultLines . slice ( 0 , matchStartLine ) ,
. . . replaceLines ,
. . . resultLines . slice ( matchEndLine ) ,
]
2025-08-31 20:52:04 +09:00
2025-12-06 12:46:40 +09:00
result = newResultLines . join ( "\n" )
2025-08-31 20:52:04 +09:00
}
2025-12-06 12:46:40 +09:00
return result
2025-08-31 20:52:04 +09:00
}
2025-03-27 06:45:38 +00:00
2025-12-03 16:14:53 +09:00
/ * *
* Validates draw . io XML structure for common issues
* @param xml - The XML string to validate
* @returns null if valid , error message string if invalid
* /
export function validateMxCellStructure ( xml : string ) : string | null {
2025-12-06 12:46:40 +09:00
const parser = new DOMParser ( )
const doc = parser . parseFromString ( xml , "text/xml" )
2025-12-03 16:14:53 +09:00
2025-12-06 12:46:40 +09:00
// Check for XML parsing errors (includes unescaped special characters)
const parseError = doc . querySelector ( "parsererror" )
if ( parseError ) {
return ` Invalid XML: The XML contains syntax errors (likely unescaped special characters like <, >, & in attribute values). Please escape special characters: use < for <, > for >, & for &, " for ". Regenerate the diagram with properly escaped values. `
2025-12-03 16:14:53 +09:00
}
2025-12-06 12:46:40 +09:00
// Get all mxCell elements once for all validations
const allCells = doc . querySelectorAll ( "mxCell" )
// Single pass: collect IDs, check for duplicates, nesting, orphans, and invalid parents
const cellIds = new Set < string > ( )
const duplicateIds : string [ ] = [ ]
const nestedCells : string [ ] = [ ]
const orphanCells : string [ ] = [ ]
const invalidParents : { id : string ; parent : string } [ ] = [ ]
const edgesToValidate : {
id : string
source : string | null
target : string | null
} [ ] = [ ]
allCells . forEach ( ( cell ) = > {
const id = cell . getAttribute ( "id" )
const parent = cell . getAttribute ( "parent" )
const isEdge = cell . getAttribute ( "edge" ) === "1"
// Check for duplicate IDs
if ( id ) {
if ( cellIds . has ( id ) ) {
duplicateIds . push ( id )
} else {
cellIds . add ( id )
}
}
// Check for nested mxCell (parent element is also mxCell)
if ( cell . parentElement ? . tagName === "mxCell" ) {
nestedCells . push ( id || "unknown" )
}
// Check parent attribute (skip root cell id="0")
if ( id !== "0" ) {
if ( ! parent ) {
if ( id ) orphanCells . push ( id )
} else {
// Store for later validation (after all IDs collected)
invalidParents . push ( { id : id || "unknown" , parent } )
}
}
// Collect edges for connection validation
if ( isEdge ) {
edgesToValidate . push ( {
id : id || "unknown" ,
source : cell.getAttribute ( "source" ) ,
target : cell.getAttribute ( "target" ) ,
} )
}
} )
// Return errors in priority order
if ( nestedCells . length > 0 ) {
return ` Invalid XML: Found nested mxCell elements (IDs: ${ nestedCells . slice ( 0 , 3 ) . join ( ", " ) } ). All mxCell elements must be direct children of <root>, never nested inside other mxCell elements. Please regenerate the diagram with correct structure. `
2025-12-03 16:14:53 +09:00
}
2025-12-06 12:46:40 +09:00
if ( duplicateIds . length > 0 ) {
return ` Invalid XML: Found duplicate cell IDs ( ${ duplicateIds . slice ( 0 , 3 ) . join ( ", " ) } ). Each mxCell must have a unique ID. Please regenerate the diagram with unique IDs for all elements. `
2025-12-03 16:14:53 +09:00
}
2025-12-06 12:46:40 +09:00
if ( orphanCells . length > 0 ) {
return ` Invalid XML: Found cells without parent attribute (IDs: ${ orphanCells . slice ( 0 , 3 ) . join ( ", " ) } ). All mxCell elements (except id="0") must have a parent attribute. Please regenerate the diagram with proper parent references. `
2025-12-03 16:14:53 +09:00
}
2025-12-06 12:46:40 +09:00
// Validate parent references (now that all IDs are collected)
const badParents = invalidParents . filter ( ( p ) = > ! cellIds . has ( p . parent ) )
if ( badParents . length > 0 ) {
const details = badParents
. slice ( 0 , 3 )
. map ( ( p ) = > ` ${ p . id } (parent: ${ p . parent } ) ` )
. join ( ", " )
return ` Invalid XML: Found cells with invalid parent references ( ${ details } ). Parent IDs must reference existing cells. Please regenerate the diagram with valid parent references. `
2025-12-03 16:14:53 +09:00
}
2025-12-06 12:46:40 +09:00
// Validate edge connections
const invalidConnections : string [ ] = [ ]
edgesToValidate . forEach ( ( edge ) = > {
if ( edge . source && ! cellIds . has ( edge . source ) ) {
invalidConnections . push ( ` ${ edge . id } (source: ${ edge . source } ) ` )
}
if ( edge . target && ! cellIds . has ( edge . target ) ) {
invalidConnections . push ( ` ${ edge . id } (target: ${ edge . target } ) ` )
}
} )
2025-12-03 16:14:53 +09:00
2025-12-06 12:46:40 +09:00
if ( invalidConnections . length > 0 ) {
return ` Invalid XML: Found edges with invalid source/target references ( ${ invalidConnections . slice ( 0 , 3 ) . join ( ", " ) } ). Edge source and target must reference existing cell IDs. Please regenerate the diagram with valid edge connections. `
}
2025-12-07 00:40:19 +09:00
// Check for orphaned mxPoint elements (not inside <Array as="points"> and without 'as' attribute)
// These cause "Could not add object mxPoint" errors in draw.io
const allMxPoints = doc . querySelectorAll ( "mxPoint" )
const orphanedMxPoints : string [ ] = [ ]
allMxPoints . forEach ( ( point ) = > {
const hasAsAttr = point . hasAttribute ( "as" )
const parentIsArray =
point . parentElement ? . tagName === "Array" &&
point . parentElement ? . getAttribute ( "as" ) === "points"
if ( ! hasAsAttr && ! parentIsArray ) {
// Find the parent mxCell to report which edge has the problem
let parent = point . parentElement
while ( parent && parent . tagName !== "mxCell" ) {
parent = parent . parentElement
}
const cellId = parent ? . getAttribute ( "id" ) || "unknown"
if ( ! orphanedMxPoints . includes ( cellId ) ) {
orphanedMxPoints . push ( cellId )
}
}
} )
if ( orphanedMxPoints . length > 0 ) {
return ` Invalid XML: Found orphaned mxPoint elements in cells ( ${ orphanedMxPoints . slice ( 0 , 3 ) . join ( ", " ) } ). mxPoint elements must either have an 'as' attribute (e.g., as="sourcePoint") or be inside <Array as="points">. For edge waypoints, use: <Array as="points"><mxPoint x="..." y="..."/></Array>. Please fix the mxPoint structure. `
}
2025-12-06 12:46:40 +09:00
return null
2025-12-03 16:14:53 +09:00
}
2025-03-27 06:45:38 +00:00
export function extractDiagramXML ( xml_svg_string : string ) : string {
2025-12-06 12:46:40 +09:00
try {
// 1. Parse the SVG string (using built-in DOMParser in a browser-like environment)
const svgString = atob ( xml_svg_string . slice ( 26 ) )
const parser = new DOMParser ( )
const svgDoc = parser . parseFromString ( svgString , "image/svg+xml" )
const svgElement = svgDoc . querySelector ( "svg" )
if ( ! svgElement ) {
throw new Error ( "No SVG element found in the input string." )
}
// 2. Extract the 'content' attribute
const encodedContent = svgElement . getAttribute ( "content" )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
if ( ! encodedContent ) {
throw new Error ( "SVG element does not have a 'content' attribute." )
}
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 3. Decode HTML entities (using a minimal function)
function decodeHtmlEntities ( str : string ) {
const textarea = document . createElement ( "textarea" ) // Use built-in element
textarea . innerHTML = str
return textarea . value
}
const xmlContent = decodeHtmlEntities ( encodedContent )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 4. Parse the XML content
const xmlDoc = parser . parseFromString ( xmlContent , "text/xml" )
const diagramElement = xmlDoc . querySelector ( "diagram" )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
if ( ! diagramElement ) {
throw new Error ( "No diagram element found" )
}
// 5. Extract base64 encoded data
const base64EncodedData = diagramElement . textContent
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
if ( ! base64EncodedData ) {
throw new Error ( "No encoded data found in the diagram element" )
}
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 6. Decode base64 data
const binaryString = atob ( base64EncodedData )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 7. Convert binary string to Uint8Array
const len = binaryString . length
const bytes = new Uint8Array ( len )
for ( let i = 0 ; i < len ; i ++ ) {
bytes [ i ] = binaryString . charCodeAt ( i )
}
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 8. Decompress data using pako (equivalent to zlib.decompress with wbits=-15)
const decompressedData = pako . inflate ( bytes , { windowBits : - 15 } )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// 9. Convert the decompressed data to a string
const decoder = new TextDecoder ( "utf-8" )
const decodedString = decoder . decode ( decompressedData )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
// Decode URL-encoded content (equivalent to Python's urllib.parse.unquote)
const urlDecodedString = decodeURIComponent ( decodedString )
2025-03-27 06:45:38 +00:00
2025-12-06 12:46:40 +09:00
return urlDecodedString
} catch ( error ) {
console . error ( "Error extracting diagram XML:" , error )
throw error // Re-throw for caller handling
}
2025-03-27 06:45:38 +00:00
}