Merge "Add dark mode support to Gitiles"
diff --git a/external_deps.lock.json b/external_deps.lock.json
index 36df3f9..1c1f65b 100755
--- a/external_deps.lock.json
+++ b/external_deps.lock.json
@@ -39,19 +39,19 @@
"org.apache.commons:commons-text": -1491671838,
"org.apache.httpcomponents:httpclient": 1643860045,
"org.apache.httpcomponents:httpcore": -1696303652,
- "org.apache.sshd:sshd-osgi": 1681538032,
- "org.apache.sshd:sshd-sftp": 2089761863,
+ "org.apache.sshd:sshd-osgi": -115413327,
+ "org.apache.sshd:sshd-sftp": 292810504,
"org.assertj:assertj-core": -1145412507,
- "org.bouncycastle:bcpg-jdk18on": -1572213535,
- "org.bouncycastle:bcpkix-jdk18on": 146639060,
- "org.bouncycastle:bcprov-jdk18on": -1405390253,
- "org.bouncycastle:bcutil-jdk18on": -469511060,
+ "org.bouncycastle:bcpg-jdk18on": 915299298,
+ "org.bouncycastle:bcpkix-jdk18on": -1660815403,
+ "org.bouncycastle:bcprov-jdk18on": -1559819624,
+ "org.bouncycastle:bcutil-jdk18on": 2018001773,
"org.commonmark:commonmark": 1129543740,
"org.commonmark:commonmark-ext-autolink": -1853742120,
"org.commonmark:commonmark-ext-gfm-strikethrough": 350394231,
"org.commonmark:commonmark-ext-gfm-tables": 1881582931,
"org.commonmark:commonmark-ext-yaml-front-matter": -1519651186,
- "org.eclipse.jetty.ee10:jetty-ee10-servlet": 835495267,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": 1925464069,
"org.eclipse.jetty.ee8:jetty-ee8-nested": 1867536598,
"org.eclipse.jetty.ee8:jetty-ee8-security": -374913491,
"org.eclipse.jetty.ee8:jetty-ee8-servlet": 894276934,
@@ -61,7 +61,7 @@
"org.eclipse.jetty:jetty-server": 1477679957,
"org.eclipse.jetty:jetty-session": 1736408150,
"org.eclipse.jetty:jetty-util": 1880514486,
- "org.eclipse.jetty:jetty-util-ajax": -622085534,
+ "org.eclipse.jetty:jetty-util-ajax": 57972836,
"org.hamcrest:hamcrest": 1547523135,
"org.mockito:mockito-core": -133446801,
"org.nibor.autolink:autolink": -342487050,
@@ -182,24 +182,24 @@
"org.apache.httpcomponents:httpclient:jar:sources": -779528763,
"org.apache.httpcomponents:httpcore": 67453319,
"org.apache.httpcomponents:httpcore:jar:sources": 2134002822,
- "org.apache.sshd:sshd-common": -1308783553,
- "org.apache.sshd:sshd-common:jar:sources": 203202718,
- "org.apache.sshd:sshd-core": 576544003,
- "org.apache.sshd:sshd-core:jar:sources": 1066552279,
- "org.apache.sshd:sshd-osgi": -880103583,
- "org.apache.sshd:sshd-osgi:jar:sources": -2061003935,
- "org.apache.sshd:sshd-sftp": -86158263,
- "org.apache.sshd:sshd-sftp:jar:sources": 1034555480,
+ "org.apache.sshd:sshd-common": -461927487,
+ "org.apache.sshd:sshd-common:jar:sources": 402940425,
+ "org.apache.sshd:sshd-core": 1923342694,
+ "org.apache.sshd:sshd-core:jar:sources": -238465986,
+ "org.apache.sshd:sshd-osgi": 2100718013,
+ "org.apache.sshd:sshd-osgi:jar:sources": 710209222,
+ "org.apache.sshd:sshd-sftp": 805382931,
+ "org.apache.sshd:sshd-sftp:jar:sources": -1268961386,
"org.assertj:assertj-core": 1683037634,
"org.assertj:assertj-core:jar:sources": -697161745,
- "org.bouncycastle:bcpg-jdk18on": -1258294405,
- "org.bouncycastle:bcpg-jdk18on:jar:sources": -1733408473,
- "org.bouncycastle:bcpkix-jdk18on": -294727450,
- "org.bouncycastle:bcpkix-jdk18on:jar:sources": 1412420619,
- "org.bouncycastle:bcprov-jdk18on": 1743462207,
- "org.bouncycastle:bcprov-jdk18on:jar:sources": 1198104999,
- "org.bouncycastle:bcutil-jdk18on": 968717615,
- "org.bouncycastle:bcutil-jdk18on:jar:sources": -1508579276,
+ "org.bouncycastle:bcpg-jdk18on": 1314692470,
+ "org.bouncycastle:bcpg-jdk18on:jar:sources": -1476258101,
+ "org.bouncycastle:bcpkix-jdk18on": -1562281305,
+ "org.bouncycastle:bcpkix-jdk18on:jar:sources": 1945286569,
+ "org.bouncycastle:bcprov-jdk18on": -390275162,
+ "org.bouncycastle:bcprov-jdk18on:jar:sources": -2118706434,
+ "org.bouncycastle:bcutil-jdk18on": -1194874921,
+ "org.bouncycastle:bcutil-jdk18on:jar:sources": -1005159791,
"org.checkerframework:checker-compat-qual": -1467964223,
"org.checkerframework:checker-compat-qual:jar:sources": 187825033,
"org.commonmark:commonmark": 965353610,
@@ -212,8 +212,8 @@
"org.commonmark:commonmark-ext-yaml-front-matter": 1918089254,
"org.commonmark:commonmark-ext-yaml-front-matter:jar:sources": -902462472,
"org.commonmark:commonmark:jar:sources": -1511261547,
- "org.eclipse.jetty.ee10:jetty-ee10-servlet": 349394374,
- "org.eclipse.jetty.ee10:jetty-ee10-servlet:jar:sources": 501419503,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": 1455490102,
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources": -873060978,
"org.eclipse.jetty.ee8:jetty-ee8-nested": -65830655,
"org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources": -2141029892,
"org.eclipse.jetty.ee8:jetty-ee8-security": -1787377969,
@@ -233,8 +233,8 @@
"org.eclipse.jetty:jetty-session": 602757569,
"org.eclipse.jetty:jetty-session:jar:sources": -1630590830,
"org.eclipse.jetty:jetty-util": 1084512308,
- "org.eclipse.jetty:jetty-util-ajax": -1660665108,
- "org.eclipse.jetty:jetty-util-ajax:jar:sources": 455294676,
+ "org.eclipse.jetty:jetty-util-ajax": 889071774,
+ "org.eclipse.jetty:jetty-util-ajax:jar:sources": -884639814,
"org.eclipse.jetty:jetty-util:jar:sources": -280937557,
"org.hamcrest:hamcrest": 1282317766,
"org.hamcrest:hamcrest-core": 649657847,
@@ -643,31 +643,31 @@
},
"org.apache.sshd:sshd-common": {
"shasums": {
- "jar": "f1e33994601b5acc4cbcf993271e9411b4231d921cc72a0ac40544cbb71ae06c",
- "sources": "945b184ba2cc49144e7e98b3c23160330c1bea0c470d2a642967736aaab8f013"
+ "jar": "41cef234f894e435ae2141e24baa25baf238a273cd28c6bbadcf830b1b2d68cb",
+ "sources": "c0b0a66452b70e6e3195665bf17a28edcd4ddf2f49801fd4732a7c60e49e9d92"
},
- "version": "2.18.0"
+ "version": "2.19.0"
},
"org.apache.sshd:sshd-core": {
"shasums": {
- "jar": "5016bd515903b9c14d18f0ee8f4e1b65f228f794f64f67344687224e804c4318",
- "sources": "0ffeed88040dc12a17bd6069d33fda2c5ac87bb69acdf5b6576654abed42be12"
+ "jar": "7215350bf71d95a34494f823a7451f966a2995b59e99fdf8ee9cbd63ef78cdb3",
+ "sources": "c1ced96338bb28821cfb63f86e4cd4a8c2b8b8ec078f85d0b7f91d4864dde0e0"
},
- "version": "2.18.0"
+ "version": "2.19.0"
},
"org.apache.sshd:sshd-osgi": {
"shasums": {
- "jar": "e771c4239b1c4818c102368d57d0cc082c224eefb76224524473bf7021d1a32f",
- "sources": "dd5838716293c8aeaf9487c50c6b313dff304e7ddca84ce16923f73865ba4d1d"
+ "jar": "0b6b9371c6a97430390b84c23c58f4470927a747e74704a0ca7d19a09a66fe37",
+ "sources": "d11b69618dba155f075571cbd9b21741953c27dfa9a1af09b4024f2183226abe"
},
- "version": "2.18.0"
+ "version": "2.19.0"
},
"org.apache.sshd:sshd-sftp": {
"shasums": {
- "jar": "bf52fd81d63337cb40050c70ee460c80e78a0a4b77f15da7125eb1841f527e93",
- "sources": "5171d9bac4ee76a013beccc1fdeab93fb43aeb84172d19b929ee158b9281c648"
+ "jar": "a3ad30d2feba9fad589c7ad9117fcd9d332823697d59eb424fe2902f4d805575",
+ "sources": "e26b7df972cead5c529bca6ab3e782c27dd43b323f9a54f98d9e23d4f5d03c30"
},
- "version": "2.18.0"
+ "version": "2.19.0"
},
"org.assertj:assertj-core": {
"shasums": {
@@ -678,31 +678,31 @@
},
"org.bouncycastle:bcpg-jdk18on": {
"shasums": {
- "jar": "c0e6303a0d7589040f400950ecee87a14b81312e84ed15e5390ebb0c4566ddab",
- "sources": "a8baa033c57614d36c3d2339a8c8e5902a8a2ed8cb7387cdb2b919e5a4b15f30"
+ "jar": "39426367dc247dfaae9b1253457a39298b6b9a6c28a39b908bca829e315323dd",
+ "sources": "605b09bd826d7e4e3e0cb4faf2e9b53a6bcb452be963c91499b79fd8497685be"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.bouncycastle:bcpkix-jdk18on": {
"shasums": {
- "jar": "c87f16ed9e5ec61bc94151e9f3646ac44e50cd448121ce84367fa4b7ec7ec1bb",
- "sources": "fe00c12243c28ead30ad6c7742be40ff005ab29f493c350b83b637fe4a9b5597"
+ "jar": "c9f82b2d4e99c4bbdfccf684e52cc06ea06a0b567bfd0d08f9c5a3f417055996",
+ "sources": "e5331f467331aba29bda6ddfb0df0da6d568928e29c2b0f20ea2fe123d802d20"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.bouncycastle:bcprov-jdk18on": {
"shasums": {
- "jar": "64d6c5a6121fcd927152dd182cbed39afe0fda641a970d9bcc0c9cb1858b2731",
- "sources": "e5f04550f7740e588edcbd1654c59277cd7ee8725d8b674e44f7f8f4b9c5674a"
+ "jar": "986b0fb92ec10e0c66b43e036ce0077e6150cfaecd1db9fb92b56672e157afe5",
+ "sources": "b37ac84b1d5435ab7b8d166c16ab9f75e09f68f8ec50479bae433939b241b03f"
},
- "version": "1.84"
+ "version": "1.85.2"
},
"org.bouncycastle:bcutil-jdk18on": {
"shasums": {
- "jar": "b374e16963421fb9cfb01cc20d7ad8fd2f8b8188e3eef0ec0a8965e245f7619a",
- "sources": "192b719273dc33e8fd6edc3b30b126760b6740cf2e1ac3cc7cf845c7ffec9f2b"
+ "jar": "590f55ed5d68529239898a4a5c4f730b6e37f45d1cfa3fbe51f8485abe32c42d",
+ "sources": "b470a692878f92abf00b9c3af9147a45453250a80930df8f23f1d092c55e2d5e"
},
- "version": "1.84"
+ "version": "1.85"
},
"org.checkerframework:checker-compat-qual": {
"shasums": {
@@ -746,12 +746,12 @@
},
"version": "0.24.0"
},
- "org.eclipse.jetty.ee10:jetty-ee10-servlet": {
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": {
"shasums": {
- "jar": "05cccc74bbe1478ceb765e967b1cb1a3c8d9b51e99acf586190887e467ffd87d",
- "sources": "51a734b4bc04235b24ccd759515dd2947e5b675c227c556f02262b765bce47eb"
+ "jar": "af76ee845345f184d96d8f6c926e64f8bda4cf858013598d392ad604d37e6bac",
+ "sources": "0daaa96e80d48f516c580b7f18bcdf2ebdea75654a7fddb1a1a933af36ae2d16"
},
- "version": "12.1.10"
+ "version": "12.1.12"
},
"org.eclipse.jetty.ee8:jetty-ee8-nested": {
"shasums": {
@@ -825,10 +825,10 @@
},
"org.eclipse.jetty:jetty-util-ajax": {
"shasums": {
- "jar": "73dfbf388b46c9e2afbc78823ea556f8d25cf851902f99873d03df90becc2b97",
- "sources": "4617309092393ed2f6ed76d915f24b518ccbff8c8731124bf109e346607e86b3"
+ "jar": "ca4d49183b8f92af96c04dab7a40e70cc2ebac554e6de010e1b9a983ae342cb2",
+ "sources": "41bcaeb259a63a898885dd05bfa6b8231051079e38140bbb4f18181a9037b319"
},
- "version": "12.1.10"
+ "version": "12.1.12"
},
"org.hamcrest:hamcrest": {
"shasums": {
@@ -1101,7 +1101,7 @@
"org.commonmark:commonmark-ext-yaml-front-matter": [
"org.commonmark:commonmark"
],
- "org.eclipse.jetty.ee10:jetty-ee10-servlet": [
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
"jakarta.servlet:jakarta.servlet-api",
"org.eclipse.jetty:jetty-security",
"org.eclipse.jetty:jetty-server",
@@ -2124,19 +2124,20 @@
"org.bouncycastle.openpgp.api.jcajce",
"org.bouncycastle.openpgp.api.util",
"org.bouncycastle.openpgp.bc",
- "org.bouncycastle.openpgp.examples",
"org.bouncycastle.openpgp.jcajce",
"org.bouncycastle.openpgp.operator",
"org.bouncycastle.openpgp.operator.bc",
"org.bouncycastle.openpgp.operator.jcajce"
],
"org.bouncycastle:bcpkix-jdk18on": [
+ "org.bouncycastle.cades",
"org.bouncycastle.cert",
"org.bouncycastle.cert.bc",
"org.bouncycastle.cert.cmp",
"org.bouncycastle.cert.crmf",
"org.bouncycastle.cert.crmf.bc",
"org.bouncycastle.cert.crmf.jcajce",
+ "org.bouncycastle.cert.ct",
"org.bouncycastle.cert.dane",
"org.bouncycastle.cert.dane.fetcher",
"org.bouncycastle.cert.jcajce",
@@ -2144,6 +2145,9 @@
"org.bouncycastle.cert.ocsp.jcajce",
"org.bouncycastle.cert.path",
"org.bouncycastle.cert.path.validations",
+ "org.bouncycastle.cert.plants",
+ "org.bouncycastle.cert.plants.bc",
+ "org.bouncycastle.cert.plants.jcajce",
"org.bouncycastle.cert.selector",
"org.bouncycastle.cert.selector.jcajce",
"org.bouncycastle.cmc",
@@ -2175,6 +2179,7 @@
"org.bouncycastle.pkcs",
"org.bouncycastle.pkcs.bc",
"org.bouncycastle.pkcs.jcajce",
+ "org.bouncycastle.pkcs.util",
"org.bouncycastle.pkix",
"org.bouncycastle.pkix.jcajce",
"org.bouncycastle.pkix.util",
@@ -2191,9 +2196,11 @@
"org.bouncycastle.asn1.bc",
"org.bouncycastle.asn1.cryptopro",
"org.bouncycastle.asn1.gm",
+ "org.bouncycastle.asn1.iana",
"org.bouncycastle.asn1.nist",
"org.bouncycastle.asn1.ocsp",
"org.bouncycastle.asn1.pkcs",
+ "org.bouncycastle.asn1.plants",
"org.bouncycastle.asn1.sec",
"org.bouncycastle.asn1.teletrust",
"org.bouncycastle.asn1.ua",
@@ -2209,14 +2216,15 @@
"org.bouncycastle.crypto.agreement.ecjpake",
"org.bouncycastle.crypto.agreement.jpake",
"org.bouncycastle.crypto.agreement.kdf",
+ "org.bouncycastle.crypto.agreement.owl",
"org.bouncycastle.crypto.agreement.srp",
+ "org.bouncycastle.crypto.bls",
"org.bouncycastle.crypto.commitments",
"org.bouncycastle.crypto.constraints",
"org.bouncycastle.crypto.digests",
"org.bouncycastle.crypto.ec",
"org.bouncycastle.crypto.encodings",
"org.bouncycastle.crypto.engines",
- "org.bouncycastle.crypto.examples",
"org.bouncycastle.crypto.fpe",
"org.bouncycastle.crypto.generators",
"org.bouncycastle.crypto.hash2curve",
@@ -2225,6 +2233,8 @@
"org.bouncycastle.crypto.hpke",
"org.bouncycastle.crypto.io",
"org.bouncycastle.crypto.kems",
+ "org.bouncycastle.crypto.kems.cmce",
+ "org.bouncycastle.crypto.kems.frodo",
"org.bouncycastle.crypto.kems.mlkem",
"org.bouncycastle.crypto.macs",
"org.bouncycastle.crypto.modes",
@@ -2243,14 +2253,12 @@
"org.bouncycastle.crypto.util",
"org.bouncycastle.i18n",
"org.bouncycastle.i18n.filter",
- "org.bouncycastle.iana",
"org.bouncycastle.internal.asn1.bsi",
"org.bouncycastle.internal.asn1.cms",
"org.bouncycastle.internal.asn1.cryptlib",
"org.bouncycastle.internal.asn1.eac",
"org.bouncycastle.internal.asn1.edec",
"org.bouncycastle.internal.asn1.gnu",
- "org.bouncycastle.internal.asn1.iana",
"org.bouncycastle.internal.asn1.isara",
"org.bouncycastle.internal.asn1.isismtt",
"org.bouncycastle.internal.asn1.iso",
@@ -2265,6 +2273,8 @@
"org.bouncycastle.jcajce.interfaces",
"org.bouncycastle.jcajce.io",
"org.bouncycastle.jcajce.provider.asymmetric",
+ "org.bouncycastle.jcajce.provider.asymmetric.cmce",
+ "org.bouncycastle.jcajce.provider.asymmetric.compositekem",
"org.bouncycastle.jcajce.provider.asymmetric.compositesignatures",
"org.bouncycastle.jcajce.provider.asymmetric.dh",
"org.bouncycastle.jcajce.provider.asymmetric.dsa",
@@ -2274,6 +2284,7 @@
"org.bouncycastle.jcajce.provider.asymmetric.ecgost12",
"org.bouncycastle.jcajce.provider.asymmetric.edec",
"org.bouncycastle.jcajce.provider.asymmetric.elgamal",
+ "org.bouncycastle.jcajce.provider.asymmetric.frodokem",
"org.bouncycastle.jcajce.provider.asymmetric.gost",
"org.bouncycastle.jcajce.provider.asymmetric.ies",
"org.bouncycastle.jcajce.provider.asymmetric.mldsa",
@@ -2319,46 +2330,64 @@
"org.bouncycastle.math.raw",
"org.bouncycastle.pqc.asn1",
"org.bouncycastle.pqc.crypto",
+ "org.bouncycastle.pqc.crypto.aimer",
"org.bouncycastle.pqc.crypto.cmce",
"org.bouncycastle.pqc.crypto.crystals.dilithium",
+ "org.bouncycastle.pqc.crypto.faest",
"org.bouncycastle.pqc.crypto.falcon",
"org.bouncycastle.pqc.crypto.frodo",
+ "org.bouncycastle.pqc.crypto.haetae",
+ "org.bouncycastle.pqc.crypto.hawk",
"org.bouncycastle.pqc.crypto.hqc",
"org.bouncycastle.pqc.crypto.lms",
"org.bouncycastle.pqc.crypto.mayo",
"org.bouncycastle.pqc.crypto.mldsa",
"org.bouncycastle.pqc.crypto.mlkem",
+ "org.bouncycastle.pqc.crypto.mqom",
"org.bouncycastle.pqc.crypto.newhope",
"org.bouncycastle.pqc.crypto.ntru",
"org.bouncycastle.pqc.crypto.ntruplus",
"org.bouncycastle.pqc.crypto.ntruprime",
+ "org.bouncycastle.pqc.crypto.qruov",
"org.bouncycastle.pqc.crypto.saber",
+ "org.bouncycastle.pqc.crypto.sdith",
"org.bouncycastle.pqc.crypto.slhdsa",
"org.bouncycastle.pqc.crypto.snova",
"org.bouncycastle.pqc.crypto.sphincs",
+ "org.bouncycastle.pqc.crypto.sqisign",
+ "org.bouncycastle.pqc.crypto.uov",
"org.bouncycastle.pqc.crypto.util",
"org.bouncycastle.pqc.crypto.xmss",
"org.bouncycastle.pqc.crypto.xwing",
"org.bouncycastle.pqc.jcajce.interfaces",
"org.bouncycastle.pqc.jcajce.provider",
+ "org.bouncycastle.pqc.jcajce.provider.aimer",
"org.bouncycastle.pqc.jcajce.provider.bike",
"org.bouncycastle.pqc.jcajce.provider.cmce",
"org.bouncycastle.pqc.jcajce.provider.dilithium",
+ "org.bouncycastle.pqc.jcajce.provider.faest",
"org.bouncycastle.pqc.jcajce.provider.falcon",
"org.bouncycastle.pqc.jcajce.provider.frodo",
+ "org.bouncycastle.pqc.jcajce.provider.haetae",
+ "org.bouncycastle.pqc.jcajce.provider.hawk",
"org.bouncycastle.pqc.jcajce.provider.hqc",
"org.bouncycastle.pqc.jcajce.provider.kyber",
"org.bouncycastle.pqc.jcajce.provider.lms",
"org.bouncycastle.pqc.jcajce.provider.mayo",
+ "org.bouncycastle.pqc.jcajce.provider.mqom",
"org.bouncycastle.pqc.jcajce.provider.newhope",
"org.bouncycastle.pqc.jcajce.provider.ntru",
"org.bouncycastle.pqc.jcajce.provider.ntruplus",
"org.bouncycastle.pqc.jcajce.provider.ntruprime",
"org.bouncycastle.pqc.jcajce.provider.picnic",
+ "org.bouncycastle.pqc.jcajce.provider.qruov",
"org.bouncycastle.pqc.jcajce.provider.saber",
+ "org.bouncycastle.pqc.jcajce.provider.sdith",
"org.bouncycastle.pqc.jcajce.provider.snova",
"org.bouncycastle.pqc.jcajce.provider.sphincs",
"org.bouncycastle.pqc.jcajce.provider.sphincsplus",
+ "org.bouncycastle.pqc.jcajce.provider.sqisign",
+ "org.bouncycastle.pqc.jcajce.provider.uov",
"org.bouncycastle.pqc.jcajce.provider.util",
"org.bouncycastle.pqc.jcajce.provider.xmss",
"org.bouncycastle.pqc.jcajce.spec",
@@ -2392,7 +2421,6 @@
"org.bouncycastle.asn1.ess",
"org.bouncycastle.asn1.est",
"org.bouncycastle.asn1.gnu",
- "org.bouncycastle.asn1.iana",
"org.bouncycastle.asn1.icao",
"org.bouncycastle.asn1.isara",
"org.bouncycastle.asn1.isismtt",
@@ -2464,14 +2492,14 @@
"org.commonmark.ext.front.matter",
"org.commonmark.ext.front.matter.internal"
],
- "org.eclipse.jetty.ee10:jetty-ee10-servlet": [
- "org.eclipse.jetty.ee10.servlet",
- "org.eclipse.jetty.ee10.servlet.internal",
- "org.eclipse.jetty.ee10.servlet.jmx",
- "org.eclipse.jetty.ee10.servlet.listener",
- "org.eclipse.jetty.ee10.servlet.security",
- "org.eclipse.jetty.ee10.servlet.security.authentication",
- "org.eclipse.jetty.ee10.servlet.util"
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet": [
+ "org.eclipse.jetty.ee11.servlet",
+ "org.eclipse.jetty.ee11.servlet.internal",
+ "org.eclipse.jetty.ee11.servlet.jmx",
+ "org.eclipse.jetty.ee11.servlet.listener",
+ "org.eclipse.jetty.ee11.servlet.security",
+ "org.eclipse.jetty.ee11.servlet.security.authentication",
+ "org.eclipse.jetty.ee11.servlet.util"
],
"org.eclipse.jetty.ee8:jetty-ee8-nested": [
"org.eclipse.jetty.ee8.nested",
@@ -2839,8 +2867,8 @@
"org.commonmark:commonmark-ext-yaml-front-matter",
"org.commonmark:commonmark-ext-yaml-front-matter:jar:sources",
"org.commonmark:commonmark:jar:sources",
- "org.eclipse.jetty.ee10:jetty-ee10-servlet",
- "org.eclipse.jetty.ee10:jetty-ee10-servlet:jar:sources",
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet",
+ "org.eclipse.jetty.ee11:jetty-ee11-servlet:jar:sources",
"org.eclipse.jetty.ee8:jetty-ee8-nested",
"org.eclipse.jetty.ee8:jetty-ee8-nested:jar:sources",
"org.eclipse.jetty.ee8:jetty-ee8-security",
diff --git a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
index 2d831a4..ba21aaa 100644
--- a/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
+++ b/java/com/google/gitiles/blame/cache/BlameCacheImpl.java
@@ -20,6 +20,7 @@
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Interner;
import com.google.common.collect.Interners;
import com.google.common.collect.Lists;
@@ -29,6 +30,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Set;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import org.eclipse.jgit.blame.BlameGenerator;
@@ -56,10 +58,16 @@
public static class Key {
private final ObjectId commitId;
private final String path;
+ private final ImmutableSet<ObjectId> ignoreIds;
public Key(ObjectId commitId, String path) {
+ this(commitId, path, ImmutableSet.of());
+ }
+
+ public Key(ObjectId commitId, String path, Set<ObjectId> ignoreIds) {
this.commitId = commitId;
this.path = path;
+ this.ignoreIds = ignoreIds != null ? ImmutableSet.copyOf(ignoreIds) : ImmutableSet.of();
}
public ObjectId getCommitId() {
@@ -70,23 +78,44 @@
return path;
}
+ public ImmutableSet<ObjectId> getIgnoreIds() {
+ return ignoreIds;
+ }
+
@Override
public boolean equals(Object o) {
if (o instanceof Key) {
Key k = (Key) o;
- return Objects.equals(commitId, k.commitId) && Objects.equals(path, k.path);
+ return Objects.equals(commitId, k.commitId)
+ && Objects.equals(path, k.path)
+ && Objects.equals(ignoreIds, k.ignoreIds);
}
return false;
}
@Override
public int hashCode() {
- return hash(commitId, path);
+ return hash(commitId, path, ignoreIds);
}
@Override
public String toString() {
- return commitId.name() + ":" + QuotedString.GIT_PATH.quote(path);
+ if (ignoreIds.isEmpty()) {
+ return commitId.name() + ":" + QuotedString.GIT_PATH.quote(path);
+ }
+ StringBuilder sb = new StringBuilder();
+ sb.append(commitId.name()).append(':').append(QuotedString.GIT_PATH.quote(path));
+ sb.append(" ignore=[");
+ boolean first = true;
+ for (ObjectId id : ignoreIds.stream().sorted().toList()) {
+ if (!first) {
+ sb.append(", ");
+ }
+ sb.append(id.name());
+ first = false;
+ }
+ sb.append(']');
+ return sb.toString();
}
}
diff --git a/java/com/google/gitiles/doc/MarkdownConfig.java b/java/com/google/gitiles/doc/MarkdownConfig.java
index 53d979a..d16380a 100644
--- a/java/com/google/gitiles/doc/MarkdownConfig.java
+++ b/java/com/google/gitiles/doc/MarkdownConfig.java
@@ -53,6 +53,7 @@
final boolean strikethrough;
final boolean tables;
final boolean toc;
+ final boolean mermaid;
private final boolean allowAnyIFrame;
private final ImmutableList<String> allowIFrame;
@@ -75,6 +76,7 @@
strikethrough = cfg.getBoolean("markdown", "strikethrough", githubFlavor);
tables = cfg.getBoolean("markdown", "tables", githubFlavor);
toc = cfg.getBoolean("markdown", "toc", true);
+ mermaid = cfg.getBoolean("markdown", "mermaid", githubFlavor);
String[] f = {};
if (safeHtml) {
@@ -105,6 +107,7 @@
strikethrough = on("strikethrough", p.strikethrough, enable, disable);
tables = on("tables", p.tables, enable, disable);
toc = on("toc", p.toc, enable, disable);
+ mermaid = on("mermaid", p.mermaid, enable, disable);
allowAnyIFrame = safeHtml ? p.allowAnyIFrame : false;
allowIFrame = safeHtml ? p.allowIFrame : ImmutableList.of();
diff --git a/java/com/google/gitiles/doc/MarkdownToHtml.java b/java/com/google/gitiles/doc/MarkdownToHtml.java
index 03cc78b..d36e18b 100644
--- a/java/com/google/gitiles/doc/MarkdownToHtml.java
+++ b/java/com/google/gitiles/doc/MarkdownToHtml.java
@@ -311,9 +311,22 @@
@Override
public void visit(FencedCodeBlock node) {
+ if (config != null && config.mermaid && isMermaid(node.getInfo())) {
+ java.util.Optional<String> svg = SimpleMermaidRenderer.renderToSvg(node.getLiteral());
+ if (svg.isPresent()) {
+ html.open("div").attribute("class", "mermaid-container");
+ html.append(com.google.common.html.types.LegacyConversions.riskilyAssumeSafeHtml(svg.get()));
+ html.close("div");
+ return;
+ }
+ }
codeInPre(node.getInfo(), node.getLiteral());
}
+ private static boolean isMermaid(@Nullable String info) {
+ return info != null && "mermaid".equalsIgnoreCase(info.trim());
+ }
+
@Override
public void visit(IndentedCodeBlock node) {
codeInPre(null, node.getLiteral());
diff --git a/java/com/google/gitiles/doc/SimpleMermaidRenderer.java b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
new file mode 100644
index 0000000..d4d5d56
--- /dev/null
+++ b/java/com/google/gitiles/doc/SimpleMermaidRenderer.java
@@ -0,0 +1,2644 @@
+// Copyright 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Deque;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import javax.annotation.Nullable;
+
+/**
+ * Server-side AST parser, layout engine, and SVG renderer for Mermaid flowchart and graph diagrams.
+ *
+ * <p>Implements a pure streaming character-scanner AST parser without regex splits, hierarchical
+ * Sugiyama DAG layout with cycle breaking, crossing reduction, arbitrary nested subgraphs,
+ * dynamic edge clearances, bidirectional curved paths, and responsive SVG emission.
+ */
+public class SimpleMermaidRenderer {
+
+ public enum Direction {
+ LR,
+ TD,
+ TB,
+ RL,
+ BT
+ }
+
+ public enum NodeShape {
+ RECTANGLE,
+ ROUNDED,
+ STADIUM,
+ SUBROUTINE,
+ CYLINDER,
+ CIRCLE,
+ DIAMOND,
+ HEXAGON,
+ FLAG
+ }
+
+ public enum EdgeStroke {
+ SOLID,
+ DASHED,
+ THICK
+ }
+
+ // =========================================================================
+ // AST Model Objects
+ // =========================================================================
+
+ public static class Node {
+ public final String id;
+ public String label;
+ public final List<String> labelLines = new ArrayList<>();
+ public NodeShape shape = NodeShape.RECTANGLE;
+ public int layer = 0;
+ public double relX;
+ public double relY;
+ public double x;
+ public double y;
+ public double width = 160;
+ public double height = 44;
+ public Subgraph parentSubgraph;
+ public double barycenter = 0;
+ public boolean isVirtual = false;
+ public @Nullable String customFill;
+ public @Nullable String customStroke;
+
+ public Node(String id) {
+ this.id = id;
+ setLabel(id);
+ }
+
+ public void setLabel(String rawLabel) {
+ this.label = rawLabel != null ? rawLabel : id;
+ this.labelLines.clear();
+ this.labelLines.addAll(parseLabelLines(this.label));
+ }
+ }
+
+ public static class Subgraph {
+ public final String id;
+ public String title;
+ public Direction direction;
+ public Subgraph parent;
+ public final List<Subgraph> children = new ArrayList<>();
+ public final List<Node> nodes = new ArrayList<>();
+ public double relX;
+ public double relY;
+ public double x;
+ public double y;
+ public double width;
+ public double height;
+ public @Nullable String customFill;
+ public @Nullable String customStroke;
+
+ public Subgraph(String id, String title) {
+ this.id = id;
+ this.title = title;
+ }
+ }
+
+ public static class Edge {
+ public final String fromId;
+ public final String toId;
+ public final String label;
+ public final EdgeStroke stroke;
+ public final boolean arrow;
+ public boolean isBackEdge = false;
+ public final List<Node> virtualNodes = new ArrayList<>();
+
+ public Edge(String fromId, String toId, String label, EdgeStroke stroke, boolean arrow) {
+ this.fromId = fromId;
+ this.toId = toId;
+ this.label = label;
+ this.stroke = stroke;
+ this.arrow = arrow;
+ }
+ }
+
+ public static class SubgraphEdge {
+ public final String fromSgId;
+ public final String toSgId;
+ public final String label;
+ public final EdgeStroke stroke;
+ public final boolean arrow;
+
+ public SubgraphEdge(
+ String fromSgId, String toSgId, String label, EdgeStroke stroke, boolean arrow) {
+ this.fromSgId = fromSgId;
+ this.toSgId = toSgId;
+ this.label = label;
+ this.stroke = stroke;
+ this.arrow = arrow;
+ }
+ }
+
+ public static class MermaidGraph {
+ public Direction direction = Direction.TD;
+ public final Map<String, Node> nodes = new LinkedHashMap<>();
+ public final Map<String, Subgraph> subgraphsMap = new LinkedHashMap<>();
+ public final List<Subgraph> rootSubgraphs = new ArrayList<>();
+ public final List<Subgraph> allSubgraphs = new ArrayList<>();
+ public final List<Edge> edges = new ArrayList<>();
+ public final List<SubgraphEdge> subgraphEdges = new ArrayList<>();
+
+ public Node ensureNode(String id, @Nullable Subgraph currentSubgraph) {
+ Node node = nodes.get(id);
+ if (node == null) {
+ node = new Node(id);
+ nodes.put(id, node);
+ if (currentSubgraph != null) {
+ node.parentSubgraph = currentSubgraph;
+ currentSubgraph.nodes.add(node);
+ }
+ } else if (node.parentSubgraph == null && currentSubgraph != null) {
+ node.parentSubgraph = currentSubgraph;
+ currentSubgraph.nodes.add(node);
+ }
+ return node;
+ }
+
+ public @Nullable Subgraph lookupSubgraph(String name) {
+ if (name == null) return null;
+ String clean = name.trim();
+ Subgraph sg = subgraphsMap.get(clean);
+ if (sg != null) return sg;
+ sg = subgraphsMap.get(stripWhitespace(clean));
+ if (sg != null) return sg;
+ sg = subgraphsMap.get(clean.toLowerCase());
+ if (sg != null) return sg;
+ sg = subgraphsMap.get(stripWhitespace(clean).toLowerCase());
+ return sg;
+ }
+ }
+
+ // =========================================================================
+ // Character Stream Scanner & Tokenizer
+ // =========================================================================
+
+ private static class CharScanner {
+ final String text;
+ int pos;
+
+ CharScanner(String text) {
+ this.text = text != null ? text : "";
+ this.pos = 0;
+ }
+
+ boolean isEof() {
+ return pos >= text.length();
+ }
+
+ char peek() {
+ return isEof() ? '\0' : text.charAt(pos);
+ }
+
+ char next() {
+ return isEof() ? '\0' : text.charAt(pos++);
+ }
+
+ boolean startsWith(String prefix) {
+ return text.startsWith(prefix, pos);
+ }
+
+ boolean startsWithIgnoreCase(String prefix) {
+ if (text.length() - pos < prefix.length()) return false;
+ return text.substring(pos, pos + prefix.length()).equalsIgnoreCase(prefix);
+ }
+
+ boolean consume(String prefix) {
+ if (startsWith(prefix)) {
+ pos += prefix.length();
+ return true;
+ }
+ return false;
+ }
+
+ boolean consumeIgnoreCase(String prefix) {
+ if (startsWithIgnoreCase(prefix)) {
+ pos += prefix.length();
+ return true;
+ }
+ return false;
+ }
+
+ void skipWhitespace() {
+ while (!isEof() && (text.charAt(pos) == ' ' || text.charAt(pos) == '\t')) {
+ pos++;
+ }
+ }
+
+ void skipWhitespaceAndNewlines() {
+ while (!isEof()) {
+ char c = text.charAt(pos);
+ if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == ';') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ }
+
+ void skipLine() {
+ while (!isEof()) {
+ char c = text.charAt(pos++);
+ if (c == '\n') break;
+ }
+ }
+
+ void skipToStatementEnd() {
+ while (!isEof()) {
+ char c = text.charAt(pos);
+ if (c == ';' || c == '\n') {
+ pos++;
+ break;
+ }
+ pos++;
+ }
+ }
+
+ String scanIdentifier() {
+ skipWhitespace();
+ int start = pos;
+ while (!isEof()) {
+ char c = text.charAt(pos);
+ if (Character.isLetterOrDigit(c) || c == '_' || c == '-' || c == '.') {
+ pos++;
+ } else {
+ break;
+ }
+ }
+ return text.substring(start, pos);
+ }
+ }
+
+ private static class RawNodeToken {
+ final String id;
+ final NodeShape shape;
+ final String label;
+
+ RawNodeToken(String id, NodeShape shape, @Nullable String label) {
+ this.id = id;
+ this.shape = shape;
+ this.label = label;
+ }
+ }
+
+ private static class RawEdgeToken {
+ final EdgeStroke stroke;
+ final boolean arrow;
+ final String label;
+
+ RawEdgeToken(EdgeStroke stroke, boolean arrow, @Nullable String label) {
+ this.stroke = stroke;
+ this.arrow = arrow;
+ this.label = label;
+ }
+ }
+
+ // =========================================================================
+ // Parser Implementation
+ // =========================================================================
+
+ /**
+ * Attempts to render a Mermaid code string into SVG XML.
+ *
+ * @param mermaidCode source Mermaid definition.
+ * @return rendered SVG XML string, or empty if unsupported / invalid syntax.
+ */
+ public static Optional<String> renderToSvg(String mermaidCode) {
+ if (mermaidCode == null || mermaidCode.trim().isEmpty()) {
+ return Optional.empty();
+ }
+
+ Optional<MermaidGraph> graphOpt = parse(mermaidCode);
+ if (!graphOpt.isPresent()) {
+ return Optional.empty();
+ }
+
+ MermaidGraph graph = graphOpt.get();
+ if (graph.nodes.isEmpty()) {
+ return Optional.empty();
+ }
+
+ return Optional.of(layoutAndRenderSvg(graph));
+ }
+
+ /** Parses Mermaid source code into a {@link MermaidGraph} AST. */
+ public static Optional<MermaidGraph> parse(String mermaidCode) {
+ CharScanner s = new CharScanner(mermaidCode);
+ MermaidGraph graph = new MermaidGraph();
+ boolean headerFound = false;
+
+ // Scan for diagram type and direction
+ while (!s.isEof()) {
+ s.skipWhitespaceAndNewlines();
+ if (s.isEof()) break;
+
+ if (s.startsWith("%%")) {
+ s.skipLine();
+ continue;
+ }
+
+ if (s.consumeIgnoreCase("graph") || s.consumeIgnoreCase("flowchart")) {
+ s.skipWhitespace();
+ String dirStr = s.scanIdentifier().toUpperCase();
+ try {
+ if (!dirStr.isEmpty()) {
+ graph.direction = Direction.valueOf(dirStr);
+ } else {
+ graph.direction = Direction.TD;
+ }
+ } catch (IllegalArgumentException e) {
+ graph.direction = Direction.TD;
+ }
+ headerFound = true;
+ s.skipToStatementEnd();
+ break;
+ }
+
+ // Check unsupported non-graph diagrams for quick fallback
+ if (s.startsWithIgnoreCase("sequenceDiagram")
+ || s.startsWithIgnoreCase("classDiagram")
+ || s.startsWithIgnoreCase("erDiagram")
+ || s.startsWithIgnoreCase("gantt")
+ || s.startsWithIgnoreCase("pie")
+ || s.startsWithIgnoreCase("gitGraph")
+ || s.startsWithIgnoreCase("xychart-beta")
+ || s.startsWithIgnoreCase("stateDiagram")) {
+ return Optional.empty();
+ }
+
+ s.skipLine();
+ }
+
+ if (!headerFound) {
+ return Optional.empty();
+ }
+
+ Deque<Subgraph> subgraphStack = new ArrayDeque<>();
+
+ // Parse diagram statements into AST
+ while (!s.isEof()) {
+ s.skipWhitespaceAndNewlines();
+ if (s.isEof()) break;
+
+ if (s.startsWith("%%")) {
+ s.skipLine();
+ continue;
+ }
+
+ if (s.startsWithIgnoreCase("style ")) {
+ parseStyleDirective(s, graph);
+ continue;
+ }
+
+ // Skip other meta directives
+ if (s.startsWithIgnoreCase("classDef ")
+ || s.startsWithIgnoreCase("class ")
+ || s.startsWithIgnoreCase("click ")
+ || s.startsWithIgnoreCase("linkStyle ")
+ || s.startsWithIgnoreCase("accTitle")
+ || s.startsWithIgnoreCase("accDescr")) {
+ s.skipToStatementEnd();
+ continue;
+ }
+
+ if (s.startsWithIgnoreCase("graph") || s.startsWithIgnoreCase("flowchart")) {
+ s.skipToStatementEnd();
+ continue;
+ }
+
+ if (s.consumeIgnoreCase("direction")) {
+ s.skipWhitespace();
+ String dirStr = s.scanIdentifier().toUpperCase();
+ if (!subgraphStack.isEmpty() && !dirStr.isEmpty()) {
+ try {
+ subgraphStack.peek().direction = Direction.valueOf(dirStr);
+ } catch (IllegalArgumentException e) {
+ // ignore
+ }
+ }
+ s.skipToStatementEnd();
+ continue;
+ }
+
+ if (s.consumeIgnoreCase("end")) {
+ char nextC = s.peek();
+ if (nextC == '\0' || Character.isWhitespace(nextC) || nextC == ';') {
+ if (!subgraphStack.isEmpty()) {
+ subgraphStack.pop();
+ }
+ s.skipToStatementEnd();
+ continue;
+ }
+ }
+
+ if (s.consumeIgnoreCase("subgraph")) {
+ parseSubgraphHeader(s, graph, subgraphStack);
+ s.skipToStatementEnd();
+ continue;
+ }
+
+ Subgraph currentSg = subgraphStack.isEmpty() ? null : subgraphStack.peek();
+ parseStatement(s, graph, currentSg);
+ s.skipToStatementEnd();
+ }
+
+ return Optional.of(graph);
+ }
+
+ private static void parseSubgraphHeader(
+ CharScanner s, MermaidGraph graph, Deque<Subgraph> subgraphStack) {
+ s.skipWhitespace();
+ String sgId, sgTitle;
+
+ // Check for `subgraph "Title Only"`
+ if (s.startsWith("\"")) {
+ s.consume("\"");
+ int start = s.pos;
+ while (!s.isEof() && !s.startsWith("\"")) s.next();
+ sgTitle = s.text.substring(start, s.pos);
+ s.consume("\"");
+ sgId = "sg_" + graph.allSubgraphs.size();
+ } else {
+ int start = s.pos;
+ while (!s.isEof() && !s.startsWith("[") && !s.startsWith("\"") && s.peek() != '\n' && s.peek() != ';') {
+ s.next();
+ }
+ String rawName = s.text.substring(start, s.pos).trim();
+ s.skipWhitespace();
+ if (s.startsWith("[")) {
+ s.consume("[");
+ s.skipWhitespace();
+ boolean quoted = s.consume("\"");
+ int tstart = s.pos;
+ if (quoted) {
+ while (!s.isEof() && !s.startsWith("\"]") && !s.startsWith("\"")) s.next();
+ sgTitle = s.text.substring(tstart, s.pos);
+ s.consume("\"");
+ s.consume("]");
+ } else {
+ while (!s.isEof() && !s.startsWith("]")) s.next();
+ sgTitle = s.text.substring(tstart, s.pos);
+ s.consume("]");
+ }
+ sgId = rawName;
+ } else {
+ sgTitle = rawName;
+ sgId = rawName;
+ }
+ }
+
+ Subgraph sg = new Subgraph(sgId, sgTitle);
+ if (!subgraphStack.isEmpty()) {
+ Subgraph parent = subgraphStack.peek();
+ sg.parent = parent;
+ parent.children.add(sg);
+ } else {
+ graph.rootSubgraphs.add(sg);
+ }
+ subgraphStack.push(sg);
+ graph.subgraphsMap.put(sgId, sg);
+ graph.subgraphsMap.put(sgTitle, sg);
+ graph.subgraphsMap.put(sgId.toLowerCase(), sg);
+ graph.subgraphsMap.put(sgTitle.toLowerCase(), sg);
+ graph.allSubgraphs.add(sg);
+ }
+
+ private static void parseStyleDirective(CharScanner s, MermaidGraph graph) {
+ s.consumeIgnoreCase("style");
+ s.skipWhitespace();
+ String targetId = s.scanIdentifier();
+ if (targetId.isEmpty()) {
+ s.skipToStatementEnd();
+ return;
+ }
+ s.skipWhitespace();
+ int start = s.pos;
+ while (!s.isEof()) {
+ char c = s.peek();
+ if (c == '\n' || c == '\r' || c == ';') break;
+ s.pos++;
+ }
+ String rest = s.text.substring(start, s.pos);
+ s.skipToStatementEnd();
+
+ String fill = null;
+ String stroke = null;
+ int p = 0;
+ while (p < rest.length()) {
+ int nextSep = rest.length();
+ for (int i = p; i < rest.length(); i++) {
+ char ch = rest.charAt(i);
+ if (ch == ',' || ch == ';') {
+ nextSep = i;
+ break;
+ }
+ }
+ String part = rest.substring(p, nextSep).trim();
+ int colonIdx = part.indexOf(':');
+ if (colonIdx != -1) {
+ String key = part.substring(0, colonIdx).trim().toLowerCase();
+ String val = part.substring(colonIdx + 1).trim();
+ if (key.equals("fill")) {
+ fill = val;
+ } else if (key.equals("stroke")) {
+ stroke = val;
+ }
+ }
+ p = nextSep + 1;
+ }
+
+ if (fill != null && !isValidCssColor(fill)) {
+ fill = null;
+ }
+ if (stroke != null && !isValidCssColor(stroke)) {
+ stroke = null;
+ }
+
+ Subgraph sg = graph.lookupSubgraph(targetId);
+ if (sg != null) {
+ if (fill != null) sg.customFill = fill;
+ if (stroke != null) sg.customStroke = stroke;
+ }
+ Node n = graph.nodes.get(targetId);
+ if (n != null) {
+ if (fill != null) n.customFill = fill;
+ if (stroke != null) n.customStroke = stroke;
+ }
+ }
+
+ private static boolean isValidCssColor(@Nullable String val) {
+ if (val == null || val.isEmpty()) return false;
+ String v = val.trim().toLowerCase();
+ if (v.startsWith("javascript:")
+ || v.startsWith("data:")
+ || v.contains("url(")
+ || v.contains("\"")
+ || v.contains("'")
+ || v.contains("<")
+ || v.contains(">")) {
+ return false;
+ }
+ if (v.matches("^#[0-9a-f]{3,8}$")) {
+ return true;
+ }
+ if (v.matches("^[a-z]{3,20}$")) {
+ return true;
+ }
+ return v.matches("^(rgb|hsl)a?\\([0-9%,. ]+\\)$");
+ }
+
+ private static void parseStatement(
+ CharScanner s, MermaidGraph graph, @Nullable Subgraph currentSubgraph) {
+ List<RawNodeToken> prevGroup = scanNodeGroup(s);
+ if (prevGroup.isEmpty()) return;
+
+ for (RawNodeToken token : prevGroup) {
+ applyNodeToken(token, graph, currentSubgraph);
+ }
+
+ while (!s.isEof()) {
+ char c = s.peek();
+ if (c == ';' || c == '\n' || c == '\r') break;
+
+ RawEdgeToken edge = scanEdgeToken(s);
+ if (edge == null) break;
+
+ List<RawNodeToken> nextGroup = scanNodeGroup(s);
+ if (nextGroup.isEmpty()) break;
+
+ for (RawNodeToken token : nextGroup) {
+ applyNodeToken(token, graph, currentSubgraph);
+ }
+
+ for (RawNodeToken fromToken : prevGroup) {
+ for (RawNodeToken toToken : nextGroup) {
+ Subgraph fromSg = graph.lookupSubgraph(fromToken.id);
+ Subgraph toSg = graph.lookupSubgraph(toToken.id);
+
+ if (fromSg != null && toSg != null) {
+ graph.subgraphEdges.add(
+ new SubgraphEdge(fromSg.id, toSg.id, edge.label, edge.stroke, edge.arrow));
+ } else if (fromSg == null && toSg != null) {
+ if (!toSg.nodes.isEmpty()) {
+ Node targetNode = toSg.nodes.get(toSg.nodes.size() / 2);
+ graph.edges.add(
+ new Edge(fromToken.id, targetNode.id, edge.label, edge.stroke, edge.arrow));
+ }
+ } else if (fromSg != null && toSg == null) {
+ if (!fromSg.nodes.isEmpty()) {
+ Node sourceNode = fromSg.nodes.get(fromSg.nodes.size() / 2);
+ graph.edges.add(
+ new Edge(sourceNode.id, toToken.id, edge.label, edge.stroke, edge.arrow));
+ }
+ } else {
+ graph.edges.add(
+ new Edge(fromToken.id, toToken.id, edge.label, edge.stroke, edge.arrow));
+ }
+ }
+ }
+
+ prevGroup = nextGroup;
+ }
+ }
+
+ private static List<RawNodeToken> scanNodeGroup(CharScanner s) {
+ List<RawNodeToken> group = new ArrayList<>();
+ RawNodeToken first = scanNodeToken(s);
+ if (first == null) return group;
+ group.add(first);
+
+ while (!s.isEof()) {
+ s.skipWhitespace();
+ if (s.startsWith("&")) {
+ s.consume("&");
+ s.skipWhitespace();
+ RawNodeToken next = scanNodeToken(s);
+ if (next != null) {
+ group.add(next);
+ } else {
+ break;
+ }
+ } else {
+ break;
+ }
+ }
+ return group;
+ }
+
+ private static void applyNodeToken(
+ RawNodeToken token, MermaidGraph graph, @Nullable Subgraph currentSubgraph) {
+ if (graph.lookupSubgraph(token.id) != null) {
+ return;
+ }
+ Node node = graph.ensureNode(token.id, currentSubgraph);
+ if (token.label != null) {
+ node.shape = token.shape;
+ node.setLabel(token.label);
+ if (node.shape == NodeShape.DIAMOND && node.labelLines.size() == 1) {
+ node.labelLines.clear();
+ node.labelLines.addAll(wrapDiamondLabel(node.label));
+ }
+ }
+ }
+
+ private static List<String> wrapDiamondLabel(String text) {
+ List<String> result = new ArrayList<>();
+ String trimmed = (text != null) ? text.trim() : "";
+ if (trimmed.length() <= 16 || !trimmed.contains(" ")) {
+ result.add(trimmed);
+ return result;
+ }
+ List<String> words = new ArrayList<>();
+ StringBuilder curWord = new StringBuilder();
+ for (int i = 0; i < trimmed.length(); i++) {
+ char c = trimmed.charAt(i);
+ if (Character.isWhitespace(c)) {
+ if (curWord.length() > 0) {
+ words.add(curWord.toString());
+ curWord.setLength(0);
+ }
+ } else {
+ curWord.append(c);
+ }
+ }
+ if (curWord.length() > 0) {
+ words.add(curWord.toString());
+ }
+
+ int targetLines = Math.max(2, (int) Math.ceil(trimmed.length() / 16.0));
+ int targetLen = (int) Math.ceil((double) trimmed.length() / targetLines);
+
+ StringBuilder cur = new StringBuilder();
+ for (String w : words) {
+ if (cur.length() == 0) {
+ cur.append(w);
+ } else if (cur.length() + 1 + w.length() <= Math.max(targetLen + 4, 18)) {
+ cur.append(" ").append(w);
+ } else {
+ result.add(cur.toString());
+ cur = new StringBuilder(w);
+ }
+ }
+ if (cur.length() > 0) {
+ result.add(cur.toString());
+ }
+ return result;
+ }
+
+ private static @Nullable RawNodeToken scanNodeToken(CharScanner s) {
+ s.skipWhitespace();
+ if (s.isEof()) return null;
+
+ String id = s.scanIdentifier();
+ if (id.isEmpty()) return null;
+
+ s.skipWhitespace();
+ NodeShape shape = NodeShape.RECTANGLE;
+ String label = null;
+
+ String[][] delims = {
+ {"[[", "]]", "SUBROUTINE"},
+ {"[(", ")]", "CYLINDER"},
+ {"([", "])", "STADIUM"},
+ {"((", "))", "CIRCLE"},
+ {"{{", "}}", "HEXAGON"},
+ {"[", "]", "RECTANGLE"},
+ {"(", ")", "ROUNDED"},
+ {"{", "}", "DIAMOND"},
+ {">", "]", "FLAG"}
+ };
+
+ for (String[] d : delims) {
+ String open = d[0];
+ String close = d[1];
+ String shapeName = d[2];
+ if (s.startsWith(open)) {
+ s.consume(open);
+ shape = NodeShape.valueOf(shapeName);
+ s.skipWhitespace();
+ if (s.startsWith("\"")) {
+ s.consume("\"");
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && s.peek() != '\r') {
+ if (s.startsWith("\\\"")) {
+ s.pos += 2;
+ } else if (s.startsWith("\"")) {
+ break;
+ } else {
+ s.next();
+ }
+ }
+ label = s.text.substring(start, s.pos);
+ s.consume("\"");
+ s.skipWhitespace();
+ s.consume(close);
+ } else {
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && !s.startsWith(close)) {
+ s.next();
+ }
+ label = s.text.substring(start, s.pos);
+ s.consume(close);
+ }
+ break;
+ }
+ }
+
+ return new RawNodeToken(id, shape, label != null ? cleanLabel(label) : null);
+ }
+
+ private static @Nullable RawEdgeToken scanEdgeToken(CharScanner s) {
+ s.skipWhitespace();
+ if (s.isEof()) return null;
+
+ // 1. Infix labels: -- label -->, -- "label" -->, == label ==>, -. label .->, -- label ---
+ if ((s.startsWith("-- ") || s.startsWith("--\"") || s.startsWith("--\t"))
+ && !s.startsWith("-->")
+ && !s.startsWith("---|")) {
+ s.consume("--");
+ s.skipWhitespace();
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && !s.startsWith("-->") && !s.startsWith("---")) {
+ s.next();
+ }
+ String label = cleanLabel(s.text.substring(start, s.pos));
+ boolean arrow = s.consume("-->");
+ if (!arrow) s.consume("---");
+ return new RawEdgeToken(EdgeStroke.SOLID, arrow, label);
+ }
+
+ if ((s.startsWith("== ") || s.startsWith("==\"") || s.startsWith("==\t"))
+ && !s.startsWith("==>")
+ && !s.startsWith("===|")) {
+ s.consume("==");
+ s.skipWhitespace();
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && !s.startsWith("==>") && !s.startsWith("===")) {
+ s.next();
+ }
+ String label = cleanLabel(s.text.substring(start, s.pos));
+ boolean arrow = s.consume("==>");
+ if (!arrow) s.consume("===");
+ return new RawEdgeToken(EdgeStroke.THICK, arrow, label);
+ }
+
+ if (s.startsWith("-. ") || s.startsWith("-.\"") || s.startsWith("-.\t")) {
+ s.consume("-.");
+ s.skipWhitespace();
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && !s.startsWith(".->") && !s.startsWith(".-")) {
+ s.next();
+ }
+ String label = cleanLabel(s.text.substring(start, s.pos));
+ boolean arrow = s.consume(".->");
+ if (!arrow) s.consume(".-");
+ return new RawEdgeToken(EdgeStroke.DASHED, arrow, label);
+ }
+
+ // 2. Standard edge operators with optional |pipe label|
+ String[][] ops = {
+ {"-.->", "DASHED", "true"},
+ {"-.-", "DASHED", "false"},
+ {"==>", "THICK", "true"},
+ {"===", "THICK", "false"},
+ {"-->", "SOLID", "true"},
+ {"---", "SOLID", "false"},
+ {"<-->", "SOLID", "true"}
+ };
+
+ for (String[] op : ops) {
+ String prefix = op[0];
+ EdgeStroke stroke = EdgeStroke.valueOf(op[1]);
+ boolean arrow = Boolean.parseBoolean(op[2]);
+ if (s.startsWith(prefix)) {
+ s.consume(prefix);
+ s.skipWhitespace();
+ String label = null;
+ if (s.startsWith("|")) {
+ s.consume("|");
+ int start = s.pos;
+ while (!s.isEof() && s.peek() != '\n' && !s.startsWith("|")) {
+ s.next();
+ }
+ label = cleanLabel(s.text.substring(start, s.pos));
+ s.consume("|");
+ }
+ return new RawEdgeToken(stroke, arrow, label);
+ }
+ }
+
+ return null;
+ }
+
+ private static String cleanLabel(String raw) {
+ if (raw == null) return "";
+ String s = raw.trim();
+ if (s.startsWith("\"") && s.endsWith("\"") && s.length() >= 2) {
+ s = s.substring(1, s.length() - 1);
+ } else if (s.startsWith("\\\"") && s.endsWith("\\\"") && s.length() >= 4) {
+ s = s.substring(2, s.length() - 2);
+ }
+ if (s.contains("\\\"")) {
+ s = s.replace("\\\"", "\"");
+ }
+ return s;
+ }
+
+ private static String stripWhitespace(String s) {
+ if (s == null) return "";
+ StringBuilder sb = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ if (!Character.isWhitespace(c)) {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ private static final Pattern BR_PATTERN = Pattern.compile("(?i)<br\\s*/?>");
+
+ private static List<String> parseLabelLines(String label) {
+ List<String> lines = new ArrayList<>();
+ if (label == null || label.isEmpty()) {
+ lines.add("");
+ return lines;
+ }
+ Matcher matcher = BR_PATTERN.matcher(label);
+ int lastEnd = 0;
+ while (matcher.find()) {
+ lines.add(label.substring(lastEnd, matcher.start()).trim());
+ lastEnd = matcher.end();
+ }
+ lines.add(label.substring(lastEnd).trim());
+ return lines;
+ }
+
+ // =========================================================================
+ // Layout Engine
+ // =========================================================================
+
+ private static String layoutAndRenderSvg(MermaidGraph graph) {
+ boolean isHorizontal = (graph.direction == Direction.LR || graph.direction == Direction.RL);
+
+ // Calculate node dimensions using structured AST labelLines
+ for (Node n : graph.nodes.values()) {
+ int maxLineLen = 0;
+ for (String l : n.labelLines) {
+ maxLineLen = Math.max(maxLineLen, l.trim().length());
+ }
+ if (n.shape == NodeShape.DIAMOND) {
+ double tw = maxLineLen * 7.5;
+ double th = n.labelLines.size() * 18.0;
+ n.width = Math.max(90, tw * 1.5 + 36);
+ n.height = Math.max(50, Math.max(th * 2.2 + 24, n.width * 0.65));
+ } else if (n.shape == NodeShape.CYLINDER) {
+ n.width = Math.max(80, maxLineLen * 7.5 + 32);
+ n.height = Math.max(50, n.labelLines.size() * 18 + 26);
+ } else {
+ n.width = Math.max(70, maxLineLen * 7.5 + 28);
+ n.height = Math.max(38, n.labelLines.size() * 18 + 16);
+ if (n.shape == NodeShape.HEXAGON) {
+ n.width += 36;
+ n.height += 16;
+ } else if (n.shape == NodeShape.CIRCLE) {
+ double d = Math.max(n.width, n.height) + 10;
+ n.width = d;
+ n.height = d;
+ }
+ }
+ }
+
+ // 1. Partition graph into connected components
+ Map<String, String> parent = new HashMap<>();
+ for (String id : graph.nodes.keySet()) {
+ parent.put(id, id);
+ }
+ for (Edge e : graph.edges) {
+ if (parent.containsKey(e.fromId) && parent.containsKey(e.toId)) {
+ unionSets(parent, e.fromId, e.toId);
+ }
+ }
+ for (Subgraph sg : graph.allSubgraphs) {
+ String sampleId = getSubgraphSampleNodeId(sg);
+ if (sg.nodes.size() > 1) {
+ String firstId = sg.nodes.get(0).id;
+ for (int i = 1; i < sg.nodes.size(); i++) {
+ unionSets(parent, firstId, sg.nodes.get(i).id);
+ }
+ }
+ for (Subgraph child : sg.children) {
+ String childSample = getSubgraphSampleNodeId(child);
+ if (sampleId != null && childSample != null) {
+ unionSets(parent, sampleId, childSample);
+ }
+ }
+ }
+ for (SubgraphEdge se : graph.subgraphEdges) {
+ Subgraph fromSg = graph.lookupSubgraph(se.fromSgId);
+ Subgraph toSg = graph.lookupSubgraph(se.toSgId);
+ if (fromSg != null && toSg != null) {
+ String fromSample = getSubgraphSampleNodeId(fromSg);
+ String toSample = getSubgraphSampleNodeId(toSg);
+ if (fromSample != null && toSample != null) {
+ unionSets(parent, fromSample, toSample);
+ }
+ }
+ }
+
+ Map<String, GraphComponent> compMap = new LinkedHashMap<>();
+ for (Node n : graph.nodes.values()) {
+ String root = findRoot(parent, n.id);
+ GraphComponent comp = compMap.computeIfAbsent(root, k -> new GraphComponent());
+ comp.nodes.put(n.id, n);
+ }
+
+ for (Edge e : graph.edges) {
+ String root = findRoot(parent, e.fromId);
+ GraphComponent comp = compMap.get(root);
+ if (comp != null && comp.nodes.containsKey(e.fromId) && comp.nodes.containsKey(e.toId)) {
+ comp.edges.add(e);
+ }
+ }
+
+ for (Subgraph sg : graph.allSubgraphs) {
+ String sampleId = getSubgraphSampleNodeId(sg);
+ if (sampleId != null) {
+ String root = findRoot(parent, sampleId);
+ GraphComponent comp = compMap.get(root);
+ if (comp != null && !comp.subgraphs.contains(sg)) {
+ comp.subgraphs.add(sg);
+ }
+ }
+ }
+
+ List<GraphComponent> components = new ArrayList<>(compMap.values());
+ components.sort((c1, c2) -> Boolean.compare(!c2.subgraphs.isEmpty(), !c1.subgraphs.isEmpty()));
+
+ for (GraphComponent comp : components) {
+ if (!comp.subgraphs.isEmpty() && comp.edges.isEmpty() && graph.subgraphEdges.isEmpty()) {
+ layoutIsolatedSubgraphs(graph.direction, comp.subgraphs);
+ } else if (!comp.subgraphs.isEmpty()) {
+ layoutCompoundComponent(graph, graph.direction, isHorizontal, comp);
+ } else {
+ layoutBySugiyamaDAG(isHorizontal, comp.nodes, comp.edges);
+ }
+
+ double cMinX = Double.MAX_VALUE, cMinY = Double.MAX_VALUE;
+ double cMaxX = Double.MIN_VALUE, cMaxY = Double.MIN_VALUE;
+ for (Node n : comp.nodes.values()) {
+ cMinX = Math.min(cMinX, n.x);
+ cMinY = Math.min(cMinY, n.y);
+ cMaxX = Math.max(cMaxX, n.x + n.width);
+ cMaxY = Math.max(cMaxY, n.y + n.height);
+ }
+ for (Subgraph sg : comp.subgraphs) {
+ cMinX = Math.min(cMinX, sg.x);
+ cMinY = Math.min(cMinY, sg.y);
+ cMaxX = Math.max(cMaxX, sg.x + sg.width);
+ cMaxY = Math.max(cMaxY, sg.y + sg.height);
+ }
+
+ if (cMinX != Double.MAX_VALUE) {
+ comp.width = cMaxX - cMinX;
+ comp.height = cMaxY - cMinY;
+ for (Node n : comp.nodes.values()) {
+ n.x -= cMinX;
+ n.y -= cMinY;
+ }
+ for (Subgraph sg : comp.subgraphs) {
+ sg.x -= cMinX;
+ sg.y -= cMinY;
+ }
+ for (Edge e : comp.edges) {
+ for (Node v : e.virtualNodes) {
+ v.x -= cMinX;
+ v.y -= cMinY;
+ }
+ }
+ }
+ }
+
+ if (!isHorizontal) {
+ double curX = 0;
+ for (GraphComponent comp : components) {
+ for (Node n : comp.nodes.values()) {
+ n.x += curX;
+ }
+ for (Subgraph sg : comp.subgraphs) {
+ sg.x += curX;
+ }
+ for (Edge e : comp.edges) {
+ for (Node v : e.virtualNodes) {
+ v.x += curX;
+ }
+ }
+ curX += comp.width + 48;
+ }
+ } else {
+ double curY = 0;
+ for (GraphComponent comp : components) {
+ for (Node n : comp.nodes.values()) {
+ n.y += curY;
+ }
+ for (Subgraph sg : comp.subgraphs) {
+ sg.y += curY;
+ }
+ for (Edge e : comp.edges) {
+ for (Node v : e.virtualNodes) {
+ v.y += curY;
+ }
+ }
+ curY += comp.height + 48;
+ }
+ }
+
+ // Compute bounding box
+ double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
+ double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE;
+
+ for (Node n : graph.nodes.values()) {
+ minX = Math.min(minX, n.x);
+ minY = Math.min(minY, n.y);
+ maxX = Math.max(maxX, n.x + n.width);
+ maxY = Math.max(maxY, n.y + n.height);
+ }
+ for (Subgraph sg : graph.allSubgraphs) {
+ minX = Math.min(minX, sg.x);
+ minY = Math.min(minY, sg.y);
+ maxX = Math.max(maxX, sg.x + sg.width);
+ maxY = Math.max(maxY, sg.y + sg.height);
+ }
+ for (Edge e : graph.edges) {
+ for (Node v : e.virtualNodes) {
+ minX = Math.min(minX, v.x);
+ minY = Math.min(minY, v.y);
+ maxX = Math.max(maxX, v.x + v.width);
+ maxY = Math.max(maxY, v.y + v.height);
+ }
+ }
+
+ for (Edge e : graph.edges) {
+ Node src = graph.nodes.get(e.fromId);
+ Node dst = graph.nodes.get(e.toId);
+ if (src != null && dst != null) {
+ double labelW =
+ (e.label != null && !e.label.trim().isEmpty()) ? (e.label.length() * 6.5 + 16) : 0;
+ if (e.isBackEdge || src.layer > dst.layer) {
+ int minL = Math.min(src.layer, dst.layer);
+ int maxL = Math.max(src.layer, dst.layer);
+ if (!isHorizontal) {
+ double maxRight = Math.max(src.x + src.width, dst.x + dst.width);
+ for (Node n : graph.nodes.values()) {
+ if (n.layer >= minL && n.layer <= maxL) {
+ maxRight = Math.max(maxRight, n.x + n.width);
+ }
+ }
+ for (Edge oe : graph.edges) {
+ for (Node v : oe.virtualNodes) {
+ if (v.layer >= minL && v.layer <= maxL) {
+ maxRight = Math.max(maxRight, v.x + v.width);
+ }
+ }
+ }
+ double loopOffset =
+ Math.max(45.0, labelW / 2.0 + 36.0)
+ + (maxRight - Math.min(src.x + src.width / 2.0, dst.x + dst.width / 2.0)) * 0.4;
+ maxX = Math.max(maxX, maxRight + loopOffset + labelW / 2.0 + 12);
+ } else {
+ double minTop = Math.min(src.y, dst.y);
+ for (Node n : graph.nodes.values()) {
+ if (n.layer >= minL && n.layer <= maxL) {
+ minTop = Math.min(minTop, n.y);
+ }
+ }
+ for (Edge oe : graph.edges) {
+ for (Node v : oe.virtualNodes) {
+ if (v.layer >= minL && v.layer <= maxL) {
+ minTop = Math.min(minTop, v.y);
+ }
+ }
+ }
+ double loopOffset =
+ Math.max(45.0, 18.0 + 36.0)
+ + (Math.max(src.y + src.height / 2.0, dst.y + dst.height / 2.0) - minTop) * 0.4;
+ minY = Math.min(minY, minTop - loopOffset - 24);
+ }
+ }
+ }
+ }
+
+ double padding = 28;
+ double offsetX = padding - minX;
+ double offsetY = padding - minY;
+ double totalWidth = (maxX - minX) + padding * 2;
+ double totalHeight = (maxY - minY) + padding * 2;
+
+ for (Node n : graph.nodes.values()) {
+ n.x += offsetX;
+ n.y += offsetY;
+ }
+ for (Subgraph sg : graph.allSubgraphs) {
+ sg.x += offsetX;
+ sg.y += offsetY;
+ }
+ for (Edge e : graph.edges) {
+ for (Node v : e.virtualNodes) {
+ v.x += offsetX;
+ v.y += offsetY;
+ }
+ }
+
+ return renderSvg(graph, isHorizontal, totalWidth, totalHeight);
+ }
+
+ private static void layoutBySugiyamaDAG(
+ boolean isHorizontal,
+ Map<String, Node> allNodes,
+ List<Edge> edges) {
+
+ // 1. Cycle Breaking via DFS
+ Map<String, List<Edge>> adj = new HashMap<>();
+ for (String id : allNodes.keySet()) {
+ adj.put(id, new ArrayList<>());
+ }
+ for (Edge e : edges) {
+ if (adj.containsKey(e.fromId)) {
+ adj.get(e.fromId).add(e);
+ }
+ }
+
+ Map<String, Integer> color = new HashMap<>();
+ for (String id : allNodes.keySet()) {
+ if (color.getOrDefault(id, 0) == 0) {
+ findCyclesDFS(id, adj, color);
+ }
+ }
+
+ // 2. Layer Assignment (Longest Path in DAG)
+ for (Node n : allNodes.values()) {
+ n.layer = 0;
+ }
+ boolean changed = true;
+ int maxIterations = allNodes.size() + 1;
+ int iter = 0;
+ while (changed && iter++ < maxIterations) {
+ changed = false;
+ for (Edge e : edges) {
+ if (!e.isBackEdge) {
+ Node src = allNodes.get(e.fromId);
+ Node dst = allNodes.get(e.toId);
+ if (src != null && dst != null) {
+ if (dst.layer < src.layer + 1) {
+ dst.layer = src.layer + 1;
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ // 2a. Source Node Sinking / Compaction (ALAP for loose source nodes)
+ for (Node n : allNodes.values()) {
+ int inCount = 0;
+ int minOutLayer = Integer.MAX_VALUE;
+ for (Edge e : edges) {
+ if (!e.isBackEdge) {
+ if (e.toId.equals(n.id)) inCount++;
+ if (e.fromId.equals(n.id)) {
+ Node dst = allNodes.get(e.toId);
+ if (dst != null) {
+ minOutLayer = Math.min(minOutLayer, dst.layer);
+ }
+ }
+ }
+ }
+ if (inCount == 0 && minOutLayer != Integer.MAX_VALUE && minOutLayer - 1 > n.layer) {
+ n.layer = minOutLayer - 1;
+ }
+ }
+
+ // 2b. Virtual Dummy Node Insertion for Long Edges
+ for (Edge e : edges) {
+ e.virtualNodes.clear();
+ if (!e.isBackEdge) {
+ Node src = allNodes.get(e.fromId);
+ Node dst = allNodes.get(e.toId);
+ if (src != null && dst != null && dst.layer - src.layer > 1) {
+ for (int l = src.layer + 1; l < dst.layer; l++) {
+ Node dummy = new Node("__v_" + src.id + "_" + dst.id + "_" + l);
+ dummy.isVirtual = true;
+ dummy.layer = l;
+ if (e.label != null && !e.label.trim().isEmpty() && l == src.layer + 1) {
+ dummy.width = Math.max(e.label.trim().length() * 6.5 + 24, 60);
+ dummy.height = 20;
+ } else {
+ dummy.width = 24;
+ dummy.height = 20;
+ }
+ e.virtualNodes.add(dummy);
+ }
+ }
+ }
+ }
+
+ // 3. Layer Ordering & Barycentric Crossing Reduction
+ Map<Integer, List<Node>> layerMap = new TreeMap<>();
+ for (Node n : allNodes.values()) {
+ layerMap.computeIfAbsent(n.layer, k -> new ArrayList<>()).add(n);
+ }
+ for (Edge e : edges) {
+ for (Node v : e.virtualNodes) {
+ layerMap.computeIfAbsent(v.layer, k -> new ArrayList<>()).add(v);
+ }
+ }
+
+ int maxLayer = layerMap.isEmpty() ? 0 : Collections.max(layerMap.keySet());
+ for (int l = 1; l <= maxLayer; l++) {
+ List<Node> currentLayer = layerMap.get(l);
+ List<Node> prevLayer = layerMap.get(l - 1);
+ if (currentLayer == null) continue;
+ for (Node n : currentLayer) {
+ double sum = 0;
+ int count = 0;
+ if (prevLayer != null) {
+ if (n.isVirtual) {
+ for (Edge e : edges) {
+ int vIdx = e.virtualNodes.indexOf(n);
+ if (vIdx != -1) {
+ Node pred = vIdx == 0 ? allNodes.get(e.fromId) : e.virtualNodes.get(vIdx - 1);
+ if (pred != null) {
+ int pos = prevLayer.indexOf(pred);
+ if (pos != -1) {
+ sum += pos;
+ count++;
+ }
+ }
+ break;
+ }
+ }
+ } else {
+ for (Edge e : edges) {
+ if (e.toId.equals(n.id) && !e.isBackEdge) {
+ Node pred;
+ if (!e.virtualNodes.isEmpty()) {
+ pred = e.virtualNodes.get(e.virtualNodes.size() - 1);
+ } else {
+ pred = allNodes.get(e.fromId);
+ }
+ if (pred != null && pred.layer == l - 1) {
+ int pos = prevLayer.indexOf(pred);
+ if (pos != -1) {
+ sum += pos;
+ count++;
+ }
+ }
+ }
+ }
+ }
+ }
+ n.barycenter = count > 0 ? sum / count : currentLayer.indexOf(n);
+ }
+ currentLayer.sort(Comparator.comparingDouble(n -> n.barycenter));
+ }
+
+ // 4. Coordinate Assignment
+ if (!isHorizontal) {
+ // Top-Down
+ double maxGraphWidth = 0;
+ Map<Integer, Double> layerWidths = new HashMap<>();
+ Map<Integer, Double> layerMaxHeights = new HashMap<>();
+
+ for (Map.Entry<Integer, List<Node>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<Node> nodes = entry.getValue();
+ double totalW = 0;
+ double maxH = 38;
+ for (int i = 0; i < nodes.size(); i++) {
+ Node n = nodes.get(i);
+ totalW += n.width + (i < nodes.size() - 1 ? 32 : 0);
+ if (!n.isVirtual) {
+ maxH = Math.max(maxH, n.height);
+ }
+ }
+ layerWidths.put(l, totalW);
+ layerMaxHeights.put(l, maxH);
+ maxGraphWidth = Math.max(maxGraphWidth, totalW);
+ }
+
+ double curY = 0;
+ for (Map.Entry<Integer, List<Node>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<Node> nodes = entry.getValue();
+ double w = layerWidths.get(l);
+ double curX = (maxGraphWidth - w) / 2.0;
+ double maxH = layerMaxHeights.get(l);
+
+ if (nodes.size() == 1 && l > 0) {
+ Node single = nodes.get(0);
+ double parentAvgX = 0;
+ int parentCount = 0;
+ for (Edge e : edges) {
+ if (e.toId.equals(single.id)) {
+ Node p = allNodes.get(e.fromId);
+ if (p != null && p.layer == l - 1) {
+ parentAvgX += p.x + p.width / 2.0;
+ parentCount++;
+ }
+ }
+ }
+ if (parentCount > 0) {
+ double targetX = (parentAvgX / parentCount) - single.width / 2.0;
+ curX = Math.max(0, Math.min(targetX, maxGraphWidth - single.width));
+ }
+ }
+
+ for (int i = 0; i < nodes.size(); i++) {
+ Node n = nodes.get(i);
+ n.x = curX;
+ n.y = curY + (maxH - n.height) / 2.0;
+ curX += n.width + 32;
+ }
+
+ double layerGap = 48;
+ for (Edge e : edges) {
+ Node src = allNodes.get(e.fromId);
+ Node dst = allNodes.get(e.toId);
+ if (src != null && dst != null) {
+ if ((e.virtualNodes.isEmpty() && src.layer == l && dst.layer == l + 1)
+ || (!e.virtualNodes.isEmpty() && src.layer <= l && l < dst.layer)) {
+ if (e.label != null && !e.label.trim().isEmpty()) {
+ layerGap = Math.max(layerGap, 60);
+ }
+ }
+ }
+ }
+ curY += maxH + layerGap;
+ }
+ } else {
+ // Left-to-Right
+ double maxGraphHeight = 0;
+ Map<Integer, Double> layerHeights = new HashMap<>();
+ Map<Integer, Double> layerMaxWidths = new HashMap<>();
+
+ for (Map.Entry<Integer, List<Node>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<Node> nodes = entry.getValue();
+ double totalH = 0;
+ double maxW = 120;
+ for (int i = 0; i < nodes.size(); i++) {
+ Node n = nodes.get(i);
+ totalH += n.height + (i < nodes.size() - 1 ? 24 : 0);
+ if (!n.isVirtual) {
+ maxW = Math.max(maxW, n.width);
+ }
+ }
+ layerHeights.put(l, totalH);
+ layerMaxWidths.put(l, maxW);
+ maxGraphHeight = Math.max(maxGraphHeight, totalH);
+ }
+
+ double curX = 0;
+ for (Map.Entry<Integer, List<Node>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<Node> nodes = entry.getValue();
+ double h = layerHeights.get(l);
+ double curY = (maxGraphHeight - h) / 2.0;
+ double maxW = layerMaxWidths.get(l);
+
+ if (nodes.size() == 1 && l > 0) {
+ Node single = nodes.get(0);
+ double parentAvgY = 0;
+ int parentCount = 0;
+ for (Edge e : edges) {
+ if (e.toId.equals(single.id)) {
+ Node p = allNodes.get(e.fromId);
+ if (p != null && p.layer == l - 1) {
+ parentAvgY += p.y + p.height / 2.0;
+ parentCount++;
+ }
+ }
+ }
+ if (parentCount > 0) {
+ double targetY = (parentAvgY / parentCount) - single.height / 2.0;
+ curY = Math.max(0, Math.min(targetY, maxGraphHeight - single.height));
+ }
+ }
+
+ for (int i = 0; i < nodes.size(); i++) {
+ Node n = nodes.get(i);
+ n.x = curX + (maxW - n.width) / 2.0;
+ n.y = curY;
+ curY += n.height + 24;
+ }
+
+ double layerGap = 55;
+ for (Edge e : edges) {
+ Node src = allNodes.get(e.fromId);
+ Node dst = allNodes.get(e.toId);
+ if (src != null && dst != null) {
+ if ((e.virtualNodes.isEmpty() && src.layer == l && dst.layer == l + 1)
+ || (!e.virtualNodes.isEmpty() && src.layer <= l && l < dst.layer)) {
+ if (e.label != null && !e.label.trim().isEmpty()) {
+ double lw = e.label.trim().length() * 6.5 + 24;
+ layerGap = Math.max(layerGap, lw + 24);
+ }
+ }
+ }
+ }
+ curX += maxW + layerGap;
+ }
+ }
+ }
+
+ private static void findCyclesDFS(
+ String u, Map<String, List<Edge>> adj, Map<String, Integer> color) {
+ color.put(u, 1); // Gray
+ List<Edge> uEdges = adj.get(u);
+ if (uEdges != null) {
+ for (Edge e : uEdges) {
+ String v = e.toId;
+ int vColor = color.getOrDefault(v, 0);
+ if (vColor == 1) {
+ e.isBackEdge = true;
+ } else if (vColor == 0) {
+ findCyclesDFS(v, adj, color);
+ }
+ }
+ }
+ color.put(u, 2); // Black
+ }
+
+ private static @Nullable String getSubgraphSampleNodeId(Subgraph sg) {
+ if (!sg.nodes.isEmpty()) {
+ return sg.nodes.get(0).id;
+ }
+ for (Subgraph child : sg.children) {
+ String id = getSubgraphSampleNodeId(child);
+ if (id != null) return id;
+ }
+ return null;
+ }
+
+ private static int getSubgraphDepth(Subgraph sg) {
+ int depth = 0;
+ Subgraph cur = sg.parent;
+ while (cur != null) {
+ depth++;
+ cur = cur.parent;
+ }
+ return depth;
+ }
+
+ private static class LayoutUnit {
+ final String id;
+ final @Nullable Subgraph subgraph;
+ final @Nullable Node node;
+ double width;
+ double height;
+ double x;
+ double y;
+ int layer = 0;
+ double barycenter = 0;
+
+ LayoutUnit(Subgraph sg) {
+ this.id = "sg_" + sg.id;
+ this.subgraph = sg;
+ this.node = null;
+ this.width = sg.width;
+ this.height = sg.height;
+ }
+
+ LayoutUnit(Node n) {
+ this.id = "n_" + n.id;
+ this.subgraph = null;
+ this.node = n;
+ this.width = n.width;
+ this.height = n.height;
+ }
+ }
+
+ private static void registerNodesToUnit(
+ Subgraph sg, LayoutUnit u, Map<String, LayoutUnit> map) {
+ for (Node n : sg.nodes) {
+ map.put(n.id, u);
+ }
+ for (Subgraph child : sg.children) {
+ registerNodesToUnit(child, u, map);
+ }
+ }
+
+ private static void layoutUnits(
+ boolean isHorizontal,
+ List<LayoutUnit> units,
+ Map<String, LayoutUnit> unitMap,
+ List<Edge> unitEdges) {
+ if (units.size() <= 1) return;
+
+ // 1. Cycle Breaking
+ Map<String, List<Edge>> uAdj = new HashMap<>();
+ for (LayoutUnit u : units) {
+ uAdj.put(u.id, new ArrayList<>());
+ }
+ for (Edge ue : unitEdges) {
+ if (uAdj.containsKey(ue.fromId)) {
+ uAdj.get(ue.fromId).add(ue);
+ }
+ }
+ Map<String, Integer> uColor = new HashMap<>();
+ for (LayoutUnit u : units) {
+ if (uColor.getOrDefault(u.id, 0) == 0) {
+ findCyclesDFS(u.id, uAdj, uColor);
+ }
+ }
+
+ // 2. Layer Assignment
+ for (LayoutUnit u : units) {
+ u.layer = 0;
+ }
+ boolean changed = true;
+ int maxIter = units.size() + 1;
+ int iter = 0;
+ while (changed && iter++ < maxIter) {
+ changed = false;
+ for (Edge ue : unitEdges) {
+ if (!ue.isBackEdge) {
+ LayoutUnit src = unitMap.get(ue.fromId);
+ LayoutUnit dst = unitMap.get(ue.toId);
+ if (src != null && dst != null) {
+ if (dst.layer < src.layer + 1) {
+ dst.layer = src.layer + 1;
+ changed = true;
+ }
+ }
+ }
+ }
+ }
+
+ // 2a. Source Unit Sinking / Compaction
+ for (LayoutUnit u : units) {
+ int inCount = 0;
+ int minOutLayer = Integer.MAX_VALUE;
+ for (Edge ue : unitEdges) {
+ if (!ue.isBackEdge) {
+ LayoutUnit src = unitMap.get(ue.fromId);
+ LayoutUnit dst = unitMap.get(ue.toId);
+ if (dst != null && dst.id.equals(u.id) && (src == null || !src.id.equals(u.id))) inCount++;
+ if (src != null && src.id.equals(u.id) && dst != null && !dst.id.equals(u.id)) {
+ minOutLayer = Math.min(minOutLayer, dst.layer);
+ }
+ }
+ }
+ if (inCount == 0 && minOutLayer != Integer.MAX_VALUE && minOutLayer - 1 > u.layer) {
+ u.layer = minOutLayer - 1;
+ }
+ }
+
+ // 3. Layer Map & Barycentric Ordering
+ Map<Integer, List<LayoutUnit>> layerMap = new TreeMap<>();
+ for (LayoutUnit u : units) {
+ layerMap.computeIfAbsent(u.layer, k -> new ArrayList<>()).add(u);
+ }
+ int maxLayer = layerMap.isEmpty() ? 0 : Collections.max(layerMap.keySet());
+ for (int l = 1; l <= maxLayer; l++) {
+ List<LayoutUnit> currentLayer = layerMap.get(l);
+ List<LayoutUnit> prevLayer = layerMap.get(l - 1);
+ if (currentLayer == null) continue;
+ for (LayoutUnit u : currentLayer) {
+ double sum = 0;
+ int count = 0;
+ if (prevLayer != null) {
+ for (Edge ue : unitEdges) {
+ if (ue.toId.equals(u.id) && !ue.isBackEdge) {
+ LayoutUnit src = unitMap.get(ue.fromId);
+ if (src != null && src.layer == l - 1) {
+ int pos = prevLayer.indexOf(src);
+ if (pos != -1) {
+ sum += pos;
+ count++;
+ }
+ }
+ }
+ }
+ }
+ u.barycenter = count > 0 ? sum / count : currentLayer.indexOf(u);
+ }
+ currentLayer.sort(Comparator.comparingDouble(u -> u.barycenter));
+ }
+
+ // 4. Coordinate Assignment
+ if (!isHorizontal) {
+ // Top-Down
+ double maxW = 0;
+ Map<Integer, Double> layerWidths = new HashMap<>();
+ Map<Integer, Double> layerMaxHeights = new HashMap<>();
+ for (Map.Entry<Integer, List<LayoutUnit>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<LayoutUnit> lUnits = entry.getValue();
+ double tw = 0;
+ double mh = 0;
+ for (int i = 0; i < lUnits.size(); i++) {
+ LayoutUnit u = lUnits.get(i);
+ tw += u.width + (i < lUnits.size() - 1 ? 36 : 0);
+ mh = Math.max(mh, u.height);
+ }
+ layerWidths.put(l, tw);
+ layerMaxHeights.put(l, mh);
+ maxW = Math.max(maxW, tw);
+ }
+
+ double curY = 0;
+ for (Map.Entry<Integer, List<LayoutUnit>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<LayoutUnit> lUnits = entry.getValue();
+ double w = layerWidths.get(l);
+ double curX = (maxW - w) / 2.0;
+ double mh = layerMaxHeights.get(l);
+ for (LayoutUnit u : lUnits) {
+ u.x = curX;
+ u.y = curY + (mh - u.height) / 2.0;
+ curX += u.width + 36;
+ }
+ double layerGap = 45;
+ for (Edge ue : unitEdges) {
+ LayoutUnit src = unitMap.get(ue.fromId);
+ LayoutUnit dst = unitMap.get(ue.toId);
+ if (src != null && dst != null && src.layer <= l && l < dst.layer) {
+ if (ue.label != null && !ue.label.trim().isEmpty()) {
+ layerGap = Math.max(layerGap, 55);
+ }
+ }
+ }
+ curY += mh + layerGap;
+ }
+ } else {
+ // Left-to-Right
+ double maxH = 0;
+ Map<Integer, Double> layerHeights = new HashMap<>();
+ Map<Integer, Double> layerMaxWidths = new HashMap<>();
+ for (Map.Entry<Integer, List<LayoutUnit>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<LayoutUnit> lUnits = entry.getValue();
+ double th = 0;
+ double mw = 0;
+ for (int i = 0; i < lUnits.size(); i++) {
+ LayoutUnit u = lUnits.get(i);
+ th += u.height + (i < lUnits.size() - 1 ? 36 : 0);
+ mw = Math.max(mw, u.width);
+ }
+ layerHeights.put(l, th);
+ layerMaxWidths.put(l, mw);
+ maxH = Math.max(maxH, th);
+ }
+
+ double curX = 0;
+ for (Map.Entry<Integer, List<LayoutUnit>> entry : layerMap.entrySet()) {
+ int l = entry.getKey();
+ List<LayoutUnit> lUnits = entry.getValue();
+ double h = layerHeights.get(l);
+ double curY = (maxH - h) / 2.0;
+ double mw = layerMaxWidths.get(l);
+ for (LayoutUnit u : lUnits) {
+ u.x = curX + (mw - u.width) / 2.0;
+ u.y = curY;
+ curY += u.height + 36;
+ }
+
+ double layerGap = 45;
+ for (Edge ue : unitEdges) {
+ LayoutUnit src = unitMap.get(ue.fromId);
+ LayoutUnit dst = unitMap.get(ue.toId);
+ if (src != null && dst != null && src.layer <= l && l < dst.layer) {
+ if (ue.label != null && !ue.label.trim().isEmpty()) {
+ double lw = ue.label.trim().length() * 6.5 + 24;
+ layerGap = Math.max(layerGap, lw + 24);
+ }
+ }
+ }
+ curX += mw + layerGap;
+ }
+ }
+ }
+
+ private static void layoutCompoundComponent(
+ MermaidGraph graph, Direction compDir, boolean isHorizontal, GraphComponent comp) {
+
+ // 1. Find root subgraphs and recursively compute internal sizes
+ List<Subgraph> rootSgs = new ArrayList<>();
+ for (Subgraph sg : comp.subgraphs) {
+ if (sg.parent == null || !comp.subgraphs.contains(sg.parent)) {
+ rootSgs.add(sg);
+ }
+ }
+ for (Subgraph sg : rootSgs) {
+ computeSubgraphSizes(
+ sg,
+ rootSgs.size() == 1 && sg.direction != null ? sg.direction : compDir,
+ comp.edges,
+ graph.subgraphEdges,
+ comp.nodes);
+ }
+
+ // 2. Build LayoutUnits for root subgraphs and standalone nodes
+ List<LayoutUnit> units = new ArrayList<>();
+ Map<String, LayoutUnit> unitMap = new LinkedHashMap<>();
+
+ for (Subgraph sg : rootSgs) {
+ LayoutUnit u = new LayoutUnit(sg);
+ units.add(u);
+ unitMap.put(u.id, u);
+ }
+ for (Node n : comp.nodes.values()) {
+ if (n.parentSubgraph == null) {
+ LayoutUnit u = new LayoutUnit(n);
+ units.add(u);
+ unitMap.put(u.id, u);
+ }
+ }
+
+ if (units.size() == 1) {
+ LayoutUnit u = units.get(0);
+ u.x = 0;
+ u.y = 0;
+ if (u.subgraph != null) {
+ u.subgraph.x = 0;
+ u.subgraph.y = 0;
+ assignAbsoluteCoordinates(u.subgraph, 0, 0);
+ }
+ return;
+ }
+
+ // 3. Map each Node ID to its root LayoutUnit
+ Map<String, LayoutUnit> nodeToUnit = new HashMap<>();
+ for (LayoutUnit u : units) {
+ if (u.node != null) {
+ nodeToUnit.put(u.node.id, u);
+ } else if (u.subgraph != null) {
+ registerNodesToUnit(u.subgraph, u, nodeToUnit);
+ }
+ }
+
+ // 4. Build Unit Edges (meta-graph)
+ List<Edge> unitEdges = new ArrayList<>();
+ Set<String> seenUnitEdges = new HashSet<>();
+ for (Edge e : comp.edges) {
+ LayoutUnit u1 = nodeToUnit.get(e.fromId);
+ LayoutUnit u2 = nodeToUnit.get(e.toId);
+ if (u1 != null && u2 != null && !u1.id.equals(u2.id)) {
+ String key = u1.id + "->" + u2.id;
+ if (!seenUnitEdges.contains(key)) {
+ seenUnitEdges.add(key);
+ Edge ue = new Edge(u1.id, u2.id, e.label, e.stroke, e.arrow);
+ unitEdges.add(ue);
+ }
+ }
+ }
+ for (SubgraphEdge se : graph.subgraphEdges) {
+ Subgraph sg1 = graph.lookupSubgraph(se.fromSgId);
+ Subgraph sg2 = graph.lookupSubgraph(se.toSgId);
+ if (sg1 != null && sg2 != null) {
+ String s1Id = getSubgraphSampleNodeId(sg1);
+ String s2Id = getSubgraphSampleNodeId(sg2);
+ LayoutUnit u1 = s1Id != null ? nodeToUnit.get(s1Id) : unitMap.get("sg_" + sg1.id);
+ LayoutUnit u2 = s2Id != null ? nodeToUnit.get(s2Id) : unitMap.get("sg_" + sg2.id);
+ if (u1 != null && u2 != null && !u1.id.equals(u2.id)) {
+ String key = u1.id + "->" + u2.id;
+ if (!seenUnitEdges.contains(key)) {
+ seenUnitEdges.add(key);
+ Edge ue = new Edge(u1.id, u2.id, se.label, se.stroke, se.arrow);
+ unitEdges.add(ue);
+ }
+ }
+ }
+ }
+
+ // 5. Run Sugiyama Layout on Units
+ layoutUnits(isHorizontal, units, unitMap, unitEdges);
+
+ // 6. Assign Absolute Coordinates
+ for (LayoutUnit u : units) {
+ if (u.subgraph != null) {
+ u.subgraph.x = u.x;
+ u.subgraph.y = u.y;
+ assignAbsoluteCoordinates(u.subgraph, u.x, u.y);
+ } else if (u.node != null) {
+ u.node.x = u.x;
+ u.node.y = u.y;
+ }
+ }
+ }
+
+ private static void computeSubgraphSizes(
+ Subgraph sg,
+ Direction parentDirection,
+ List<Edge> edges,
+ List<SubgraphEdge> subgraphEdges,
+ Map<String, Node> allNodes) {
+ Direction dir = sg.direction != null ? sg.direction : parentDirection;
+ boolean isHorizontal = (dir == Direction.LR || dir == Direction.RL);
+
+ // 1. Recursively compute internal sizes of all child subgraphs
+ for (Subgraph child : sg.children) {
+ computeSubgraphSizes(child, dir, edges, subgraphEdges, allNodes);
+ }
+
+ double padding = 20;
+ double headerH = 28;
+
+ // 2. Build LayoutUnits for direct child subgraphs and direct child nodes
+ List<LayoutUnit> units = new ArrayList<>();
+ Map<String, LayoutUnit> unitMap = new LinkedHashMap<>();
+
+ for (Subgraph child : sg.children) {
+ LayoutUnit u = new LayoutUnit(child);
+ units.add(u);
+ unitMap.put(u.id, u);
+ }
+ for (Node n : sg.nodes) {
+ LayoutUnit u = new LayoutUnit(n);
+ units.add(u);
+ unitMap.put(u.id, u);
+ }
+
+ if (units.isEmpty()) {
+ sg.width = 100;
+ sg.height = 60;
+ return;
+ }
+
+ if (units.size() == 1) {
+ LayoutUnit u = units.get(0);
+ u.x = padding;
+ u.y = headerH + padding;
+ if (u.subgraph != null) {
+ u.subgraph.relX = u.x;
+ u.subgraph.relY = u.y;
+ } else if (u.node != null) {
+ u.node.relX = u.x;
+ u.node.relY = u.y;
+ }
+ sg.width = u.width + padding * 2;
+ sg.height = u.height + padding * 2 + headerH;
+ return;
+ }
+
+ // 3. Map each Node ID to its immediate LayoutUnit inside sg
+ Map<String, LayoutUnit> nodeToUnit = new HashMap<>();
+ for (LayoutUnit u : units) {
+ if (u.node != null) {
+ nodeToUnit.put(u.node.id, u);
+ } else if (u.subgraph != null) {
+ registerNodesToUnit(u.subgraph, u, nodeToUnit);
+ }
+ }
+
+ // 4. Build Unit Edges (meta-graph) for edges where both endpoints are in sg
+ List<Edge> unitEdges = new ArrayList<>();
+ Set<String> seenUnitEdges = new HashSet<>();
+ for (Edge e : edges) {
+ LayoutUnit u1 = nodeToUnit.get(e.fromId);
+ LayoutUnit u2 = nodeToUnit.get(e.toId);
+ if (u1 != null && u2 != null && !u1.id.equals(u2.id)) {
+ String key = u1.id + "->" + u2.id;
+ if (!seenUnitEdges.contains(key)) {
+ seenUnitEdges.add(key);
+ Edge ue = new Edge(u1.id, u2.id, e.label, e.stroke, e.arrow);
+ unitEdges.add(ue);
+ }
+ }
+ }
+ for (SubgraphEdge se : subgraphEdges) {
+ Subgraph sg1 = lookupSubgraphInTree(sg, se.fromSgId);
+ Subgraph sg2 = lookupSubgraphInTree(sg, se.toSgId);
+ if (sg1 != null && sg2 != null) {
+ String s1Id = getSubgraphSampleNodeId(sg1);
+ String s2Id = getSubgraphSampleNodeId(sg2);
+ LayoutUnit u1 = s1Id != null ? nodeToUnit.get(s1Id) : unitMap.get("sg_" + sg1.id);
+ LayoutUnit u2 = s2Id != null ? nodeToUnit.get(s2Id) : unitMap.get("sg_" + sg2.id);
+ if (u1 != null && u2 != null && !u1.id.equals(u2.id)) {
+ String key = u1.id + "->" + u2.id;
+ if (!seenUnitEdges.contains(key)) {
+ seenUnitEdges.add(key);
+ Edge ue = new Edge(u1.id, u2.id, se.label, se.stroke, se.arrow);
+ unitEdges.add(ue);
+ }
+ }
+ }
+ }
+
+ // 5. Run Sugiyama DAG layout on units
+ layoutUnits(isHorizontal, units, unitMap, unitEdges);
+
+ // 6. Assign relative coordinates inside sg and compute sg dimensions
+ double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
+ double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE;
+ for (LayoutUnit u : units) {
+ minX = Math.min(minX, u.x);
+ minY = Math.min(minY, u.y);
+ maxX = Math.max(maxX, u.x + u.width);
+ maxY = Math.max(maxY, u.y + u.height);
+ }
+
+ for (LayoutUnit u : units) {
+ double rx = padding + (u.x - minX);
+ double ry = headerH + padding + (u.y - minY);
+ if (u.subgraph != null) {
+ u.subgraph.relX = rx;
+ u.subgraph.relY = ry;
+ } else if (u.node != null) {
+ u.node.relX = rx;
+ u.node.relY = ry;
+ }
+ }
+
+ sg.width = (maxX - minX) + padding * 2;
+ sg.height = (maxY - minY) + padding * 2 + headerH;
+ }
+
+ private static @Nullable Subgraph lookupSubgraphInTree(Subgraph root, String id) {
+ if (root.id.equals(id)) return root;
+ for (Subgraph child : root.children) {
+ Subgraph res = lookupSubgraphInTree(child, id);
+ if (res != null) return res;
+ }
+ return null;
+ }
+
+ private static void assignAbsoluteCoordinates(Subgraph sg, double parentAbsX, double parentAbsY) {
+ for (Subgraph child : sg.children) {
+ child.x = parentAbsX + child.relX;
+ child.y = parentAbsY + child.relY;
+ assignAbsoluteCoordinates(child, child.x, child.y);
+ }
+ for (Node n : sg.nodes) {
+ n.x = parentAbsX + n.relX;
+ n.y = parentAbsY + n.relY;
+ }
+ }
+
+ private static Direction getSubgraphEffectiveDirection(@Nullable Subgraph sg, Direction fallback) {
+ Subgraph cur = sg;
+ while (cur != null) {
+ if (cur.direction != null) return cur.direction;
+ cur = cur.parent;
+ }
+ return fallback;
+ }
+
+ // =========================================================================
+ // SVG Renderer
+ // =========================================================================
+
+ private static String renderSvg(
+ MermaidGraph graph,
+ boolean isHorizontal,
+ double width,
+ double height) {
+
+ StringBuilder svg = new StringBuilder(4096);
+ svg.append(
+ String.format(
+ "<svg class=\"mermaid-svg\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 %.0f %.0f\" style=\"max-width: %.0fpx; width: 100%%; height: auto;\">\n",
+ width, height, width));
+
+ svg.append(" <defs>\n");
+ svg.append(
+ " <marker id=\"mermaid-arrow\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\">\n");
+ svg.append(" <path d=\"M 0 1.5 L 10 5 L 0 8.5 z\" fill=\"#64748b\" />\n");
+ svg.append(" </marker>\n");
+ svg.append(" <filter id=\"node-shadow\" x=\"-5%\" y=\"-5%\" width=\"115%\" height=\"120%\">\n");
+ svg.append(" <feDropShadow dx=\"0\" dy=\"1.5\" stdDeviation=\"2\" flood-color=\"#0f172a\" flood-opacity=\"0.06\" />\n");
+ svg.append(" </filter>\n");
+ svg.append(" </defs>\n");
+
+ // 1. Render Subgraphs (sorted by depth so parent containers render before child containers)
+ List<Subgraph> sortedSubgraphs = new ArrayList<>(graph.allSubgraphs);
+ sortedSubgraphs.sort(Comparator.comparingInt(SimpleMermaidRenderer::getSubgraphDepth));
+ for (Subgraph sg : sortedSubgraphs) {
+ renderSubgraph(svg, sg);
+ }
+
+ // 2. Render Subgraph Edges
+ for (SubgraphEdge se : graph.subgraphEdges) {
+ Subgraph sg1 = graph.subgraphsMap.get(se.fromSgId);
+ Subgraph sg2 = graph.subgraphsMap.get(se.toSgId);
+ if (sg1 != null && sg2 != null) {
+ boolean sgEdgeHorizontal = isHorizontal;
+ if (sg1.parent != null && sg1.parent.equals(sg2.parent)) {
+ Direction sgDir = getSubgraphEffectiveDirection(sg1.parent, graph.direction);
+ sgEdgeHorizontal = (sgDir == Direction.LR || sgDir == Direction.RL);
+ }
+ renderSubgraphEdge(svg, sgEdgeHorizontal, graph, sg1, sg2, se);
+ }
+ }
+
+ // 3. Render Node Edges
+ for (Edge e : graph.edges) {
+ Node src = graph.nodes.get(e.fromId);
+ Node dst = graph.nodes.get(e.toId);
+ if (src != null && dst != null) {
+ boolean edgeHorizontal = isHorizontal;
+ if (src.parentSubgraph != null && src.parentSubgraph.equals(dst.parentSubgraph)) {
+ Direction sgDir = getSubgraphEffectiveDirection(src.parentSubgraph, graph.direction);
+ edgeHorizontal = (sgDir == Direction.LR || sgDir == Direction.RL);
+ }
+ renderEdge(svg, edgeHorizontal, graph, src, dst, e);
+ }
+ }
+
+ // 4. Render Nodes
+ for (Node n : graph.nodes.values()) {
+ renderNode(svg, n);
+ }
+
+ svg.append("</svg>");
+ return svg.toString();
+ }
+
+ private static void renderSubgraph(StringBuilder svg, Subgraph sg) {
+ int depth = getSubgraphDepth(sg);
+ String fill = sg.customFill != null ? sg.customFill : (depth % 2 == 0 ? "#fafafa" : "#f8fafc");
+ String stroke = sg.customStroke != null ? sg.customStroke : "#cbd5e1";
+ svg.append(
+ String.format(
+ " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"8\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" stroke-dasharray=\"4,4\" />\n",
+ sg.x, sg.y, sg.width, sg.height, fill, stroke));
+ if (sg.title != null && !sg.title.isEmpty()) {
+ svg.append(
+ String.format(
+ " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"600\" fill=\"#334155\">%s</text>\n",
+ sg.x + 14, sg.y + 18, escapeXml(sg.title)));
+ }
+ }
+
+ private static void renderNode(StringBuilder svg, Node n) {
+ double rx = 6;
+ if (n.shape == NodeShape.ROUNDED) {
+ rx = 10;
+ } else if (n.shape == NodeShape.STADIUM) {
+ rx = n.height / 2.0;
+ }
+
+ String fill = n.customFill != null ? n.customFill : "#ffffff";
+ String stroke = n.customStroke != null ? n.customStroke : "#64748b";
+
+ // Shape Geometry
+ if (n.shape == NodeShape.CIRCLE) {
+ double r = n.width / 2.0;
+ svg.append(
+ String.format(
+ " <circle cx=\"%.1f\" cy=\"%.1f\" r=\"%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x + r, n.y + r, r, fill, stroke));
+ } else if (n.shape == NodeShape.DIAMOND) {
+ double cx = n.x + n.width / 2.0;
+ double cy = n.y + n.height / 2.0;
+ svg.append(
+ String.format(
+ " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ cx, n.y, n.x + n.width, cy, cx, n.y + n.height, n.x, cy, fill, stroke));
+ } else if (n.shape == NodeShape.HEXAGON) {
+ double h2 = n.height / 2.0;
+ double indent = 16;
+ svg.append(
+ String.format(
+ " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x + indent, n.y,
+ n.x + n.width - indent, n.y,
+ n.x + n.width, n.y + h2,
+ n.x + n.width - indent, n.y + n.height,
+ n.x + indent, n.y + n.height,
+ n.x, n.y + h2, fill, stroke));
+ } else if (n.shape == NodeShape.CYLINDER) {
+ double ry = 7.0;
+ double rxCyl = n.width / 2.0;
+ double h = n.height;
+ svg.append(
+ String.format(
+ " <path d=\"M %.1f %.1f a %.1f,%.1f 0 1,0 %.1f,0 a %.1f,%.1f 0 1,0 -%.1f,0 l 0,%.1f a %.1f,%.1f 0 0,0 %.1f,0 l 0,-%.1f Z\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x, n.y + ry, rxCyl, ry, n.width, rxCyl, ry, n.width, h - ry * 2, rxCyl, ry, n.width, h - ry * 2, fill, stroke));
+ svg.append(
+ String.format(
+ " <path d=\"M %.1f %.1f a %.1f,%.1f 0 0,0 %.1f,0\" fill=\"none\" stroke=\"%s\" stroke-width=\"1.5\" />\n",
+ n.x, n.y + ry, rxCyl, ry, n.width, stroke));
+ } else if (n.shape == NodeShape.FLAG) {
+ double notch = 12;
+ svg.append(
+ String.format(
+ " <polygon points=\"%.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f %.1f,%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x, n.y, n.x + n.width, n.y, n.x + n.width - notch, n.y + n.height / 2.0, n.x + n.width, n.y + n.height, n.x, n.y + n.height, fill, stroke));
+ } else if (n.shape == NodeShape.SUBROUTINE) {
+ svg.append(
+ String.format(
+ " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"4\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x, n.y, n.width, n.height, fill, stroke));
+ svg.append(
+ String.format(
+ " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\" stroke-width=\"1.5\" />\n",
+ n.x + 10, n.y, n.x + 10, n.y + n.height, stroke));
+ svg.append(
+ String.format(
+ " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"%s\" stroke-width=\"1.5\" />\n",
+ n.x + n.width - 10, n.y, n.x + n.width - 10, n.y + n.height, stroke));
+ } else {
+ svg.append(
+ String.format(
+ " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"%.1f\" fill=\"%s\" stroke=\"%s\" stroke-width=\"1.5\" filter=\"url(#node-shadow)\" />\n",
+ n.x, n.y, n.width, n.height, rx, fill, stroke));
+ }
+
+ // Node Text using structured AST labelLines
+ double cx = n.x + n.width / 2.0;
+ double textYOffset = n.shape == NodeShape.CYLINDER ? 4.0 : 0.0;
+ double startTextY = n.y + textYOffset + (n.height - (n.labelLines.size() - 1) * 16) / 2.0;
+
+ if (n.labelLines.size() == 1) {
+ svg.append(
+ String.format(
+ " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" font-weight=\"500\" fill=\"#0f172a\" text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n",
+ cx, n.y + textYOffset + n.height / 2.0, escapeXml(n.labelLines.get(0).trim())));
+ } else {
+ svg.append(
+ String.format(
+ " <text x=\"%.1f\" y=\"%.1f\" font-size=\"12\" text-anchor=\"middle\">\n",
+ cx, startTextY));
+ for (int i = 0; i < n.labelLines.size(); i++) {
+ String weight = i == 0 ? "600" : "400";
+ String textColor = i == 0 ? "#0f172a" : "#475569";
+ String fontSize = i == 0 ? "12" : "10.5";
+ svg.append(
+ String.format(
+ " <tspan x=\"%.1f\" dy=\"%s\" font-size=\"%s\" font-weight=\"%s\" fill=\"%s\">%s</tspan>\n",
+ cx, i == 0 ? "0" : "16", fontSize, weight, textColor, escapeXml(n.labelLines.get(i).trim())));
+ }
+ svg.append(" </text>\n");
+ }
+ }
+
+ private static void renderSubgraphEdge(
+ StringBuilder svg,
+ boolean isHorizontal,
+ MermaidGraph graph,
+ Subgraph sg1,
+ Subgraph sg2,
+ SubgraphEdge se) {
+
+ String strokeDash = se.stroke == EdgeStroke.DASHED ? "stroke-dasharray=\"4,4\" " : "";
+ String strokeWidth = se.stroke == EdgeStroke.THICK ? "2.5" : "1.5";
+ String marker = se.arrow ? "marker-end=\"url(#mermaid-arrow)\" " : "";
+
+ double startX, startY, endX, endY;
+ if (isHorizontal) {
+ startX = sg1.x + sg1.width;
+ startY = sg1.y + sg1.height / 2.0;
+ endX = sg2.x;
+ endY = sg2.y + sg2.height / 2.0;
+
+ double minY = Math.min(startY, endY) - 10;
+ double maxY = Math.max(startY, endY) + 10;
+ boolean blocked = false;
+ double maxBottom = Math.max(sg1.y + sg1.height, sg2.y + sg2.height);
+ for (Node n : graph.nodes.values()) {
+ if (n.x >= startX - 10 && n.x + n.width <= endX + 10 && n.y <= maxY && n.y + n.height >= minY) {
+ blocked = true;
+ maxBottom = Math.max(maxBottom, n.y + n.height);
+ }
+ }
+
+ if (blocked) {
+ double labelW = (se.label != null && !se.label.trim().isEmpty()) ? se.label.trim().length() * 6.5 + 12 : 20;
+ double loopOffset = Math.max(35.0, labelW / 2.0 + 20.0);
+ double cpY = maxBottom + loopOffset;
+ svg.append(
+ String.format(
+ " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ startX, startY, startX + 20, cpY, endX - 20, cpY, endX, endY, strokeWidth, strokeDash, marker));
+ if (se.label != null && !se.label.trim().isEmpty()) {
+ double midX = (startX + endX) / 2.0;
+ renderEdgeLabelBadge(svg, midX, cpY, se.label.trim());
+ }
+ return;
+ }
+ } else {
+ startX = sg1.x + sg1.width / 2.0;
+ startY = sg1.y + sg1.height;
+ endX = sg2.x + sg2.width / 2.0;
+ endY = sg2.y;
+
+ double minX = Math.min(startX, endX) - 10;
+ double maxX = Math.max(startX, endX) + 10;
+ boolean blocked = false;
+ double maxRight = Math.max(sg1.x + sg1.width, sg2.x + sg2.width);
+ for (Node n : graph.nodes.values()) {
+ if (n.y >= startY - 10 && n.y + n.height <= endY + 10 && n.x <= maxX && n.x + n.width >= minX) {
+ blocked = true;
+ maxRight = Math.max(maxRight, n.x + n.width);
+ }
+ }
+
+ if (blocked) {
+ double labelW = (se.label != null && !se.label.trim().isEmpty()) ? se.label.trim().length() * 6.5 + 12 : 20;
+ double loopOffset = Math.max(35.0, labelW / 2.0 + 20.0);
+ double cpX = maxRight + loopOffset;
+ svg.append(
+ String.format(
+ " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ startX, startY, cpX, startY + 20, cpX, endY - 20, endX, endY, strokeWidth, strokeDash, marker));
+ if (se.label != null && !se.label.trim().isEmpty()) {
+ double midY = (startY + endY) / 2.0;
+ renderEdgeLabelBadge(svg, cpX, midY, se.label.trim());
+ }
+ return;
+ }
+ }
+
+ svg.append(
+ String.format(
+ " <line x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ startX, startY, endX, endY, strokeWidth, strokeDash, marker));
+
+ if (se.label != null && !se.label.trim().isEmpty()) {
+ double midX = (startX + endX) / 2.0;
+ double midY = (startY + endY) / 2.0;
+ renderEdgeLabelBadge(svg, midX, midY, se.label.trim());
+ }
+ }
+
+ private static void renderEdge(
+ StringBuilder svg,
+ boolean isHorizontal,
+ MermaidGraph graph,
+ Node src,
+ Node dst,
+ Edge e) {
+
+ String strokeDash = e.stroke == EdgeStroke.DASHED ? "stroke-dasharray=\"4,4\" " : "";
+ String strokeWidth = e.stroke == EdgeStroke.THICK ? "2.5" : "1.5";
+ String marker = e.arrow ? "marker-end=\"url(#mermaid-arrow)\" " : "";
+
+ double x1, y1, x2, y2;
+ double cp1x, cp1y, cp2x, cp2y;
+
+ if (!isHorizontal) {
+ if (src.id.equals(dst.id)) {
+ // Self-loop
+ x1 = src.x + src.width;
+ y1 = src.y + src.height * 0.3;
+ x2 = src.x + src.width;
+ y2 = src.y + src.height * 0.7;
+ cp1x = x1 + 35;
+ cp1y = y1 - 20;
+ cp2x = x2 + 35;
+ cp2y = y2 + 20;
+ } else if (e.isBackEdge || src.layer > dst.layer) {
+ // Cycle back-edge loop
+ x1 = src.x + src.width;
+ y1 = src.y + src.height / 2.0;
+ x2 = dst.x + dst.width;
+ y2 = dst.y + dst.height / 2.0;
+ double maxRight = Math.max(x1, x2);
+ int minL = Math.min(src.layer, dst.layer);
+ int maxL = Math.max(src.layer, dst.layer);
+ for (Node n : graph.nodes.values()) {
+ if (n.layer >= minL && n.layer <= maxL) {
+ maxRight = Math.max(maxRight, n.x + n.width);
+ }
+ }
+ for (Edge oe : graph.edges) {
+ for (Node v : oe.virtualNodes) {
+ if (v.layer >= minL && v.layer <= maxL) {
+ maxRight = Math.max(maxRight, v.x + v.width);
+ }
+ }
+ }
+ double labelW =
+ (e.label != null && !e.label.trim().isEmpty()) ? (e.label.trim().length() * 6.5 + 16) : 0;
+ double loopOffset =
+ Math.max(45.0, labelW / 2.0 + 36.0) + (maxRight - Math.min(x1, x2)) * 0.4;
+ cp1x = maxRight + loopOffset;
+ cp1y = y1;
+ cp2x = maxRight + loopOffset;
+ cp2y = y2;
+ } else if (!e.virtualNodes.isEmpty()) {
+ List<Double> px = new ArrayList<>();
+ List<Double> py = new ArrayList<>();
+ px.add(src.x + src.width / 2.0);
+ py.add(src.y + src.height);
+ for (Node v : e.virtualNodes) {
+ px.add(v.x + v.width / 2.0);
+ py.add(v.y + v.height / 2.0);
+ }
+ px.add(dst.x + dst.width / 2.0);
+ py.add(dst.y);
+
+ StringBuilder pathD = new StringBuilder();
+ pathD.append(String.format("M %.1f %.1f", px.get(0), py.get(0)));
+ for (int i = 0; i < px.size() - 1; i++) {
+ double xA = px.get(i), yA = py.get(i);
+ double xB = px.get(i + 1), yB = py.get(i + 1);
+ double dy = yB - yA;
+ pathD.append(
+ String.format(
+ " C %.1f %.1f, %.1f %.1f, %.1f %.1f",
+ xA, yA + dy * 0.5, xB, yB - dy * 0.5, xB, yB));
+ }
+ svg.append(
+ String.format(
+ " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ pathD.toString(), strokeWidth, strokeDash, marker));
+
+ if (e.label != null && !e.label.trim().isEmpty()) {
+ Node firstV = e.virtualNodes.get(0);
+ renderEdgeLabelBadge(svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim());
+ }
+ return;
+ } else {
+ // Standard forward edge
+ x1 = src.x + src.width / 2.0;
+ y1 = src.y + src.height;
+ x2 = dst.x + dst.width / 2.0;
+ y2 = dst.y;
+ double dy = y2 - y1;
+ cp1x = x1;
+ cp1y = y1 + dy * 0.5;
+ cp2x = x2;
+ cp2y = y1 + dy * 0.5;
+ }
+ } else {
+ // Horizontal (LR)
+ if (src.id.equals(dst.id)) {
+ x1 = src.x + src.width * 0.3;
+ y1 = src.y;
+ x2 = src.x + src.width * 0.7;
+ y2 = src.y;
+ cp1x = x1 - 20;
+ cp1y = y1 - 35;
+ cp2x = x2 + 20;
+ cp2y = y2 - 35;
+ } else if (e.isBackEdge || src.layer > dst.layer) {
+ x1 = src.x + src.width / 2.0;
+ y1 = src.y;
+ x2 = dst.x + dst.width / 2.0;
+ y2 = dst.y;
+ double minTop = Math.min(y1, y2);
+ int minL = Math.min(src.layer, dst.layer);
+ int maxL = Math.max(src.layer, dst.layer);
+ for (Node n : graph.nodes.values()) {
+ if (n.layer >= minL && n.layer <= maxL) {
+ minTop = Math.min(minTop, n.y);
+ }
+ }
+ for (Edge oe : graph.edges) {
+ for (Node v : oe.virtualNodes) {
+ if (v.layer >= minL && v.layer <= maxL) {
+ minTop = Math.min(minTop, v.y);
+ }
+ }
+ }
+ double labelH = 18.0;
+ double loopOffset =
+ Math.max(45.0, labelH + 36.0) + (Math.max(y1, y2) - minTop) * 0.4;
+ cp1x = x1;
+ cp1y = minTop - loopOffset;
+ cp2x = x2;
+ cp2y = minTop - loopOffset;
+ } else if (!e.virtualNodes.isEmpty()) {
+ List<Double> px = new ArrayList<>();
+ List<Double> py = new ArrayList<>();
+ px.add(src.x + src.width);
+ py.add(src.y + src.height / 2.0);
+ for (Node v : e.virtualNodes) {
+ px.add(v.x + v.width / 2.0);
+ py.add(v.y + v.height / 2.0);
+ }
+ px.add(dst.x);
+ py.add(dst.y + dst.height / 2.0);
+
+ StringBuilder pathD = new StringBuilder();
+ pathD.append(String.format("M %.1f %.1f", px.get(0), py.get(0)));
+ for (int i = 0; i < px.size() - 1; i++) {
+ double xA = px.get(i), yA = py.get(i);
+ double xB = px.get(i + 1), yB = py.get(i + 1);
+ double dx = xB - xA;
+ pathD.append(
+ String.format(
+ " C %.1f %.1f, %.1f %.1f, %.1f %.1f",
+ xA + dx * 0.5, yA, xB - dx * 0.5, yB, xB, yB));
+ }
+ svg.append(
+ String.format(
+ " <path d=\"%s\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ pathD.toString(), strokeWidth, strokeDash, marker));
+
+ if (e.label != null && !e.label.trim().isEmpty()) {
+ Node firstV = e.virtualNodes.get(0);
+ renderEdgeLabelBadge(svg, firstV.x + firstV.width / 2.0, firstV.y + firstV.height / 2.0, e.label.trim());
+ }
+ return;
+ } else {
+ x1 = src.x + src.width;
+ y1 = src.y + src.height / 2.0;
+ x2 = dst.x;
+ y2 = dst.y + dst.height / 2.0;
+ double dx = x2 - x1;
+ cp1x = x1 + dx * 0.5;
+ cp1y = y1;
+ cp2x = x1 + dx * 0.5;
+ cp2y = y2;
+ }
+ }
+
+ svg.append(
+ String.format(
+ " <path d=\"M %.1f %.1f C %.1f %.1f, %.1f %.1f, %.1f %.1f\" fill=\"none\" stroke=\"#64748b\" stroke-width=\"%s\" %s%s/>\n",
+ x1, y1, cp1x, cp1y, cp2x, cp2y, x2, y2, strokeWidth, strokeDash, marker));
+
+ if (e.label != null && !e.label.trim().isEmpty()) {
+ // Evaluate Cubic Bézier midpoint at t = 0.5
+ double midX = 0.125 * x1 + 0.375 * cp1x + 0.375 * cp2x + 0.125 * x2;
+ double midY = 0.125 * y1 + 0.375 * cp1y + 0.375 * cp2y + 0.125 * y2;
+ renderEdgeLabelBadge(svg, midX, midY, e.label.trim());
+ }
+ }
+
+ private static void renderEdgeLabelBadge(StringBuilder svg, double midX, double midY, String label) {
+ double textLen = label.length() * 6.5;
+ double rectW = textLen + 12;
+ double rectH = 18;
+ svg.append(
+ String.format(
+ " <rect x=\"%.1f\" y=\"%.1f\" width=\"%.1f\" height=\"%.1f\" rx=\"3\" fill=\"#ffffff\" fill-opacity=\"0.95\" />\n",
+ midX - rectW / 2.0, midY - rectH / 2.0, rectW, rectH));
+ svg.append(
+ String.format(
+ " <text x=\"%.1f\" y=\"%.1f\" font-size=\"10.5\" fill=\"#475569\" text-anchor=\"middle\" dominant-baseline=\"central\">%s</text>\n",
+ midX, midY, escapeXml(label)));
+ }
+
+ private static String escapeXml(String text) {
+ if (text == null) return "";
+ StringBuilder sb = new StringBuilder(text.length() + 16);
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ // Strip invalid XML 1.0 control characters (valid chars: 0x9, 0xA, 0xD, 0x20+)
+ if (c < 0x20 && c != '\t' && c != '\n' && c != '\r') {
+ continue;
+ }
+ switch (c) {
+ case '&':
+ sb.append("&");
+ break;
+ case '<':
+ sb.append("<");
+ break;
+ case '>':
+ sb.append(">");
+ break;
+ case '"':
+ sb.append(""");
+ break;
+ case '\'':
+ sb.append("'");
+ break;
+ default:
+ sb.append(c);
+ break;
+ }
+ }
+ return sb.toString();
+ }
+
+ private static class GraphComponent {
+ final Map<String, Node> nodes = new LinkedHashMap<>();
+ final List<Edge> edges = new ArrayList<>();
+ final List<Subgraph> subgraphs = new ArrayList<>();
+ double width = 0;
+ double height = 0;
+ }
+
+ private static String findRoot(Map<String, String> parent, String id) {
+ String p = parent.get(id);
+ if (p == null || p.equals(id)) {
+ return id;
+ }
+ String root = findRoot(parent, p);
+ parent.put(id, root);
+ return root;
+ }
+
+ private static void unionSets(Map<String, String> parent, String id1, String id2) {
+ String r1 = findRoot(parent, id1);
+ String r2 = findRoot(parent, id2);
+ if (!r1.equals(r2)) {
+ parent.put(r1, r2);
+ }
+ }
+
+ private static void layoutIsolatedSubgraphs(Direction dir, List<Subgraph> subgraphs) {
+ boolean isHorizontal = (dir == Direction.LR || dir == Direction.RL);
+ double padding = 20;
+ double headerH = 22;
+
+ for (Subgraph sg : subgraphs) {
+ if (sg.nodes.isEmpty()) continue;
+ if (!isHorizontal) {
+ double maxW = 0;
+ for (Node n : sg.nodes) {
+ maxW = Math.max(maxW, n.width);
+ }
+ double curY = headerH + padding;
+ for (Node n : sg.nodes) {
+ n.x = padding + (maxW - n.width) / 2.0;
+ n.y = curY;
+ curY += n.height + 24;
+ }
+ sg.x = 0;
+ sg.y = 0;
+ sg.width = maxW + padding * 2;
+ sg.height = curY + padding;
+ } else {
+ double maxH = 0;
+ for (Node n : sg.nodes) {
+ maxH = Math.max(maxH, n.height);
+ }
+ double curX = padding;
+ for (Node n : sg.nodes) {
+ n.x = curX;
+ n.y = headerH + padding + (maxH - n.height) / 2.0;
+ curX += n.width + 32;
+ }
+ sg.x = 0;
+ sg.y = 0;
+ sg.width = curX + padding;
+ sg.height = maxH + padding * 2 + headerH;
+ }
+ }
+ }
+}
diff --git a/javatests/com/google/gitiles/BUILD b/javatests/com/google/gitiles/BUILD
index b2410ff..03a9841 100644
--- a/javatests/com/google/gitiles/BUILD
+++ b/javatests/com/google/gitiles/BUILD
@@ -40,6 +40,7 @@
runtime_deps = ["//lib/junit:hamcrest"],
deps = DEPS + [
"//java/com/google/gitiles:servlet",
+ "//java/com/google/gitiles/blame/cache",
":testutil",
"//lib:servlet-api",
"//lib:commonmark",
diff --git a/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
new file mode 100644
index 0000000..1ead02a
--- /dev/null
+++ b/javatests/com/google/gitiles/blame/cache/BlameCacheTest.java
@@ -0,0 +1,67 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.blame.cache;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableSet;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/** Unit tests for {@link BlameCacheImpl.Key}. */
+@RunWith(JUnit4.class)
+public class BlameCacheTest {
+ @Test
+ public void keyEqualsAndHashCode() {
+ ObjectId c1 = ObjectId.fromString("1111111111111111111111111111111111111111");
+ ObjectId c2 = ObjectId.fromString("2222222222222222222222222222222222222222");
+ ObjectId c3 = ObjectId.fromString("3333333333333333333333333333333333333333");
+
+ BlameCacheImpl.Key k1 = new BlameCacheImpl.Key(c1, "foo.txt");
+ BlameCacheImpl.Key k2 = new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of());
+ BlameCacheImpl.Key k3 = new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c2, c3));
+ BlameCacheImpl.Key k4 = new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c3, c2));
+ BlameCacheImpl.Key k5 = new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c2));
+
+ assertThat(k1).isEqualTo(k2);
+ assertThat(k1.hashCode()).isEqualTo(k2.hashCode());
+
+ assertThat(k3).isEqualTo(k4);
+ assertThat(k3.hashCode()).isEqualTo(k4.hashCode());
+
+ assertThat(k1).isNotEqualTo(k3);
+ assertThat(k3).isNotEqualTo(k5);
+ }
+
+ @Test
+ public void keyToString() {
+ ObjectId c1 = ObjectId.fromString("1111111111111111111111111111111111111111");
+ ObjectId c2 = ObjectId.fromString("2222222222222222222222222222222222222222");
+ ObjectId c3 = ObjectId.fromString("3333333333333333333333333333333333333333");
+
+ BlameCacheImpl.Key kNoIgnore = new BlameCacheImpl.Key(c1, "foo.txt");
+ assertThat(kNoIgnore.toString()).isEqualTo("1111111111111111111111111111111111111111:foo.txt");
+
+ BlameCacheImpl.Key kWithIgnore =
+ new BlameCacheImpl.Key(c1, "foo.txt", ImmutableSet.of(c3, c2));
+ assertThat(kWithIgnore.toString())
+ .isEqualTo(
+ "1111111111111111111111111111111111111111:foo.txt"
+ + " ignore=[2222222222222222222222222222222222222222,"
+ + " 3333333333333333333333333333333333333333]");
+ }
+}
diff --git a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
index 74e4c3e..2e51b5f 100644
--- a/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
+++ b/javatests/com/google/gitiles/doc/GitilesMarkdownTest.java
@@ -74,6 +74,231 @@
assertThat(html).contains("title: Kittens");
}
+ @Test
+ public void renderMermaidDiagram() {
+ String md =
+ "```mermaid\n"
+ + "graph LR\n"
+ + " subgraph CoreGarden\n"
+ + " A[\"Fluffy Puppy\"]\n"
+ + " end\n"
+ + " subgraph GreenLawn\n"
+ + " B[\"Playful Kitten\"]\n"
+ + " end\n"
+ + " B --> A\n"
+ + "```\n";
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+ assertThat(htmlStr).contains("class=\"mermaid-container\"");
+ assertThat(htmlStr).contains("<svg class=\"mermaid-svg\"");
+ assertThat(htmlStr).contains("Fluffy Puppy");
+ assertThat(htmlStr).contains("Playful Kitten");
+ }
+
+ @Test
+ public void renderMermaidWithSubgraphAndDAG() {
+ String md =
+ "```mermaid\n"
+ + "graph TD\n"
+ + " ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten)\n"
+ + " ClientApp --> Utils(Happy Bunny)\n"
+ + " Utils --> ServiceDiscovery[Red Apple Berry]\n"
+ + " Utils --> ModelManager[Yellow Banana Snack]\n"
+ + " Extras --> Recognition(Fluffy Duckling)\n"
+ + " Recognition --> SODA(Green Frog Jump)\n"
+ + " Recognition --> S3(Sunny Daisy Flower)\n"
+ + " subgraph Play Park Garden\n"
+ + " Executors(Teddy Bear)\n"
+ + " Errors(Wooden Blocks)\n"
+ + " Protos(Toy Wagon)\n"
+ + " end\n"
+ + " Recognition -.-> PlayParkGarden\n"
+ + "```\n";
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+ assertThat(htmlStr).contains("Play Park Garden");
+ assertThat(htmlStr).contains("Little Puppy Plays");
+ assertThat(htmlStr).contains("Sweet Kitten");
+ assertThat(htmlStr).contains("Happy Bunny");
+ assertThat(htmlStr).contains("Teddy Bear");
+ assertThat(htmlStr).contains("Wooden Blocks");
+ assertThat(htmlStr).contains("Toy Wagon");
+ }
+
+ @Test
+ public void renderMermaidWithQuotedBracketsAndDiamond() {
+ String md =
+ "```mermaid\n"
+ + "graph TD\n"
+ + " A[\"Happy little bunny jumps\"]\n"
+ + " B[\"Locate [TeddyBear] in garden\"]\n"
+ + " A --> B\n"
+ + " B --> C[\"Sing Sweet Melody\"]\n"
+ + " C --> D[\"Dance Around Blossom Tree\"]\n"
+ + " E{\"Is kitten happy & pure?\"}\n"
+ + " E -- Yes --> F[\"Give Tasty Cookie Treat\"]\n"
+ + " F --> G[\"Play With Soft Yarn Ball\"]\n"
+ + " E -- No --> H[\"Read Gentle Story Book\"]\n"
+ + " H --> I[\"Warm Cozy Blanket Nap\"]\n"
+ + " G --> J[\"Wake Up In Morning Sun\"]\n"
+ + " I --> J\n"
+ + " J --> K[\"Smile At Rainbow Sky\"]\n"
+ + "```\n";
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+ assertThat(htmlStr).contains("Is kitten happy &");
+ assertThat(htmlStr).contains("pure?");
+ }
+
+ @Test
+ public void renderMermaidWithBidirectionalEdges() {
+ String md =
+ "```mermaid\n"
+ + "graph TD\n"
+ + " Puppy[\"Fluffy Puppy\"]\n"
+ + " Kitten[\"Playful Kitten\"]\n"
+ + " Bunny[\"Little Bunny\"]\n"
+ + " Puppy -->|Roll Ball| Kitten\n"
+ + " Kitten -->|Chase Toy| Bunny\n"
+ + " Bunny -->|Share Snack| Kitten\n"
+ + " Kitten -->|Give Hug| Puppy\n"
+ + "```\n";
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+ assertThat(htmlStr).contains("Fluffy Puppy");
+ assertThat(htmlStr).contains("Playful Kitten");
+ assertThat(htmlStr).contains("Roll Ball");
+ assertThat(htmlStr).contains("Give Hug");
+ }
+
+ @Test
+ public void testMarkdownConfigIFrameAndExtensions() {
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "safehtml", true);
+ cfg.setString("markdown", null, "allowiframe", "https://example.com/embed/");
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ assertThat(mc.isIFrameAllowed("https://example.com/embed/123")).isTrue();
+ assertThat(mc.isIFrameAllowed("https://other.com/embed/123")).isFalse();
+
+ Config anyCfg = new Config();
+ anyCfg.setBoolean("markdown", null, "safehtml", true);
+ anyCfg.setBoolean("markdown", null, "allowiframe", true);
+ MarkdownConfig anyMc = new MarkdownConfig(anyCfg);
+ assertThat(anyMc.isIFrameAllowed("https://anything.com")).isTrue();
+
+ MarkdownConfig copied = mc.copyWithExtensions(
+ java.util.Collections.singleton("toc"),
+ java.util.Collections.singleton("autolink"));
+ assertThat(copied).isNotNull();
+ }
+
+ @Test
+ public void testMarkdownConfigMermaidDisabledFallsBackToCodeBlock() {
+ String md = "```mermaid\ngraph TD\n A --> B\n```\n";
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", false);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+ // When disabled, it renders as a code pre block, not a mermaid svg container
+ assertThat(htmlStr).doesNotContain("class=\"mermaid-container\"");
+ assertThat(htmlStr).contains("<pre class=\"code\">");
+ assertThat(htmlStr).contains("graph TD");
+ }
+
+ @Test
+ public void testSecurityMermaidMarkdownEndToEndNoScriptOrIframeInjection() {
+ String md =
+ "# Diagram Title\n\n"
+ + "```mermaid\n"
+ + "graph TD\n"
+ + " A[\"<script>alert('xss-1')</script>\"]\n"
+ + " B[\"<iframe src='javascript:alert(2)'></iframe>\"]\n"
+ + " C[\"<img src=x onerror=alert('xss-3')>\"]\n"
+ + " D[\"<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>\"]\n"
+ + " A -->|\"<script>alert('edge')</script>\"| B\n"
+ + " B --> C --> D\n"
+ + " click A href \"javascript:alert('click')\"\n"
+ + "```\n";
+
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ Node node = GitilesMarkdown.parse(mc, md);
+ SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("index.md")
+ .build()
+ .toSoyHtml(node);
+
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+
+ // Verify rendered HTML has the mermaid-container and SVG
+ assertThat(htmlStr).contains("<div class=\"mermaid-container\"><svg class=\"mermaid-svg\"");
+ String mermaidHtml = htmlStr.substring(htmlStr.indexOf("<div class=\"mermaid-container\">"));
+
+ // Verify absolutely no executable script, iframe, or event handlers exist in Mermaid HTML
+ assertThat(mermaidHtml).doesNotContain("<script");
+ assertThat(mermaidHtml).doesNotContain("<iframe");
+ assertThat(mermaidHtml).doesNotContain("<foreignObject");
+ assertThat(mermaidHtml).doesNotContain("<img");
+ assertThat(mermaidHtml).doesNotContain("<a ");
+
+ // Verify payload is safely escaped as XML text inside SVG tspans
+ assertThat(htmlStr).contains("<script>alert('xss-1')</script>");
+ assertThat(htmlStr).contains("<iframe src='javascript:alert(2)'></iframe>");
+ assertThat(htmlStr).contains("<img src=x onerror=alert('xss-3')>");
+ }
+
private static String render(String markdown, boolean frontMatter) {
Config cfg = new Config();
cfg.setBoolean("markdown", null, "frontmatter", frontMatter);
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java
new file mode 100644
index 0000000..ecaa1e3
--- /dev/null
+++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererSecurityTest.java
@@ -0,0 +1,431 @@
+// Copyright 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+/**
+ * Deep security test suite for {@link SimpleMermaidRenderer} covering SVG injection, XSS vectors,
+ * XML entity attacks, protocol exploits, tag breakouts, and parser robustness.
+ */
+@RunWith(JUnit4.class)
+public class SimpleMermaidRendererSecurityTest {
+
+ // =========================================================================
+ // 1. Script Element Injections
+ // =========================================================================
+
+ @Test
+ public void testScriptTagVariationsInNodeLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<script>alert(1)</script>",
+ "<script src='https://evil.com/xss.js'></script>",
+ "<script src='//evil.com/xss.js'/>",
+ "<sCrIpT>alert('case')</ScRiPt>",
+ "<SCRIPT/SRC=\"data:text/javascript,alert(1)\">",
+ "<script xmlns=\"http://www.w3.org/1999/xhtml\">alert(1)</script>",
+ "<script><![CDATA[alert(1)]]></script>",
+ "<script>/*<![CDATA[*/alert(1)/*]]>*/</script>",
+ "<script defer>alert(1)</script>",
+ "<script async>alert(1)</script>",
+ "<script type=\"module\">import 'https://evil.com/x.js';</script>",
+ "<script type=\"text/javascript\">alert(String.fromCharCode(88,83,83))</script>");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 2. ForeignObject and Embedded HTML Injections
+ // =========================================================================
+
+ @Test
+ public void testForeignObjectAndEmbeddedHtmlInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<foreignObject><body xmlns=\"http://www.w3.org/1999/xhtml\"><script>alert(1)</script></body></foreignObject>",
+ "<foreignObject><iframe src=\"javascript:alert(1)\"></iframe></foreignObject>",
+ "<foreignObject><iframe src=\"https://evil.com\"></iframe></foreignObject>",
+ "<foreignObject><form action=\"//evil.com\"><input type=\"password\" name=\"pass\"></form></foreignObject>",
+ "<foreignObject><embed src=\"evil.swf\"></embed></foreignObject>",
+ "<foreignObject><object data=\"javascript:alert(1)\"></object></foreignObject>",
+ "<foreignObject><audio src=\"x\" onerror=\"alert(1)\"></audio></foreignObject>",
+ "<foreignObject><video src=\"x\" onerror=\"alert(1)\"></video></foreignObject>",
+ "<foreignObject width=\"100\" height=\"100\"><div xmlns=\"http://www.w3.org/1999/xhtml\"><span>HTML Content</span></div></foreignObject>");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 3. Event Handler Attributes Injections
+ // =========================================================================
+
+ @Test
+ public void testEventHandlerAttributesInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<svg onload=\"alert('svg-onload')\">",
+ "<svg onresize=\"alert(1)\">",
+ "<img src=\"invalid-image.jpg\" onerror=\"alert('img-onerror')\">",
+ "<body onload=\"alert('body-onload')\">",
+ "<rect onmouseover=\"alert('rect-hover')\">",
+ "<circle onclick=\"alert('circle-click')\">",
+ "<polygon onfocus=\"alert('poly-focus')\">",
+ "<text onpointerdown=\"alert('pointer')\">",
+ "<path onanimationstart=\"alert('anim')\">",
+ "<g ontouchstart=\"alert('touch')\">",
+ "x\" onfocus=\"alert(1)\" autofocus=\"",
+ "x' onclick='alert(1)' x='",
+ "x` onmouseover=`alert(1)` `");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 4. Anchor and Protocol Injections
+ // =========================================================================
+
+ @Test
+ public void testAnchorAndProtocolInjectionsInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<a href=\"javascript:alert('a-href')\">Click Link</a>",
+ "<a xlink:href=\"javascript:alert('xlink-href')\">Click Link</a>",
+ "<a href=\"data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\">Data Link</a>",
+ "<a href=\"vbscript:msgbox(1)\">VBScript Link</a>",
+ "<a href=\"java\0script:alert(1)\">Null Byte Protocol</a>",
+ "<a href=\"java
script:alert(1)\">Newline Protocol</a>",
+ "<a href=\"java	script:alert(1)\">Tab Protocol</a>",
+ "<a href=\"javascript:alert(1)\">Entity Colon Protocol</a>",
+ "<a href=\"blob:https://evil.com/uuid\">Blob Link</a>");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 5. SVG Animation Exploits (<animate>, <set>)
+ // =========================================================================
+
+ @Test
+ public void testSvgAnimationExploitsInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<animate onbegin=\"alert('anim-begin')\" attributeName=\"x\" dur=\"1s\" />",
+ "<animate onend=\"alert('anim-end')\" attributeName=\"x\" dur=\"1s\" />",
+ "<animate onrepeat=\"alert('anim-repeat')\" attributeName=\"x\" dur=\"1s\" />",
+ "<animate attributeName=\"href\" values=\"javascript:alert(1)\" dur=\"1s\" />",
+ "<animate attributeName=\"xlink:href\" values=\"javascript:alert(1)\" dur=\"1s\" />",
+ "<set onbegin=\"alert('set-begin')\" attributeName=\"x\" dur=\"1s\" />",
+ "<set attributeName=\"onmouseover\" to=\"alert(1)\" />",
+ "<animateTransform attributeName=\"transform\" type=\"rotate\" from=\"0\" to=\"360\" onend=\"alert(1)\" />");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 6. SVG Resource and Reference Exploits (<use>, <image>, <feImage>)
+ // =========================================================================
+
+ @Test
+ public void testSvgResourceAndReferenceExploitsInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<use href=\"javascript:alert(1)\" />",
+ "<use xlink:href=\"javascript:alert(1)\" />",
+ "<use href=\"data:image/svg+xml;utf8,<svg id='x' xmlns='http://www.w3.org/2000/svg'><script>alert(1)</script></svg>#x\" />",
+ "<use xlink:href=\"https://evil.com/payload.svg#icon\" />",
+ "<image href=\"javascript:alert(1)\" />",
+ "<image xlink:href=\"javascript:alert(1)\" />",
+ "<image href=\"data:image/svg+xml;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==\" />",
+ "<feImage href=\"javascript:alert(1)\" />",
+ "<feImage xlink:href=\"https://evil.com/tracker.png\" />",
+ "<pattern href=\"javascript:alert(1)\" />",
+ "<pattern xlink:href=\"https://evil.com/xss.svg\" />");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 7. CSS and Style Injections
+ // =========================================================================
+
+ @Test
+ public void testCssAndStyleInjectionsInLabels() {
+ List<String> payloads =
+ Arrays.asList(
+ "<style>@import 'javascript:alert(1)';</style>",
+ "<style>body { background: url(\"javascript:alert(1)\"); }</style>",
+ "<style>* { -moz-binding: url('http://evil.com/xss.xml#test'); }</style>",
+ "<style>svg { behavior: url(xss.htc); }</style>",
+ "<style>@keyframes xss { from { background-image: url('javascript:alert(1)'); } } </style>",
+ "<div style=\"fill:expression(alert(1))\">Styled Div</div>",
+ "<div style=\"background-image:url(javascript:alert(1))\">Background</div>",
+ "<div style=\"behavior:url(xss.htc)\">HTC Component</div>");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 8. XML Structure and CDATA Breakout Payloads
+ // =========================================================================
+
+ @Test
+ public void testXmlStructureAndCdataBreakoutPayloads() {
+ List<String> payloads =
+ Arrays.asList(
+ "</text></svg><script>alert('tag-breakout')</script><svg><text>",
+ "</tspan></text><iframe src='javascript:alert(1)'></iframe><text><tspan>",
+ "]]><script>alert('cdata-breakout')</script><![CDATA[",
+ "--> <script>alert('comment-breakout')</script> <!--",
+ "<?xml-stylesheet href=\"javascript:alert(1)\"?>",
+ "<!DOCTYPE svg [ <!ENTITY xxe SYSTEM \"file:///etc/passwd\"> ]>&xxe;",
+ "<script>alert(1)</script>",
+ "\"><script>alert('quote-breakout')</script>",
+ "'\"><script>alert('double-quote-breakout')</script>",
+ "`><script>alert('backtick-breakout')</script>");
+
+ for (String payload : payloads) {
+ String code = "graph TD\n A[\"" + payload.replace("\"", "\\\"") + "\"] --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 9. Edge Labels and Infix Strokes Injection
+ // =========================================================================
+
+ @Test
+ public void testEdgeLabelsAndInfixStrokesSecurity() {
+ List<String> payloads =
+ Arrays.asList(
+ "<script>alert('pipe-edge')</script>",
+ "<iframe src='javascript:alert(1)'></iframe>",
+ "<img src=x onerror=alert('edge-onerror')>",
+ "<svg onload=alert('edge-onload')>",
+ "<foreignObject><script>alert(1)</script></foreignObject>",
+ "</text></svg><script>alert('edge-breakout')</script><svg><text>");
+
+ for (String payload : payloads) {
+ String codePipe = "graph TD\n A -->|\"" + payload.replace("\"", "\\\"") + "\"| B\n";
+ assertSafeSvg(codePipe, payload);
+
+ String codeInfixSolid = "graph TD\n A -- \"" + payload.replace("\"", "\\\"") + "\" --> B\n";
+ assertSafeSvg(codeInfixSolid, payload);
+
+ String codeInfixThick = "graph TD\n A == \"" + payload.replace("\"", "\\\"") + "\" ==> B\n";
+ assertSafeSvg(codeInfixThick, payload);
+
+ String codeInfixDashed = "graph TD\n A -. \"" + payload.replace("\"", "\\\"") + "\" .-> B\n";
+ assertSafeSvg(codeInfixDashed, payload);
+ }
+ }
+
+ // =========================================================================
+ // 10. Subgraph Titles and Aliases Injection
+ // =========================================================================
+
+ @Test
+ public void testSubgraphTitlesAndAliasesSecurity() {
+ List<String> payloads =
+ Arrays.asList(
+ "<script>alert('subgraph')</script>",
+ "<iframe src='javascript:alert(1)'></iframe>",
+ "<img src=x onerror=alert('sg-img')>",
+ "<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>",
+ "</text></svg><script>alert('sg-breakout')</script><svg><text>");
+
+ for (String payload : payloads) {
+ String code =
+ "graph TD\n"
+ + " subgraph Sg [\"" + payload.replace("\"", "\\\"") + "\"]\n"
+ + " A[Node A]\n"
+ + " end\n"
+ + " A --> B\n";
+ assertSafeSvg(code, payload);
+ }
+ }
+
+ // =========================================================================
+ // 11. Mermaid Directive Stripping Verification
+ // =========================================================================
+
+ @Test
+ public void testMermaidDirectivesCannotInjectCode() {
+ String code =
+ "graph TD\n"
+ + " A[Node A] --> B[Node B]\n"
+ + " click A href \"javascript:alert('click-href')\"\n"
+ + " click B call alert('click-call')\n"
+ + " click A \"javascript:alert('click-positional')\"\n"
+ + " style A fill:url(javascript:alert('style-fill'))\n"
+ + " style B stroke:url(data:image/svg+xml,<svg onload=alert(1)>)\n"
+ + " classDef evil fill:red,stroke:url(javascript:alert(1));\n"
+ + " class A evil\n"
+ + " linkStyle 0 stroke:url(javascript:alert(1));\n";
+
+ SvgDoc svg = SvgDoc.render(code);
+ svg.assertNoDangerousTags();
+
+ String raw = SimpleMermaidRenderer.renderToSvg(code).get();
+ assertThat(raw).doesNotContain("javascript:");
+ assertThat(raw).doesNotContain("href=");
+ assertThat(raw).doesNotContain("xlink:href");
+ assertThat(raw).doesNotContain("onclick=");
+ assertThat(raw).doesNotContain("onmouseover=");
+ }
+
+ // =========================================================================
+ // 12. Parser Stress, Recursion, and Malformed Characters
+ // =========================================================================
+
+ @Test
+ public void testNullBytesAndControlCharactersInInput() {
+ String codeWithNulls = "graph TD\n A[\"Null\0Byte\u0001Control\u0008Test\"] --> B\n";
+ SvgDoc svg = SvgDoc.render(codeWithNulls);
+ svg.assertNoDangerousTags();
+ }
+
+ @Test
+ public void testDeeplyNestedSubgraphsDosResistance() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("graph TD\n");
+ int depth = 25;
+ for (int i = 0; i < depth; i++) {
+ sb.append(" subgraph Level").append(i).append(" [\"Level ").append(i).append("\"]\n");
+ }
+ sb.append(" A[Deep Node]\n");
+ for (int i = 0; i < depth; i++) {
+ sb.append(" end\n");
+ }
+ sb.append(" A --> B[Outer Node]\n");
+
+ SvgDoc svg = SvgDoc.render(sb.toString());
+ svg.assertNoDangerousTags();
+ }
+
+ @Test
+ public void testDenseCyclesAndMutualEdgesDosResistance() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("graph TD\n");
+ for (int i = 0; i < 30; i++) {
+ sb.append(" N").append(i).append(" --> N").append((i + 1) % 30).append("\n");
+ sb.append(" N").append((i + 1) % 30).append(" --> N").append(i).append("\n");
+ }
+ SvgDoc svg = SvgDoc.render(sb.toString());
+ svg.assertNoDangerousTags();
+ }
+
+ // =========================================================================
+ // 13. End-to-End MarkdownToHtml & Gitiles Integration
+ // =========================================================================
+
+ @Test
+ public void testEndToEndMarkdownXssPrevention() {
+ String md =
+ "# Security Audit Title\n\n"
+ + "```mermaid\n"
+ + "graph TD\n"
+ + " A[\"<script>alert('e2e-node')</script>\"]\n"
+ + " B[\"<iframe src='javascript:alert(1)'></iframe>\"]\n"
+ + " C[\"<img src=x onerror=alert('e2e-img')>\"]\n"
+ + " D[\"<foreignObject><iframe src='http://evil.com'></iframe></foreignObject>\"]\n"
+ + " A -->|\"<script>alert('e2e-edge')</script>\"| B\n"
+ + " B --> C --> D\n"
+ + " click A href \"javascript:alert('e2e-click')\"\n"
+ + "```\n";
+
+ Config cfg = new Config();
+ cfg.setBoolean("markdown", null, "mermaid", true);
+ MarkdownConfig mc = new MarkdownConfig(cfg);
+ org.commonmark.node.Node node = GitilesMarkdown.parse(mc, md);
+ com.google.common.html.types.SafeHtml html =
+ MarkdownToHtml.builder()
+ .setConfig(mc)
+ .setFilePath("security_test.md")
+ .build()
+ .toSoyHtml(node);
+
+ assertThat(html).isNotNull();
+ String htmlStr = html.getSafeHtmlString();
+
+ // Verify container and SVG exist
+ assertThat(htmlStr).contains("<div class=\"mermaid-container\"><svg class=\"mermaid-svg\"");
+ String mermaidPart = htmlStr.substring(htmlStr.indexOf("<div class=\"mermaid-container\">"));
+
+ // Verify no executable HTML tags exist in the Mermaid output
+ for (String tag : SvgDoc.DANGEROUS_TAGS) {
+ assertThat(mermaidPart).doesNotContain("<" + tag);
+ assertThat(mermaidPart).doesNotContain("</" + tag + ">");
+ }
+
+ // Verify all payloads are safely encoded as XML entity text
+ assertThat(htmlStr).contains("<script>alert('e2e-node')</script>");
+ assertThat(htmlStr).contains("<iframe src='javascript:alert(1)'></iframe>");
+ assertThat(htmlStr).contains("<img src=x onerror=alert('e2e-img')>");
+ }
+
+ // =========================================================================
+ // Helper Methods for Strict Security Validation
+ // =========================================================================
+
+ private void assertSafeSvg(String mermaidCode, String originalPayload) {
+ Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode);
+ assertThat(svgOpt.isPresent()).isTrue();
+
+ String rawSvg = svgOpt.get();
+
+ // 1. Raw string inspection for any unescaped tags
+ for (String tag : SvgDoc.DANGEROUS_TAGS) {
+ assertThat(rawSvg).doesNotContain("<" + tag);
+ assertThat(rawSvg).doesNotContain("</" + tag + ">");
+ }
+
+ // 2. XML DOM inspection ensuring strictly valid XML document with 0 dangerous elements
+ SvgDoc svg = new SvgDoc(rawSvg);
+ svg.assertAllInvariants();
+
+ // 3. Confirm original payload text is preserved in text nodes
+ assertThat(svg.findText(originalPayload)).isNotNull();
+ }
+}
diff --git a/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
new file mode 100644
index 0000000..6ae35c1
--- /dev/null
+++ b/javatests/com/google/gitiles/doc/SimpleMermaidRendererTest.java
@@ -0,0 +1,1411 @@
+// Copyright 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.base.Splitter;
+import com.google.common.primitives.Doubles;
+import java.util.List;
+import java.util.Optional;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.w3c.dom.Element;
+
+/** Comprehensive XML DOM-based unit tests for {@link SimpleMermaidRenderer}. */
+@RunWith(JUnit4.class)
+public class SimpleMermaidRendererTest {
+
+ @Test
+ public void testConstructorInstantiation() {
+ SimpleMermaidRenderer renderer = new SimpleMermaidRenderer();
+ assertThat(renderer).isNotNull();
+ }
+
+ @Test
+ public void testBasicGraphTDExactSvgStructure() {
+ SvgDoc svg = render("graph TD\n A --> B\n");
+ svg.assertDefs();
+
+ // Verify exactly 2 node rects and 1 edge path
+ assertThat(svg.getElementsByTag("rect").size()).isEqualTo(2);
+ assertThat(svg.getElementsByTag("path").size()).isEqualTo(2); // 1 arrow in defs + 1 edge
+
+ // Verify exact node texts
+ List<String> texts = svg.getAllTextContents();
+ assertThat(texts).containsExactly("A", "B").inOrder();
+
+ // Verify edge has marker and cubic bezier curve
+ Element edgePath = svg.getEdgePaths().get(0);
+ assertThat(edgePath.getAttribute("marker-end")).isEqualTo("url(#mermaid-arrow)");
+ assertThat(edgePath.getAttribute("stroke")).isEqualTo("#64748b");
+ assertThat(edgePath.getAttribute("fill")).isEqualTo("none");
+ assertThat(edgePath.getAttribute("d")).startsWith("M ");
+ assertThat(edgePath.getAttribute("d")).contains(" C ");
+ }
+
+ @Test
+ public void testGraphDirectionsAndFallbacks() {
+ for (String dir : new String[] {"TD", "TB", "LR", "RL", "BT"}) {
+ SvgDoc svg = render("graph " + dir + "\n A --> B\n");
+ assertThat(svg.getAllTextContents()).containsExactly("A", "B").inOrder();
+ }
+ // Default direction when omitted
+ SvgDoc defSvg = render("graph\n A --> B\n");
+ assertThat(defSvg.getAllTextContents()).containsExactly("A", "B").inOrder();
+
+ // Invalid direction fallback to TD
+ SvgDoc fallbackSvg = render("flowchart INVALID\n A --> B\n");
+ assertThat(fallbackSvg.getAllTextContents()).containsExactly("A", "B").inOrder();
+ }
+
+ @Test
+ public void testAllNodeShapesExactSvgElements() {
+ String code =
+ "graph TD\n"
+ + " A[Rectangle Box]\n"
+ + " B(Rounded Ball)\n"
+ + " C([Stadium Ring])\n"
+ + " D[[Subroutine Cart]]\n"
+ + " E[(Cylinder Drum)]\n"
+ + " F((Circle Star))\n"
+ + " G{{Hexagon Block}}\n"
+ + " H{Diamond Kite}\n"
+ + " I>Asymmetric Flag]\n";
+ SvgDoc svg = render(code);
+
+ // Exact text elements
+ List<String> texts = svg.getAllTextContents();
+ assertThat(texts).containsExactly(
+ "Rectangle Box", "Rounded Ball", "Stadium Ring", "Subroutine Cart", "Cylinder Drum", "Circle Star", "Hexagon Block", "Diamond Kite", "Asymmetric Flag")
+ .inOrder();
+
+ // Verify Diamond has a polygon with 4 vertices
+ Element diamond = svg.findPolygonWithVertices(4);
+ assertThat(diamond).isNotNull();
+ assertThat(diamond.getAttribute("fill")).isEqualTo("#ffffff");
+ assertThat(diamond.getAttribute("stroke")).isEqualTo("#64748b");
+
+ // Verify Hexagon has a polygon with 6 vertices
+ Element hexagon = svg.findPolygonWithVertices(6);
+ assertThat(hexagon).isNotNull();
+
+ // Verify Asymmetric flag has a polygon with 5 vertices
+ Element flag = svg.findPolygonWithVertices(5);
+ assertThat(flag).isNotNull();
+
+ // Verify Subroutine has rect with 2 inner border lines
+ List<Element> lines = svg.getElementsByTag("line");
+ assertThat(lines.size()).isEqualTo(2);
+
+ // Verify Cylinder has 2 paths (body + top rim arc)
+ List<Element> paths = svg.getEdgePaths();
+ assertThat(paths.size()).isEqualTo(2);
+ }
+
+ @Test
+ public void testMultilineNodeLabelsWithTspans() {
+ String code =
+ "graph TD\n"
+ + " A[\"Sunny Blue Sky<br/>Warm Golden Sun<br/>Soft Green Grass\"]\n"
+ + " B[\"Little Red Apple\"]\n"
+ + " A --> B\n";
+ SvgDoc svg = render(code);
+
+ List<Element> tspans = svg.getElementsByTag("tspan");
+ assertThat(tspans.size()).isEqualTo(3);
+ assertThat(tspans.get(0).getTextContent()).isEqualTo("Sunny Blue Sky");
+ assertThat(tspans.get(0).getAttribute("font-weight")).isEqualTo("600");
+ assertThat(tspans.get(0).getAttribute("dy")).isEqualTo("0");
+
+ assertThat(tspans.get(1).getTextContent()).isEqualTo("Warm Golden Sun");
+ assertThat(tspans.get(1).getAttribute("font-weight")).isEqualTo("400");
+ assertThat(tspans.get(1).getAttribute("dy")).isEqualTo("16");
+
+ assertThat(tspans.get(2).getTextContent()).isEqualTo("Soft Green Grass");
+ assertThat(tspans.get(2).getAttribute("font-weight")).isEqualTo("400");
+ assertThat(tspans.get(2).getAttribute("dy")).isEqualTo("16");
+
+ Element singleLineText = svg.findText("Little Red Apple");
+ assertThat(singleLineText).isNotNull();
+ assertThat(singleLineText.getAttribute("dominant-baseline")).isEqualTo("central");
+ }
+
+ @Test
+ public void testQuotedLabelsWithBracketsAndEntities() {
+ String code =
+ "graph TD\n"
+ + " A[\"Play with teddy bear\"]\n"
+ + " B[\"Find [Puppy] in cozy room\"]\n"
+ + " C{\"Is kitten <tiny> & 'sweet'?\"}\n"
+ + " A --> B --> C\n";
+ SvgDoc svg = render(code);
+
+ // XML parsing confirms correct unescaping of <, >, ', &
+ assertThat(svg.getAllTextContents()).containsExactly(
+ "Play with teddy bear",
+ "Find [Puppy] in cozy room",
+ "Is kitten <tiny> & 'sweet'?")
+ .inOrder();
+ }
+
+ @Test
+ public void testQuotedCleanLabel() {
+ SvgDoc svg = render("graph TD\n A[\"\\\"Sleepy Kitten\\\"\"] --> B\n");
+ assertThat(svg.getAllTextContents()).containsExactly("Sleepy Kitten", "B").inOrder();
+ }
+
+ @Test
+ public void testAllEdgeTypesAndInfixLabelsExactAttributes() {
+ String code =
+ "graph LR\n"
+ + " A -->|Yellow Duck| B\n"
+ + " B ---|Blue Bird| C\n"
+ + " C -.->|Green Frog| D\n"
+ + " D ==>|Red Puppy| E\n"
+ + " E -- Orange Kitten --> F\n"
+ + " F -- Purple Bunny --- G\n"
+ + " G == White Lamb ==> H\n"
+ + " H == Pink Piggy === I\n"
+ + " I -. Brown Bear .-> J\n"
+ + " J -. Gray Mouse .- K\n"
+ + " K -.- L\n"
+ + " L === M\n"
+ + " M <--> N\n";
+ SvgDoc svg = render(code);
+
+ List<Element> edgePaths = svg.getEdgePaths();
+ assertThat(edgePaths.size()).isEqualTo(13);
+
+ // Verify dashed stroke
+ Element dashedEdge = edgePaths.get(2);
+ assertThat(dashedEdge.getAttribute("stroke-dasharray")).isEqualTo("4,4");
+ assertThat(dashedEdge.getAttribute("marker-end")).isEqualTo("url(#mermaid-arrow)");
+
+ // Verify thick stroke
+ Element thickEdge = edgePaths.get(3);
+ assertThat(thickEdge.getAttribute("stroke-width")).isEqualTo("2.5");
+ assertThat(thickEdge.getAttribute("marker-end")).isEqualTo("url(#mermaid-arrow)");
+
+ // Verify unarrowed solid line
+ Element unarrowedLine = edgePaths.get(1);
+ assertThat(unarrowedLine.getAttribute("marker-end")).isEmpty();
+
+ // Verify edge labels
+ List<String> texts = svg.getAllTextContents();
+ assertThat(texts).contains("Yellow Duck");
+ assertThat(texts).contains("Blue Bird");
+ assertThat(texts).contains("Green Frog");
+ assertThat(texts).contains("Red Puppy");
+ assertThat(texts).contains("Orange Kitten");
+ assertThat(texts).contains("Purple Bunny");
+ assertThat(texts).contains("White Lamb");
+ assertThat(texts).contains("Pink Piggy");
+ assertThat(texts).contains("Brown Bear");
+ assertThat(texts).contains("Gray Mouse");
+ }
+
+ @Test
+ public void testSubgraphsWithTitlesAliasesAndDirectionOverrides() {
+ String code =
+ "graph TD\n"
+ + " subgraph Playground Park\n"
+ + " direction LR\n"
+ + " E1(puppy)\n"
+ + " E2(kitten)\n"
+ + " end\n"
+ + " subgraph ToyHouse [\"Magic Toy House\"]\n"
+ + " direction INVALID_DIR\n"
+ + " S1[(Teddy)]\n"
+ + " end\n"
+ + " subgraph \"Music Tree Castle\"\n"
+ + " T1[Wooden Blocks]\n"
+ + " end\n"
+ + " subgraph MeadowHill [Sunny Meadow Hill]\n"
+ + " U1[Little Duck]\n"
+ + " end\n"
+ + " Baby --> E1\n"
+ + " E1 --> ToyHouse\n"
+ + " ToyHouse --> T1\n"
+ + " T1 --> MeadowHill\n";
+ SvgDoc svg = render(code);
+ svg.assertNoLabelNodeOverlaps();
+ svg.assertSubgraphsDoNotOverlap();
+
+ // Verify 4 subgraph boundary rects (stroke-dasharray="4,4")
+ List<Element> subgraphs = svg.findSubgraphRects();
+ assertThat(subgraphs.size()).isEqualTo(4);
+ for (Element sgRect : subgraphs) {
+ assertThat(sgRect.getAttribute("stroke-dasharray")).isEqualTo("4,4");
+ assertThat(sgRect.getAttribute("rx")).isEqualTo("8");
+ }
+
+ // Verify subgraph titles
+ assertThat(svg.findText("Playground Park")).isNotNull();
+ assertThat(svg.findText("Magic Toy House")).isNotNull();
+ assertThat(svg.findText("Music Tree Castle")).isNotNull();
+ assertThat(svg.findText("Sunny Meadow Hill")).isNotNull();
+ }
+
+ @Test
+ public void testSubgraphToSubgraphAndSubgraphToNodeEdges() {
+ String codeTD =
+ "graph TD\n"
+ + " subgraph SubA [\"Garden A\"]\n"
+ + " A1[Daisy Flower]\n"
+ + " end\n"
+ + " subgraph SubB [\"Garden B\"]\n"
+ + " B1[Tulip Flower]\n"
+ + " end\n"
+ + " SubA -->|Garden Link TD| SubB\n"
+ + " SubA -->|Flower Link| NodeC[Red Rose]\n"
+ + " NodeC -->|Petal Link| SubB\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Garden Link TD")).isNotNull();
+ assertThat(svgTD.findText("Flower Link")).isNotNull();
+ assertThat(svgTD.findText("Petal Link")).isNotNull();
+ assertThat(svgTD.getElementsByTag("path")).isNotEmpty();
+
+ String codeLR =
+ "graph LR\n"
+ + " subgraph SubA [\"Garden A\"]\n"
+ + " A1[Daisy Flower]\n"
+ + " end\n"
+ + " subgraph SubB [\"Garden B\"]\n"
+ + " B1[Tulip Flower]\n"
+ + " end\n"
+ + " SubA -->|Garden Link LR| SubB\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Garden Link LR")).isNotNull();
+
+ String codeLRWithBlockedNode =
+ "graph LR\n"
+ + " subgraph SubA [\"Garden A\"]\n"
+ + " A1[Daisy Flower]\n"
+ + " end\n"
+ + " subgraph SubB [\"Garden B\"]\n"
+ + " B1[Tulip Flower]\n"
+ + " end\n"
+ + " SubA -->|Garden Link LR Blocked| SubB\n"
+ + " SubA -->|Flower Link LR| NodeC[Red Rose]\n"
+ + " NodeC -->|Petal Link LR| SubB\n";
+ SvgDoc svgLRBlocked = render(codeLRWithBlockedNode);
+ assertThat(svgLRBlocked.findText("Garden Link LR Blocked")).isNotNull();
+ assertThat(svgLRBlocked.findText("Flower Link LR")).isNotNull();
+ assertThat(svgLRBlocked.findText("Petal Link LR")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphsExactHierarchy() {
+ String code =
+ "graph TD\n"
+ + " subgraph Sandbox [\"Play Sandbox\"]\n"
+ + " subgraph SandCastle [\"Sand Castle\"]\n"
+ + " SPA[\"Red Bucket\"]\n"
+ + " end\n"
+ + " subgraph ToyPond [\"Toy Pond\"]\n"
+ + " CS[\"Yellow Boat\"]\n"
+ + " end\n"
+ + " end\n"
+ + " SPA -->|Water Splash| CS\n";
+ SvgDoc svg = render(code);
+
+ // Exactly 3 subgraph boxes (1 outer + 2 inner)
+ List<Element> subgraphs = svg.findSubgraphRects();
+ assertThat(subgraphs.size()).isEqualTo(3);
+
+ assertThat(svg.findText("Play Sandbox")).isNotNull();
+ assertThat(svg.findText("Sand Castle")).isNotNull();
+ assertThat(svg.findText("Toy Pond")).isNotNull();
+ assertThat(svg.findText("Red Bucket")).isNotNull();
+ assertThat(svg.findText("Yellow Boat")).isNotNull();
+ assertThat(svg.findText("Water Splash")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphsHorizontal() {
+ String code =
+ "graph LR\n"
+ + " subgraph Outer [\"Playhouse\"]\n"
+ + " subgraph InnerA [\"Kitten Corner\"]\n"
+ + " A1[Soft Pillow]\n"
+ + " end\n"
+ + " subgraph InnerB [\"Puppy Corner\"]\n"
+ + " B1[Squeaky Ball]\n"
+ + " end\n"
+ + " end\n"
+ + " A1 -->|Play Time| B1\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.findText("Play Time")).isNotNull();
+ }
+
+ @Test
+ public void testSubgraphWithMixedChildrenAndDirectNodes() {
+ String codeTD =
+ "graph TD\n"
+ + " subgraph OuterTD [\"Tree House TD\"]\n"
+ + " subgraph InnerTD [\"Bird Nest TD\"]\n"
+ + " A1[Baby Bird TD]\n"
+ + " end\n"
+ + " D1[Little Squirrel TD]\n"
+ + " end\n"
+ + " A1 --> D1\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Tree House TD")).isNotNull();
+ assertThat(svgTD.findText("Bird Nest TD")).isNotNull();
+ assertThat(svgTD.findText("Little Squirrel TD")).isNotNull();
+
+ String codeLR =
+ "graph LR\n"
+ + " subgraph OuterLR [\"Tree House LR\"]\n"
+ + " subgraph InnerLR [\"Bird Nest LR\"]\n"
+ + " A1[Baby Bird LR]\n"
+ + " end\n"
+ + " D1[Little Squirrel LR]\n"
+ + " end\n"
+ + " A1 --> D1\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Tree House LR")).isNotNull();
+ assertThat(svgLR.findText("Bird Nest LR")).isNotNull();
+ assertThat(svgLR.findText("Little Squirrel LR")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphWithLabeledAdjacentEdge() {
+ String codeTD =
+ "graph TD\n"
+ + " subgraph SubTD [\"Animal Farm TD\"]\n"
+ + " A[Happy Lamb]\n"
+ + " B[Little Pony]\n"
+ + " A -->|Green Grass TD| B\n"
+ + " end\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Green Grass TD")).isNotNull();
+
+ String codeLR =
+ "graph LR\n"
+ + " subgraph SubLR [\"Animal Farm LR\"]\n"
+ + " A[Happy Lamb]\n"
+ + " B[Little Pony]\n"
+ + " A -->|Green Grass LR| B\n"
+ + " end\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Green Grass LR")).isNotNull();
+ }
+
+ @Test
+ public void testSugiyamaLateralAdjacentLabeledEdge() {
+ String code =
+ "graph TD\n"
+ + " A1[Fuzzy Panda] --> B1[Baby Giraffe]\n"
+ + " A2[Little Koala] --> B2[Tiny Hamster]\n"
+ + " A1 -->|Sunny Day| A2\n"
+ + " B1 -->|Happy Play| B2\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.findText("Sunny Day")).isNotNull();
+ assertThat(svg.findText("Happy Play")).isNotNull();
+ }
+
+ @Test
+ public void testBidirectionalMutualEdgesBothOrientations() {
+ String codeTD =
+ "graph TD\n"
+ + " A[\"Little Lamb\"]\n"
+ + " B[\"Sweet Bunny\"]\n"
+ + " A -->|Hop Down| B\n"
+ + " B -->|Jump Up| A\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Hop Down")).isNotNull();
+ assertThat(svgTD.findText("Jump Up")).isNotNull();
+ // Exactly 2 mutual curved edge paths
+ assertThat(svgTD.getEdgePaths().size()).isEqualTo(2);
+
+ String codeLR =
+ "graph LR\n"
+ + " A[\"Little Lamb\"]\n"
+ + " B[\"Sweet Bunny\"]\n"
+ + " A -->|Run Forward| B\n"
+ + " B -->|Run Backward| A\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Run Forward")).isNotNull();
+ assertThat(svgLR.findText("Run Backward")).isNotNull();
+ assertThat(svgLR.getEdgePaths().size()).isEqualTo(2);
+ }
+
+ @Test
+ public void testCycleDetectionAndLoopbackBothOrientations() {
+ String codeTD =
+ "graph TD\n"
+ + " A --> B\n"
+ + " B --> C\n"
+ + " C -->|Loop TD| A\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Loop TD")).isNotNull();
+ assertThat(svgTD.getEdgePaths().size()).isEqualTo(3);
+
+ String codeLR =
+ "graph LR\n"
+ + " A --> B\n"
+ + " B --> C\n"
+ + " C -->|Loop LR| A\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Loop LR")).isNotNull();
+ assertThat(svgLR.getEdgePaths().size()).isEqualTo(3);
+ }
+
+ @Test
+ public void testSkipLayerBypassBothOrientations() {
+ String codeTD =
+ "graph TD\n"
+ + " A --> B\n"
+ + " B --> C\n"
+ + " A -->|Skip TD| C\n";
+ SvgDoc svgTD = render(codeTD);
+ assertThat(svgTD.findText("Skip TD")).isNotNull();
+
+ String codeLR =
+ "graph LR\n"
+ + " A --> B\n"
+ + " B --> C\n"
+ + " A -->|Skip LR| C\n";
+ SvgDoc svgLR = render(codeLR);
+ assertThat(svgLR.findText("Skip LR")).isNotNull();
+ }
+
+ @Test
+ public void testHorizontalGraphWithCycleAndLongSpanEdge() {
+ String code =
+ "graph LR\n"
+ + " A[Happy Kitten] --> B[Playful Puppy]\n"
+ + " B --> C[Cozy Hamster]\n"
+ + " C -->|Run Back| A\n"
+ + " A -->|Long Leap| C\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Happy Kitten")).isNotNull();
+ assertThat(doc.findText("Playful Puppy")).isNotNull();
+ assertThat(doc.findText("Cozy Hamster")).isNotNull();
+ assertThat(doc.findText("Run Back")).isNotNull();
+ assertThat(doc.findText("Long Leap")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphWithLooseSourceCompaction() {
+ String code =
+ "graph TD\n"
+ + " subgraph Meadow [\"Sunny Green Meadow\"]\n"
+ + " A[Bright Buttercup] --> B[Busy Ant]\n"
+ + " B --> C[Tall Oak Tree]\n"
+ + " D[Quiet Snail] --> C\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Sunny Green Meadow")).isNotNull();
+ assertThat(doc.findText("Bright Buttercup")).isNotNull();
+ assertThat(doc.findText("Busy Ant")).isNotNull();
+ assertThat(doc.findText("Tall Oak Tree")).isNotNull();
+ assertThat(doc.findText("Quiet Snail")).isNotNull();
+ }
+
+ @Test
+ public void testDirectivesAndStylingIgnoredGracefully() {
+ String code =
+ "graph TD\n"
+ + " accTitle: Cheerful Morning Playground\n"
+ + " accDescr: Story of fluffy puppy and kitten\n"
+ + " classDef default fill:#f9f,stroke:#333;\n"
+ + " classDef special fill:#bbf,stroke:#333;\n"
+ + " class A special\n"
+ + " style B fill:#dfd,stroke:#333;\n"
+ + " click A href \"https://example.com\"\n"
+ + " linkStyle 0 stroke:#ff3,stroke-width:4px;\n"
+ + " A[Little Kitten] --> B[Fluffy Bunny]\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.getAllTextContents()).containsExactly("Little Kitten", "Fluffy Bunny").inOrder();
+ }
+
+ @Test
+ public void testDisconnectedNodes() {
+ SvgDoc svg = render("graph TD\n A[Quiet Mouse]\n B[Sleeping Turtle]\n C --> D\n");
+ assertThat(svg.getAllTextContents()).containsExactly("Quiet Mouse", "Sleeping Turtle", "C", "D").inOrder();
+ assertThat(svg.getElementsByTag("rect").size()).isEqualTo(4);
+ assertThat(svg.getEdgePaths().size()).isEqualTo(1);
+ }
+
+ @Test
+ public void testSelfLoop() {
+ SvgDoc svg = render("graph TD\n A --> A\n");
+ assertThat(svg.getAllTextContents()).containsExactly("A");
+ }
+
+ @Test
+ public void testWhitespaceAndEmptyBlocks() {
+ assertThat(SimpleMermaidRenderer.renderToSvg(" \n\n\t").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("graph TD\n%% only comments\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg(null).isPresent()).isFalse();
+ }
+
+ @Test
+ public void testEmptyLinesAndCommentsAtStart() {
+ String code = "\n\n%% Leading comment\n \ngraph TD\n\n%% Inner comment\n A --> B\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.getAllTextContents()).containsExactly("A", "B").inOrder();
+
+ assertThat(SimpleMermaidRenderer.renderToSvg("%% only comments\nsome random text\n").isPresent())
+ .isFalse();
+ }
+
+ @Test
+ public void testMalformedDelimitersGracefulHandling() {
+ String code = "graph TD\n A[\"Unclosed String\n B --> A\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.getAllTextContents()).contains("B");
+ }
+
+ @Test
+ public void testAllUnsupportedDiagramTypesReturnEmpty() {
+ assertThat(SimpleMermaidRenderer.renderToSvg("sequenceDiagram\nAlice->>Bob: Hello\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("classDiagram\nClass01 <|-- Class02\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("erDiagram\nCUSTOMER ||--o{ ORDER : places\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("gantt\ntitle A Gantt Diagram\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("pie title Pets\n\"Dogs\" : 386\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("gitGraph\ncommit\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("xychart-beta\ntitle \"Score\"\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram\n[*] --> Still\n").isPresent()).isFalse();
+ assertThat(SimpleMermaidRenderer.renderToSvg("stateDiagram-v2\n[*] --> Still\n").isPresent()).isFalse();
+ }
+
+ @Test
+ public void testNodeReassignmentToSubgraph() {
+ String code =
+ "graph TD\n"
+ + " A[Singing Robin]\n"
+ + " subgraph Sub\n"
+ + " A\n"
+ + " B[Flying Bluebird]\n"
+ + " end\n"
+ + " A --> B\n";
+ SvgDoc svg = render(code);
+ assertThat(svg.getAllTextContents()).containsExactly("Sub", "Singing Robin", "Flying Bluebird").inOrder();
+ }
+
+ @Test
+ public void testRemainingEdgeCasesForFullCoverage() {
+ // Quoted pipe label triggering cleanLabel unwrap
+ String code1 = "graph TD\n A -->|\"Sweet Honey Pie\"| B\n";
+ SvgDoc doc1 = render(code1);
+ assertThat(doc1.findText("Sweet Honey Pie")).isNotNull();
+
+ // Reverse edge in vertical nested subgraph
+ String code2 = "graph TD\n subgraph Sub\n A[Baby Chick]\n B[Mama Hen]\n B -->|Chirp Vert| A\n end\n";
+ SvgDoc doc2 = render(code2);
+ assertThat(doc2.findText("Chirp Vert")).isNotNull();
+
+ // Reverse edge in horizontal nested subgraph
+ String code3 = "graph LR\n subgraph Sub\n A[Baby Chick]\n B[Mama Hen]\n B -->|Chirp Horiz| A\n end\n";
+ SvgDoc doc3 = render(code3);
+ assertThat(doc3.findText("Chirp Horiz")).isNotNull();
+
+ // Mutual same-layer edge with label
+ String code5 = "graph TD\n A --> B\n B --> A\n A -->|Golden Star| B\n C --> D\n";
+ render(code5);
+
+ // Trailing non-edge characters to hit scanEdgeToken default return null
+ String code6 = "graph TD\n A 12345\n";
+ assertThat(SimpleMermaidRenderer.renderToSvg(code6).isPresent()).isTrue();
+ }
+
+ private static SvgDoc render(String code) {
+ return SvgDoc.render(code);
+ }
+
+ @Test
+ public void testHorizontalSelfLoopAndExtraHeaders() {
+ // Horizontal self loop
+ String code1 = "graph LR\n A --> A\n";
+ SvgDoc doc1 = render(code1);
+ assertThat(doc1.findText("A")).isNotNull();
+
+ // Redundant header line in body
+ String code2 = "graph TD\n graph TD\n A --> B\n";
+ assertThat(SimpleMermaidRenderer.renderToSvg(code2).isPresent()).isTrue();
+
+ // Direct AST Node empty label setter
+ SimpleMermaidRenderer.Node n = new SimpleMermaidRenderer.Node("testNode");
+ n.setLabel("");
+ assertThat(n.labelLines).containsExactly("");
+
+ // Double quotes in label and title to exercise escapeXml
+ String code3 = "graph TD\n subgraph Sg [\"Magic Castle with \\\"Stars\\\"\"]\n A[\"Has \\\"Glitter\\\" in pocket\"]\n end\n";
+ assertThat(SimpleMermaidRenderer.renderToSvg(code3).isPresent()).isTrue();
+ }
+
+ @Test
+ public void testSecurityNoScriptOrIframeExecutionInNodeLabels() {
+ String code =
+ "graph TD\n"
+ + " A[\"<script>alert('xss-script')</script>\"]\n"
+ + " B[\"<iframe src='javascript:alert(1)'></iframe>\"]\n"
+ + " C[\"<img src=x onerror=alert('img-onerror')>\"]\n"
+ + " D[\"<svg onload=alert('svg-onload')>\"]\n"
+ + " E[\"<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>\"]\n"
+ + " F[\"<a href='javascript:alert(1)'>Click Me</a>\"]\n"
+ + " A --> B --> C --> D --> E --> F\n";
+
+ Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(code);
+ assertThat(svgOpt.isPresent()).isTrue();
+
+ SvgDoc svg = new SvgDoc(svgOpt.get());
+ svg.assertRootSvg();
+
+ // Verify absolutely no executable or iframe elements exist in the DOM
+ assertThat(svg.getElementsByTag("script")).isEmpty();
+ assertThat(svg.getElementsByTag("iframe")).isEmpty();
+ assertThat(svg.getElementsByTag("foreignObject")).isEmpty();
+ assertThat(svg.getElementsByTag("img")).isEmpty();
+ assertThat(svg.getElementsByTag("a")).isEmpty();
+
+ // Verify raw SVG string contains no unescaped tags
+ String raw = svgOpt.get();
+ assertThat(raw).doesNotContain("<script");
+ assertThat(raw).doesNotContain("</script>");
+ assertThat(raw).doesNotContain("<iframe");
+ assertThat(raw).doesNotContain("</iframe>");
+ assertThat(raw).doesNotContain("<foreignObject");
+ assertThat(raw).doesNotContain("<img");
+ assertThat(raw).doesNotContain("<a ");
+
+ // Verify content is preserved safely as inert escaped text
+ assertThat(svg.findText("<script>alert('xss-script')</script>")).isNotNull();
+ assertThat(svg.findText("<iframe src='javascript:alert(1)'></iframe>")).isNotNull();
+ assertThat(svg.findText("<img src=x onerror=alert('img-onerror')>")).isNotNull();
+ assertThat(svg.findText("<svg onload=alert('svg-onload')>")).isNotNull();
+ assertThat(svg.findText("<foreignObject><iframe src='https://evil.com'></iframe></foreignObject>")).isNotNull();
+ assertThat(svg.findText("<a href='javascript:alert(1)'>Click Me</a>")).isNotNull();
+ }
+
+ @Test
+ public void testSecurityNoScriptOrIframeInEdgeLabels() {
+ String code =
+ "graph TD\n"
+ + " A -->|\"<script>alert('edge-pipe')</script>\"| B\n"
+ + " B -- \"<iframe src='http://evil.com'></iframe>\" --> C\n"
+ + " C == \"<img src=x onerror=alert('thick-edge')>\" ==> D\n"
+ + " D -. \"<svg onload=alert('dashed-edge')>\" .-> E\n";
+ SvgDoc svg = render(code);
+
+ assertThat(svg.getElementsByTag("script")).isEmpty();
+ assertThat(svg.getElementsByTag("iframe")).isEmpty();
+ assertThat(svg.getElementsByTag("foreignObject")).isEmpty();
+
+ String raw = SimpleMermaidRenderer.renderToSvg(code).get();
+ assertThat(raw).doesNotContain("<script");
+ assertThat(raw).doesNotContain("</script>");
+ assertThat(raw).doesNotContain("<iframe");
+ assertThat(raw).doesNotContain("</iframe>");
+ assertThat(raw).doesNotContain("<img");
+ assertThat(raw).doesNotContain("<svg onload");
+
+ assertThat(svg.findText("<script>alert('edge-pipe')</script>")).isNotNull();
+ assertThat(svg.findText("<iframe src='http://evil.com'></iframe>")).isNotNull();
+ assertThat(svg.findText("<img src=x onerror=alert('thick-edge')>")).isNotNull();
+ assertThat(svg.findText("<svg onload=alert('dashed-edge')>")).isNotNull();
+ }
+
+ @Test
+ public void testSecurityNoScriptOrIframeInSubgraphTitles() {
+ String code =
+ "graph TD\n"
+ + " subgraph Sg1 [\"<script>alert('subgraph-title')</script>\"]\n"
+ + " A[Node A]\n"
+ + " end\n"
+ + " subgraph Sg2 [\"<iframe src='javascript:alert(2)'></iframe>\"]\n"
+ + " B[Node B]\n"
+ + " end\n"
+ + " A --> B\n";
+ SvgDoc svg = render(code);
+
+ assertThat(svg.getElementsByTag("script")).isEmpty();
+ assertThat(svg.getElementsByTag("iframe")).isEmpty();
+
+ String raw = SimpleMermaidRenderer.renderToSvg(code).get();
+ assertThat(raw).doesNotContain("<script");
+ assertThat(raw).doesNotContain("</script>");
+ assertThat(raw).doesNotContain("<iframe");
+ assertThat(raw).doesNotContain("</iframe>");
+
+ assertThat(svg.findText("<script>alert('subgraph-title')</script>")).isNotNull();
+ assertThat(svg.findText("<iframe src='javascript:alert(2)'></iframe>")).isNotNull();
+ }
+
+ @Test
+ public void testSecurityDirectivesCannotInjectJavascriptUrls() {
+ String code =
+ "graph TD\n"
+ + " A[Node A] --> B[Node B]\n"
+ + " click A href \"javascript:alert('click-href')\"\n"
+ + " click B call alert('click-call')\n"
+ + " click A \"javascript:alert('positional-href')\"\n"
+ + " style A fill:url(javascript:alert(1))\n"
+ + " classDef evil fill:red,color:white;\n"
+ + " linkStyle 0 stroke:red;\n";
+ SvgDoc svg = render(code);
+
+ assertThat(svg.getElementsByTag("a")).isEmpty();
+ assertThat(svg.getElementsByTag("script")).isEmpty();
+
+ String raw = SimpleMermaidRenderer.renderToSvg(code).get();
+ assertThat(raw).doesNotContain("javascript:");
+ assertThat(raw).doesNotContain("href=");
+ assertThat(raw).doesNotContain("onclick=");
+ assertThat(raw).doesNotContain("xlink:href");
+ }
+
+ @Test
+ public void testSecurityXmlBreakoutPayloads() {
+ String code =
+ "graph TD\n"
+ + " A[\"</text></svg><script>alert('breakout')</script><svg><text>\"]\n"
+ + " B[\"'\"><script>alert('quote-breakout')</script>\"]\n"
+ + " A --> B\n";
+ SvgDoc svg = render(code);
+
+ // Verify the document root remains the only SVG element and no script elements were injected
+ assertThat(svg.getElementsByTag("script")).isEmpty();
+ assertThat(svg.getElementsByTag("svg").size()).isEqualTo(1);
+
+ String raw = SimpleMermaidRenderer.renderToSvg(code).get();
+ assertThat(raw).doesNotContain("<script");
+ assertThat(raw).doesNotContain("</svg><script>");
+ }
+
+ @Test
+ public void testIsolatedSubgraphAlongsideMainDagTree() {
+ String code =
+ "graph TD\n"
+ + " ClientApp[Little Puppy Plays] --> Extras(Sweet Kitten)\n"
+ + " ClientApp --> Utils(Happy Bunny)\n"
+ + " \n"
+ + " Utils --> ServiceDiscovery[Red Apple Berry]\n"
+ + " Utils --> ModelManager[Yellow Banana Snack]\n"
+ + " \n"
+ + " Extras --> Recognition(Fluffy Duckling)\n"
+ + " \n"
+ + " Recognition --> SODA(Green Frog Jump)\n"
+ + " Recognition --> S3(Sunny Daisy Flower)\n"
+ + " \n"
+ + " subgraph Play Park Garden\n"
+ + " Executors(Teddy Bear)\n"
+ + " Errors(Wooden Blocks)\n"
+ + " Protos(Toy Wagon)\n"
+ + " end\n"
+ + " \n"
+ + " Recognition -.-> PlayParkGarden\n";
+ SvgDoc svg = render(code);
+
+ // Verify all nodes and subgraph title exist
+ assertThat(svg.findText("Play Park Garden")).isNotNull();
+ assertThat(svg.findText("Teddy Bear")).isNotNull();
+ assertThat(svg.findText("Wooden Blocks")).isNotNull();
+ assertThat(svg.findText("Toy Wagon")).isNotNull();
+ assertThat(svg.findText("Little Puppy Plays")).isNotNull();
+ assertThat(svg.findText("Sweet Kitten")).isNotNull();
+ assertThat(svg.findText("Happy Bunny")).isNotNull();
+ assertThat(svg.findText("Red Apple Berry")).isNotNull();
+ assertThat(svg.findText("Yellow Banana Snack")).isNotNull();
+ assertThat(svg.findText("Fluffy Duckling")).isNotNull();
+ assertThat(svg.findText("Green Frog Jump")).isNotNull();
+ assertThat(svg.findText("Sunny Daisy Flower")).isNotNull();
+ assertThat(svg.findText("PlayParkGarden")).isNotNull();
+
+ // Verify vertical stack in Play Park Garden subgraph (Teddy Bear above Wooden Blocks above Toy Wagon)
+ Element executorsText = svg.findText("Teddy Bear");
+ Element errorsText = svg.findText("Wooden Blocks");
+ Element protoText = svg.findText("Toy Wagon");
+ double execY = Double.parseDouble(executorsText.getAttribute("y"));
+ double errY = Double.parseDouble(errorsText.getAttribute("y"));
+ double protoY = Double.parseDouble(protoText.getAttribute("y"));
+ assertThat(execY).isLessThan(errY);
+ assertThat(errY).isLessThan(protoY);
+
+ // Verify Play Park Garden is placed on the left of ClientApp
+ Element clientAppText = svg.findText("Little Puppy Plays");
+ double execX = Double.parseDouble(executorsText.getAttribute("x"));
+ double clientX = Double.parseDouble(clientAppText.getAttribute("x"));
+ assertThat(execX).isLessThan(clientX);
+ }
+
+ @Test
+ public void testMultiNodeChainingWithAmpersand() {
+ String code =
+ "graph TD\n"
+ + " A[Little Star] --> CheckJDAA{Is Puppy Sleepy?}\n"
+ + " CheckJDAA -- No --> InstallJDA[Play With Soft Ball]\n"
+ + " InstallJDA --> CheckJDAA\n"
+ + " CheckJDAA -- Yes --> B{Wants Sweet Cookie?}\n"
+ + " B -- Yes --> C[Drink Warm Milk Cup]\n"
+ + " B -- No --> D[Sing Happy Lullaby]\n"
+ + " D --> E[Cuddle Warm Blanket]\n"
+ + " D --> F[Hug Fluffy Panda]\n"
+ + " D --> G[Close Shiny Eyes]\n"
+ + " E & F & G --> H[Sweet Dreams Forest]\n"
+ + " C & H --> I[Gentle Good Night]\n"
+ + " I --> J[Sleep Until Morning]\n"
+ + " J --> K[Wake Up Happy Sun]\n";
+ SvgDoc svg = render(code);
+
+ // Verify all nodes exist
+ assertThat(svg.findText("Little Star")).isNotNull();
+ assertThat(svg.findText("Is Puppy Sleepy?")).isNotNull();
+ assertThat(svg.findText("Play With Soft Ball")).isNotNull();
+ assertThat(svg.findText("Wants Sweet Cookie?")).isNotNull();
+ assertThat(svg.findText("Drink Warm Milk Cup")).isNotNull();
+ assertThat(svg.findText("Sing Happy Lullaby")).isNotNull();
+ assertThat(svg.findText("Cuddle Warm Blanket")).isNotNull();
+ assertThat(svg.findText("Hug Fluffy Panda")).isNotNull();
+ assertThat(svg.findText("Close Shiny Eyes")).isNotNull();
+ assertThat(svg.findText("Sweet Dreams Forest")).isNotNull();
+ assertThat(svg.findText("Gentle Good Night")).isNotNull();
+ assertThat(svg.findText("Sleep Until Morning")).isNotNull();
+ assertThat(svg.findText("Wake Up Happy Sun")).isNotNull();
+ // Verify diamond shape polygons exist for decision nodes CheckJDAA and B
+ List<Element> polygons = svg.getElementsByTag("polygon");
+ assertThat(polygons.size()).isAtLeast(2); // CheckJDAA and B are diamonds
+
+ // Verify vertical order down the DAG
+ Element queryText = svg.findText("Little Star");
+ Element hText = svg.findText("Sweet Dreams Forest");
+ Element iText = svg.findText("Gentle Good Night");
+ Element jText = svg.findText("Sleep Until Morning");
+ Element kText = svg.findText("Wake Up Happy Sun");
+
+ double queryY = Double.parseDouble(queryText.getAttribute("y"));
+ double hY = Double.parseDouble(hText.getAttribute("y"));
+ double iY = Double.parseDouble(iText.getAttribute("y"));
+ double jY = Double.parseDouble(jText.getAttribute("y"));
+ double kY = Double.parseDouble(kText.getAttribute("y"));
+
+ assertThat(queryY).isLessThan(hY);
+ assertThat(hY).isLessThan(iY);
+ assertThat(iY).isLessThan(jY);
+ assertThat(jY).isLessThan(kY);
+
+ // Also test multi-source to multi-target chaining (A & B --> C & D)
+ String multiCode = "graph TD\n A & B --> C & D\n";
+ SvgDoc multiDoc = render(multiCode);
+ assertThat(multiDoc.findText("A")).isNotNull();
+ assertThat(multiDoc.findText("B")).isNotNull();
+ assertThat(multiDoc.findText("C")).isNotNull();
+ assertThat(multiDoc.findText("D")).isNotNull();
+ assertThat(multiDoc.getElementsByTag("path").size()).isEqualTo(5); // 1 marker path in <defs> + 4 edge paths
+ }
+
+ @Test
+ public void testSubgraphDirectionOverrideWithCrossEdges() {
+ String code =
+ "graph TD\n"
+ + " subgraph Castle [\"Toy Castle\"]\n"
+ + " direction LR\n"
+ + " A[Happy Bear] --> B[Silly Goose]\n"
+ + " end\n"
+ + " subgraph Garden [\"Flower Garden\"]\n"
+ + " C[Sunny Daisy]\n"
+ + " end\n"
+ + " B --> C\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Toy Castle")).isNotNull();
+ assertThat(doc.findText("Flower Garden")).isNotNull();
+ assertThat(doc.findText("Happy Bear")).isNotNull();
+ assertThat(doc.findText("Silly Goose")).isNotNull();
+ assertThat(doc.findText("Sunny Daisy")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphsWithLabeledInterChildEdge() {
+ String code =
+ "graph LR\n"
+ + " subgraph ToyBox [\"Big Toy Box\"]\n"
+ + " subgraph PuzzleA [\"Puppy Puzzle\"]\n"
+ + " A[Little Dog]\n"
+ + " end\n"
+ + " subgraph PuzzleB [\"Kitten Puzzle\"]\n"
+ + " B[Little Cat]\n"
+ + " end\n"
+ + " A -->|Friendly Meow| B\n"
+ + " end\n"
+ + " subgraph BedTime [\"Sleepy Pillow\"]\n"
+ + " C[Cozy Blanket]\n"
+ + " end\n"
+ + " B -->|Soft Hug| C\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Big Toy Box")).isNotNull();
+ assertThat(doc.findText("Puppy Puzzle")).isNotNull();
+ assertThat(doc.findText("Kitten Puzzle")).isNotNull();
+ assertThat(doc.findText("Little Dog")).isNotNull();
+ assertThat(doc.findText("Little Cat")).isNotNull();
+ assertThat(doc.findText("Friendly Meow")).isNotNull();
+ assertThat(doc.findText("Sleepy Pillow")).isNotNull();
+ assertThat(doc.findText("Cozy Blanket")).isNotNull();
+ assertThat(doc.findText("Soft Hug")).isNotNull();
+ }
+
+ @Test
+ public void testNestedSubgraphsVerticalWithCrossEdgesAndEmptySubgraphs() {
+ String code =
+ "graph TD\n"
+ + " subgraph WonderLand [\"Magic Wonderland\"]\n"
+ + " subgraph EmptyBox [\"Empty Treasure Chest\"]\n"
+ + " end\n"
+ + " subgraph ZoneA [\"Butterfly Valley\"]\n"
+ + " A[Shiny Butterfly]\n"
+ + " end\n"
+ + " subgraph ZoneB [\"Rainbow Hill\"]\n"
+ + " B[Glowing Rainbow]\n"
+ + " end\n"
+ + " B -->|Sweet Melody| A\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Magic Wonderland")).isNotNull();
+ assertThat(doc.findText("Empty Treasure Chest")).isNotNull();
+ assertThat(doc.findText("Butterfly Valley")).isNotNull();
+ assertThat(doc.findText("Rainbow Hill")).isNotNull();
+ assertThat(doc.findText("Shiny Butterfly")).isNotNull();
+ assertThat(doc.findText("Glowing Rainbow")).isNotNull();
+ assertThat(doc.findText("Sweet Melody")).isNotNull();
+ }
+
+ @Test
+ public void testSkipLayerBypassWithDummyNodesTD() {
+ String code =
+ "graph TD\n"
+ + " A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey]\n"
+ + " A -->|Red Apple| C[Happy Puppy]\n"
+ + " B --> C\n"
+ + " C --> D[Little Kitten]\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Teddy Bear")).isNotNull();
+ assertThat(doc.findText("Silly Monkey")).isNotNull();
+ assertThat(doc.findText("Happy Puppy")).isNotNull();
+ assertThat(doc.findText("Little Kitten")).isNotNull();
+ assertThat(doc.findText("Blue Balloon")).isNotNull();
+ assertThat(doc.findText("Red Apple")).isNotNull();
+ }
+
+ @Test
+ public void testSkipLayerBypassWithDummyNodesLR() {
+ String code =
+ "graph LR\n"
+ + " A[Teddy Bear] -->|Blue Balloon| B[Silly Monkey]\n"
+ + " A -->|Red Apple| C[Happy Puppy]\n"
+ + " B --> C\n"
+ + " C --> D[Little Kitten]\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Teddy Bear")).isNotNull();
+ assertThat(doc.findText("Silly Monkey")).isNotNull();
+ assertThat(doc.findText("Happy Puppy")).isNotNull();
+ assertThat(doc.findText("Little Kitten")).isNotNull();
+ assertThat(doc.findText("Blue Balloon")).isNotNull();
+ assertThat(doc.findText("Red Apple")).isNotNull();
+ }
+
+ @Test
+ public void testCompoundSubgraphAndStandaloneNodesLayoutWithStyles() {
+ String code =
+ "graph LR\n"
+ + " subgraph CastleBox [\"Play Castle\"]\n"
+ + " direction TB\n"
+ + " ToyA[Magic Wand] <--> ToyB[Cozy Teddy]\n"
+ + " end\n"
+ + " subgraph GardenBox [\"Flower Garden\"]\n"
+ + " ToyC[Pink Blossom] --> ToyD[Sweet Daisy]\n"
+ + " end\n"
+ + " ToyB --> ToyC\n"
+ + " ToyE[Happy Butterfly] --> ToyB\n"
+ + " style CastleBox fill:#e3f2fd,stroke:#1e88e5\n"
+ + " style GardenBox fill:rgb(240,250,240),stroke:#43a047\n"
+ + " style ToyE fill:hsl(120,50%,90%),stroke:blue\n"
+ + " style ToyA fill:rgba(255,255,255,0.8),stroke:purple\n"
+ + " style ToyC fill:#ffb300,stroke:#333333\n"
+ + " style \"\" fill:#fff\n"
+ + " style NonExistent fill:#fff\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Play Castle")).isNotNull();
+ assertThat(doc.findText("Flower Garden")).isNotNull();
+ assertThat(doc.findText("Magic Wand")).isNotNull();
+ assertThat(doc.findText("Cozy Teddy")).isNotNull();
+ assertThat(doc.findText("Pink Blossom")).isNotNull();
+ assertThat(doc.findText("Sweet Daisy")).isNotNull();
+ assertThat(doc.findText("Happy Butterfly")).isNotNull();
+ }
+
+ @Test
+ public void testStyledCustomShapes() {
+ String code =
+ "graph TD\n"
+ + " N1((Sun Ball)) --> N2{Magic Gem}\n"
+ + " N2 --> N3{{Toy Boat}}\n"
+ + " N3 --> N4[(Toy Castle)]\n"
+ + " N4 --> N5>Sweet Candy]\n"
+ + " N5 --> N6[[Puppy House]]\n"
+ + " style N1 fill:#ffecb3,stroke:#ffa000\n"
+ + " style N2 fill:#e1bee7,stroke:#8e24aa\n"
+ + " style N3 fill:#c8e6c9,stroke:#388e3c\n"
+ + " style N4 fill:#b2ebf2,stroke:#00838f\n"
+ + " style N5 fill:#ffcdd2,stroke:#c62828\n"
+ + " style N6 fill:#d1c4e9,stroke:#512da8\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Sun Ball")).isNotNull();
+ assertThat(doc.findText("Magic Gem")).isNotNull();
+ assertThat(doc.findText("Toy Boat")).isNotNull();
+ assertThat(doc.findText("Toy Castle")).isNotNull();
+ assertThat(doc.findText("Sweet Candy")).isNotNull();
+ assertThat(doc.findText("Puppy House")).isNotNull();
+ }
+
+ @Test
+ public void testSingleUnitCompoundComponent() {
+ String code =
+ "graph TD\n"
+ + " subgraph SoloBox [\"Secret Clubhouse\"]\n"
+ + " KidA[Little Star] --> KidB[Bright Moon]\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Secret Clubhouse")).isNotNull();
+ assertThat(doc.findText("Little Star")).isNotNull();
+ assertThat(doc.findText("Bright Moon")).isNotNull();
+ }
+
+ @Test
+ public void testSequentialSubgraphsWithoutInternalDag() {
+ String code =
+ "graph TD\n"
+ + " subgraph VertBox [\"Stacking Blocks\"]\n"
+ + " BoxA[Red Block]\n"
+ + " BoxB[Blue Block]\n"
+ + " BoxC[Green Block]\n"
+ + " end\n"
+ + " subgraph HorizBox [\"Toy Train\"]\n"
+ + " direction LR\n"
+ + " CarA[Train Engine]\n"
+ + " CarB[Train Caboose]\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Stacking Blocks")).isNotNull();
+ assertThat(doc.findText("Toy Train")).isNotNull();
+ }
+
+ @Test
+ public void testSequentialSubgraphsWithLabels() {
+ String code =
+ "graph TD\n"
+ + " subgraph VertBox [\"Stacking Blocks\"]\n"
+ + " BoxA[Red Block]\n"
+ + " BoxB[Blue Block]\n"
+ + " end\n"
+ + " subgraph HorizBox [\"Toy Train\"]\n"
+ + " direction LR\n"
+ + " CarA[Train Engine]\n"
+ + " CarB[Train Caboose]\n"
+ + " end\n"
+ + " BoxA -->|Stack On| BoxB\n"
+ + " CarA -->|Pull Car| CarB\n"
+ + " style BoxA fill:#112233;stroke:#445566\n"
+ + " style BoxB fill:#112233,stroke:#445566\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Stacking Blocks")).isNotNull();
+ assertThat(doc.findText("Toy Train")).isNotNull();
+ }
+
+ @Test
+ public void testMultiLayerCompoundComponent() {
+ String code =
+ "graph LR\n"
+ + " subgraph Box1 [\"First Box\"]\n"
+ + " A[Puppy Dog]\n"
+ + " end\n"
+ + " subgraph Box2 [\"Second Box\"]\n"
+ + " B[Kitty Cat]\n"
+ + " end\n"
+ + " subgraph Box3 [\"Third Box\"]\n"
+ + " C[Bunny Rabbit]\n"
+ + " end\n"
+ + " A --> B\n"
+ + " B --> C\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("First Box")).isNotNull();
+ assertThat(doc.findText("Second Box")).isNotNull();
+ assertThat(doc.findText("Third Box")).isNotNull();
+ }
+
+ @Test
+ public void testTripleNestedSubgraphsWithCrossChildEdges() {
+ String code =
+ "graph TD\n"
+ + " subgraph OuterCastle [\"Giant Castle\"]\n"
+ + " subgraph MidTower [\"High Tower\"]\n"
+ + " subgraph InnerRoom [\"Secret Room\"]\n"
+ + " Gem[Magic Ruby]\n"
+ + " end\n"
+ + " end\n"
+ + " subgraph SecondTower [\"Low Tower\"]\n"
+ + " OtherGem[Shiny Emerald]\n"
+ + " end\n"
+ + " Gem -->|Sparkle Magic| OtherGem\n"
+ + " end\n"
+ + " Dragon[Friendly Dragon] --> Gem\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Giant Castle")).isNotNull();
+ assertThat(doc.findText("High Tower")).isNotNull();
+ assertThat(doc.findText("Secret Room")).isNotNull();
+ assertThat(doc.findText("Low Tower")).isNotNull();
+ assertThat(doc.findText("Magic Ruby")).isNotNull();
+ assertThat(doc.findText("Shiny Emerald")).isNotNull();
+ assertThat(doc.findText("Friendly Dragon")).isNotNull();
+ assertThat(doc.findText("Sparkle Magic")).isNotNull();
+ }
+
+ @Test
+ public void testDecisionTreeBranchingWithFeedbackLoopAndChildrenWords() {
+ String code =
+ "graph TD\n"
+ + " A[Little Bunny Play] --> B{Choose Sweet Snack}\n"
+ + " B -->|Crisp Red Apple| C[Happy Bunny Chew]\n"
+ + " B -->|Sweet Yellow Banana| D[Joyful Bunny Hop]\n"
+ + " B -->|Crunchy Orange Carrot| E[Cheerful Bunny Munch]\n"
+ + " E -->|Ask For More Treats| B\n"
+ + " E -->|Tired Little Nap| F[Sleepy Cozy Blanket]\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText("Little Bunny Play")).isNotNull();
+ assertThat(doc.findText("Choose Sweet Snack")).isNotNull();
+ assertThat(doc.findText("Happy Bunny Chew")).isNotNull();
+ assertThat(doc.findText("Joyful Bunny Hop")).isNotNull();
+ assertThat(doc.findText("Cheerful Bunny Munch")).isNotNull();
+ assertThat(doc.findText("Sleepy Cozy Blanket")).isNotNull();
+ assertThat(doc.findText("Crisp Red Apple")).isNotNull();
+ assertThat(doc.findText("Sweet Yellow Banana")).isNotNull();
+ assertThat(doc.findText("Crunchy Orange Carrot")).isNotNull();
+ assertThat(doc.findText("Ask For More Treats")).isNotNull();
+ assertThat(doc.findText("Tired Little Nap")).isNotNull();
+
+ Element eText = doc.findText("Cheerful Bunny Munch");
+ Element fText = doc.findText("Sleepy Cozy Blanket");
+ assertThat(eText).isNotNull();
+ assertThat(fText).isNotNull();
+ double eX = Double.parseDouble(eText.getAttribute("x"));
+ double fX = Double.parseDouble(fText.getAttribute("x"));
+ assertThat(Math.abs(eX - fX)).isLessThan(2.0);
+ }
+
+ @Test
+ public void testLongNodeLabelDoesNotOverflowOrGetCapped() {
+ String longLabel = "Happy Little Friendly Puppy Plays With Big Red Ball In The Green Garden";
+ String code = "graph TD\n A[\"" + longLabel + "\"]\n";
+ SvgDoc doc = render(code);
+ assertThat(doc.findText(longLabel)).isNotNull();
+
+ List<Element> rects = doc.getElementsByTag("rect");
+ assertThat(rects.size()).isEqualTo(1);
+ double rectWidth = Double.parseDouble(rects.get(0).getAttribute("width"));
+ double expectedMin = longLabel.length() * 7.5;
+ assertThat(rectWidth).isGreaterThan(expectedMin);
+ assertThat(rectWidth).isGreaterThan(350.0);
+ }
+
+ @Test
+ public void testNestedSubgraphWithSiblingNodesAndCrossLayerEdges() {
+ String code =
+ "graph TD\n"
+ + " subgraph ToyBox [\"Big Toy Box\"]\n"
+ + " A[Magic Wand] --> B[Golden Crown]\n"
+ + " B --> C[Shiny Sparkles]\n"
+ + " D[Toy Train] --> E{Has Train Track?}\n"
+ + " E -->|Yes| F[Start Train Engine]\n"
+ + " subgraph TrainCars [\"Little Train Cars\"]\n"
+ + " F --> G[Red Caboose]\n"
+ + " G --> H[Blue Engine]\n"
+ + " end\n"
+ + " end\n"
+ + " subgraph Playroom [\"Sunny Playroom\"]\n"
+ + " Target[Happy Child Playing]\n"
+ + " end\n"
+ + " H --> Target\n";
+ SvgDoc doc = render(code);
+
+ assertThat(doc.findText("Big Toy Box")).isNotNull();
+ assertThat(doc.findText("Magic Wand")).isNotNull();
+ assertThat(doc.findText("Golden Crown")).isNotNull();
+ assertThat(doc.findText("Shiny Sparkles")).isNotNull();
+ assertThat(doc.findText("Toy Train")).isNotNull();
+ assertThat(doc.findText("Has Train Track?")).isNotNull();
+ assertThat(doc.findText("Start Train Engine")).isNotNull();
+ assertThat(doc.findText("Little Train Cars")).isNotNull();
+ assertThat(doc.findText("Red Caboose")).isNotNull();
+ assertThat(doc.findText("Blue Engine")).isNotNull();
+ assertThat(doc.findText("Sunny Playroom")).isNotNull();
+ assertThat(doc.findText("Happy Child Playing")).isNotNull();
+
+ Element fEl = doc.findText("Start Train Engine");
+ Element gEl = doc.findText("Red Caboose");
+ assertThat(fEl).isNotNull();
+ assertThat(gEl).isNotNull();
+ double fY = Double.parseDouble(fEl.getAttribute("y"));
+ double gY = Double.parseDouble(gEl.getAttribute("y"));
+ assertThat(fY).isLessThan(gY);
+ }
+
+ @Test
+ public void testCylinderShapeWithAlapCompactionAndBackEdgeClearance() {
+ String code =
+ "graph TD\n"
+ + " A[(\"Honey Pot <br> Sweet & Yummy\")] -->|Morning Buzz| B[Busy Little Bumblebee]\n"
+ + " B -->|Happy Flight| C[Flower Garden Patch]\n"
+ + " D[Playful Garden Snail] -->|Slow Crawl| C\n"
+ + " C -->|Gather Nectar| A\n"
+ + " C -->|Pollinate Plants| E[Bright Sunflower]\n";
+ SvgDoc doc = render(code);
+
+ assertThat(doc.findText("Honey Pot")).isNotNull();
+ assertThat(doc.findText("Sweet & Yummy")).isNotNull();
+ assertThat(doc.findText("Busy Little Bumblebee")).isNotNull();
+ assertThat(doc.findText("Flower Garden Patch")).isNotNull();
+ assertThat(doc.findText("Playful Garden Snail")).isNotNull();
+ assertThat(doc.findText("Bright Sunflower")).isNotNull();
+
+ Element bEl = doc.findText("Busy Little Bumblebee");
+ Element dEl = doc.findText("Playful Garden Snail");
+ assertThat(bEl).isNotNull();
+ assertThat(dEl).isNotNull();
+ double bY = Double.parseDouble(bEl.getAttribute("y"));
+ double dY = Double.parseDouble(dEl.getAttribute("y"));
+ assertThat(Math.abs(bY - dY)).isLessThan(2.0);
+
+ List<Element> paths = doc.getEdgePaths();
+ assertThat(paths.size()).isAtLeast(5);
+
+ // Verify back-edge from C to A loops with rightward clearance beyond D
+ Element dText = doc.findText("Playful Garden Snail");
+ double dRight = Double.parseDouble(dText.getAttribute("x")) + 80.0;
+ boolean foundClearanceLoop = false;
+ for (Element p : paths) {
+ String d = p.getAttribute("d");
+ if (d.contains(" C ")) {
+ for (String part : Splitter.onPattern("[,\\s]+").omitEmptyStrings().split(d)) {
+ Double val = Doubles.tryParse(part);
+ if (val != null && val > dRight) {
+ foundClearanceLoop = true;
+ break;
+ }
+ }
+ }
+ }
+ assertThat(foundClearanceLoop).isTrue();
+ }
+
+ @Test
+ public void testSubgraphEdgeWithDirectionOverrideAndDynamicLabelSpacing() {
+ String code =
+ "flowchart TD\n"
+ + " subgraph StoryOne [\"Teddy Bear Adventure\"]\n"
+ + " direction LR\n"
+ + " P1[Cozy Blanket] ---|Soft Fluffy Hug| P2[Sweet Dream]\n"
+ + " P2 ---|Gentle Night Song| P3[Morning Sun]\n"
+ + " end\n"
+ + " subgraph StoryTwo [\"Puppy Playground\"]\n"
+ + " direction LR\n"
+ + " Q1[Rubber Ball] ---|Happy Bouncy Leap| Q2[Flying Frisbee]\n"
+ + " Q2 ---|Wagging Tail Jump| Q3[Green Lawn]\n"
+ + " end\n"
+ + " StoryOne ==>|Wake Up Early| StoryTwo\n";
+ SvgDoc doc = render(code);
+
+ // 1. Subgraph container layout and node containment checks
+ List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes();
+ assertThat(sgs.size()).isEqualTo(2);
+ SvgDoc.Rect2D sg1 = sgs.get(0);
+ SvgDoc.Rect2D sg2 = sgs.get(1);
+
+ // StoryOne must be completely vertically above StoryTwo
+ assertThat(sg1.bottom()).isLessThan(sg2.y);
+
+ List<SvgDoc.Rect2D> nodes = doc.getNodeBoundingBoxes();
+ assertThat(nodes.size()).isEqualTo(6);
+ for (int i = 0; i < 3; i++) {
+ assertThat(sg1.contains(nodes.get(i), 10.0)).isTrue();
+ }
+ for (int i = 3; i < 6; i++) {
+ assertThat(sg2.contains(nodes.get(i), 10.0)).isTrue();
+ }
+
+ // 2. Subgraph connecting edge geometry
+ List<Element> lines = doc.getElementsByTag("line");
+ assertThat(lines.size()).isEqualTo(1);
+ Element seLine = lines.get(0);
+ double lx1 = Double.parseDouble(seLine.getAttribute("x1"));
+ double ly1 = Double.parseDouble(seLine.getAttribute("y1"));
+ double lx2 = Double.parseDouble(seLine.getAttribute("x2"));
+ double ly2 = Double.parseDouble(seLine.getAttribute("y2"));
+ assertThat(Math.abs(lx1 - sg1.centerX())).isLessThan(1.0);
+ assertThat(Math.abs(ly1 - sg1.bottom())).isLessThan(1.0);
+ assertThat(Math.abs(lx2 - sg2.centerX())).isLessThan(1.0);
+ assertThat(Math.abs(ly2 - sg2.y)).isLessThan(1.0);
+
+ // 3. Intra-subgraph horizontal edge paths
+ List<Element> paths = doc.getEdgePaths();
+ assertThat(paths.size()).isEqualTo(4);
+ for (Element p : paths) {
+ String d = p.getAttribute("d");
+ assertThat(d).startsWith("M ");
+ }
+ }
+
+ @Test
+ public void testIsolatedSubgraphInHorizontalGraph() {
+ String code =
+ "graph LR\n"
+ + " subgraph Garden [\"Flower Garden\"]\n"
+ + " A[Bright Tulip]\n"
+ + " B[Daisy Flower]\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+
+ List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes();
+ assertThat(sgs.size()).isEqualTo(1);
+ List<SvgDoc.Rect2D> nodes = doc.getNodeBoundingBoxes();
+ assertThat(nodes.size()).isEqualTo(2);
+ assertThat(sgs.get(0).contains(nodes.get(0), 10.0)).isTrue();
+ assertThat(sgs.get(0).contains(nodes.get(1), 10.0)).isTrue();
+ }
+
+ @Test
+ public void testNestedSubgraphsWithInternalSubgraphEdgeAndInheritedDirection() {
+ String code =
+ "graph TD\n"
+ + " subgraph MainBox [\"Toy Warehouse\"]\n"
+ + " direction LR\n"
+ + " subgraph BoxOne [\"Teddy Room\"]\n"
+ + " A[Brown Bear]\n"
+ + " end\n"
+ + " subgraph BoxTwo [\"Puppy Room\"]\n"
+ + " B[Happy Dog]\n"
+ + " end\n"
+ + " BoxOne --> BoxTwo\n"
+ + " end\n";
+ SvgDoc doc = render(code);
+
+ List<SvgDoc.Rect2D> sgs = doc.getSubgraphBoundingBoxes();
+ assertThat(sgs.size()).isEqualTo(3);
+ }
+}
diff --git a/javatests/com/google/gitiles/doc/SvgDoc.java b/javatests/com/google/gitiles/doc/SvgDoc.java
new file mode 100644
index 0000000..0463ee7
--- /dev/null
+++ b/javatests/com/google/gitiles/doc/SvgDoc.java
@@ -0,0 +1,377 @@
+// Copyright 2026 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gitiles.doc;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+
+import com.google.common.base.Splitter;
+import java.io.StringReader;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+import org.xml.sax.InputSource;
+
+/**
+ * Shared DOM and geometric assertion helper for Mermaid SVG test suites.
+ */
+public class SvgDoc {
+
+ public static final List<String> DANGEROUS_TAGS =
+ Arrays.asList(
+ "script",
+ "iframe",
+ "foreignObject",
+ "img",
+ "a",
+ "object",
+ "embed",
+ "applet",
+ "audio",
+ "video",
+ "form",
+ "input",
+ "button",
+ "style",
+ "link",
+ "meta",
+ "use",
+ "image",
+ "animate",
+ "set",
+ "animateTransform",
+ "feImage",
+ "pattern",
+ "xml-stylesheet");
+
+ public final Document doc;
+ public final Element root;
+
+ public SvgDoc(String svg) {
+ try {
+ DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
+ factory.setNamespaceAware(false);
+ DocumentBuilder builder = factory.newDocumentBuilder();
+ this.doc = builder.parse(new InputSource(new StringReader(svg)));
+ this.root = doc.getDocumentElement();
+ } catch (Exception e) {
+ throw new AssertionError("SVG string is not valid XML: " + e.getMessage(), e);
+ }
+ }
+
+ /**
+ * Renders the given Mermaid diagram code and verifies all geometric and structural invariants.
+ */
+ public static SvgDoc render(String mermaidCode) {
+ Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode);
+ assertThat(svgOpt.isPresent()).isTrue();
+ SvgDoc doc = new SvgDoc(svgOpt.get());
+ doc.assertAllInvariants();
+ return doc;
+ }
+
+ /**
+ * Renders the given Mermaid diagram code without asserting collision invariants (e.g. for malformed tests).
+ */
+ public static SvgDoc renderRaw(String mermaidCode) {
+ Optional<String> svgOpt = SimpleMermaidRenderer.renderToSvg(mermaidCode);
+ assertThat(svgOpt.isPresent()).isTrue();
+ return new SvgDoc(svgOpt.get());
+ }
+
+ /**
+ * Asserts root SVG structure, safe tags, and non-overlapping nodes, edge labels, and subgraphs.
+ */
+ public void assertAllInvariants() {
+ assertRootSvg();
+ assertNoDangerousTags();
+ assertNoNodeOverlaps();
+ assertNoLabelNodeOverlaps();
+ assertSubgraphsDoNotOverlap();
+ }
+
+ public void assertRootSvg() {
+ assertThat(root.getTagName()).isEqualTo("svg");
+ assertThat(root.getAttribute("class")).isEqualTo("mermaid-svg");
+ assertThat(root.getAttribute("xmlns")).isEqualTo("http://www.w3.org/2000/svg");
+ assertThat(root.getAttribute("viewBox")).matches("^0 0 \\d+ \\d+$");
+ assertThat(root.getAttribute("style")).contains("max-width:");
+ }
+
+ public void assertDefs() {
+ List<Element> defs = getElementsByTag("defs");
+ assertThat(defs.size()).isEqualTo(1);
+ Element def = defs.get(0);
+ NodeList markers = def.getElementsByTagName("marker");
+ assertThat(markers.getLength()).isEqualTo(1);
+ Element marker = (Element) markers.item(0);
+ assertThat(marker.getAttribute("id")).isEqualTo("mermaid-arrow");
+
+ NodeList filters = def.getElementsByTagName("filter");
+ assertThat(filters.getLength()).isEqualTo(1);
+ Element filter = (Element) filters.item(0);
+ assertThat(filter.getAttribute("id")).isEqualTo("node-shadow");
+ }
+
+ public void assertNoDangerousTags() {
+ assertThat(root.getTagName()).isEqualTo("svg");
+ for (String tag : DANGEROUS_TAGS) {
+ NodeList nl = doc.getElementsByTagName(tag);
+ assertThat(nl.getLength()).isEqualTo(0);
+ }
+ }
+
+ public List<Element> getElementsByTag(String tagName) {
+ List<Element> list = new ArrayList<>();
+ NodeList nl = doc.getElementsByTagName(tagName);
+ for (int i = 0; i < nl.getLength(); i++) {
+ Node n = nl.item(i);
+ if (n instanceof Element) {
+ list.add((Element) n);
+ }
+ }
+ return list;
+ }
+
+ public List<Element> getEdgePaths() {
+ List<Element> list = new ArrayList<>();
+ for (Element p : getElementsByTag("path")) {
+ if (!"M 0 1.5 L 10 5 L 0 8.5 z".equals(p.getAttribute("d"))) {
+ list.add(p);
+ }
+ }
+ return list;
+ }
+
+ public List<Element> findSubgraphRects() {
+ List<Element> list = new ArrayList<>();
+ for (Element r : getElementsByTag("rect")) {
+ if ("4,4".equals(r.getAttribute("stroke-dasharray"))) {
+ list.add(r);
+ }
+ }
+ return list;
+ }
+
+ public @Nullable Element findPolygonWithVertices(int count) {
+ for (Element p : getElementsByTag("polygon")) {
+ String pts = p.getAttribute("points").trim();
+ if (!pts.isEmpty() && pts.split("\\s+").length == count) {
+ return p;
+ }
+ }
+ return null;
+ }
+
+ public @Nullable Element findText(String text) {
+ String expected = text.replace("\0", "").trim();
+ for (Element t : getElementsByTag("text")) {
+ String full = t.getTextContent().trim().replaceAll("\\s+", " ");
+ if (expected.equals(full) || expected.equals(t.getTextContent().trim()) || full.contains(expected)) {
+ return t;
+ }
+ }
+ for (Element t : getElementsByTag("tspan")) {
+ String full = t.getTextContent().trim();
+ if (expected.equals(full) || full.contains(expected)) {
+ return t;
+ }
+ }
+ return null;
+ }
+
+ public double getAttrDouble(Element el, String attr) {
+ return Double.parseDouble(el.getAttribute(attr));
+ }
+
+ public List<String> getAllTextContents() {
+ List<String> list = new ArrayList<>();
+ for (Element t : getElementsByTag("text")) {
+ String txt = t.getTextContent().trim().replaceAll("\\s+", " ");
+ if (!txt.isEmpty()) {
+ list.add(txt);
+ }
+ }
+ return list;
+ }
+
+ public static class Rect2D {
+ public final double x;
+ public final double y;
+ public final double width;
+ public final double height;
+ public final String label;
+
+ public Rect2D(double x, double y, double width, double height, String label) {
+ this.x = x;
+ this.y = y;
+ this.width = width;
+ this.height = height;
+ this.label = label;
+ }
+
+ public double right() {
+ return x + width;
+ }
+
+ public double bottom() {
+ return y + height;
+ }
+
+ public double centerX() {
+ return x + width / 2.0;
+ }
+
+ public boolean overlaps(Rect2D other, double tolerance) {
+ return (this.x + this.width - tolerance > other.x)
+ && (other.x + other.width - tolerance > this.x)
+ && (this.y + this.height - tolerance > other.y)
+ && (other.y + other.height - tolerance > this.y);
+ }
+
+ public boolean contains(Rect2D inner, double padding) {
+ return inner.x >= this.x + padding - 0.5
+ && inner.y >= this.y + padding - 0.5
+ && inner.right() <= this.right() - padding + 0.5
+ && inner.bottom() <= this.bottom() - padding + 0.5;
+ }
+
+ @Override
+ public String toString() {
+ return String.format("[%s: (%.1f, %.1f) %.1fx%.1f]", label, x, y, width, height);
+ }
+ }
+
+ public List<Rect2D> getNodeBoundingBoxes() {
+ List<Rect2D> list = new ArrayList<>();
+ for (Element r : getElementsByTag("rect")) {
+ String dash = r.getAttribute("stroke-dasharray");
+ String opacity = r.getAttribute("fill-opacity");
+ if ("4,4".equals(dash) || "0.95".equals(opacity)) {
+ continue;
+ }
+ double x = Double.parseDouble(r.getAttribute("x"));
+ double y = Double.parseDouble(r.getAttribute("y"));
+ double w = Double.parseDouble(r.getAttribute("width"));
+ double h = Double.parseDouble(r.getAttribute("height"));
+ list.add(new Rect2D(x, y, w, h, "NodeRect"));
+ }
+ for (Element c : getElementsByTag("circle")) {
+ double cx = Double.parseDouble(c.getAttribute("cx"));
+ double cy = Double.parseDouble(c.getAttribute("cy"));
+ double cr = Double.parseDouble(c.getAttribute("r"));
+ list.add(new Rect2D(cx - cr, cy - cr, cr * 2, cr * 2, "NodeCircle"));
+ }
+ for (Element p : getElementsByTag("polygon")) {
+ String pts = p.getAttribute("points").trim();
+ if (!pts.isEmpty()) {
+ double minX = Double.MAX_VALUE, minY = Double.MAX_VALUE;
+ double maxX = Double.MIN_VALUE, maxY = Double.MIN_VALUE;
+ for (String pair : Splitter.onPattern("\\s+").omitEmptyStrings().split(pts)) {
+ List<String> xy = Splitter.on(',').splitToList(pair);
+ if (xy.size() == 2) {
+ double px = Double.parseDouble(xy.get(0));
+ double py = Double.parseDouble(xy.get(1));
+ minX = Math.min(minX, px);
+ minY = Math.min(minY, py);
+ maxX = Math.max(maxX, px);
+ maxY = Math.max(maxY, py);
+ }
+ }
+ if (minX != Double.MAX_VALUE) {
+ list.add(new Rect2D(minX, minY, maxX - minX, maxY - minY, "NodePolygon"));
+ }
+ }
+ }
+ return list;
+ }
+
+ public List<Rect2D> getEdgeLabelBadgeBoundingBoxes() {
+ List<Rect2D> list = new ArrayList<>();
+ for (Element r : getElementsByTag("rect")) {
+ if ("0.95".equals(r.getAttribute("fill-opacity"))) {
+ double x = Double.parseDouble(r.getAttribute("x"));
+ double y = Double.parseDouble(r.getAttribute("y"));
+ double w = Double.parseDouble(r.getAttribute("width"));
+ double h = Double.parseDouble(r.getAttribute("height"));
+ list.add(new Rect2D(x, y, w, h, "LabelBadge"));
+ }
+ }
+ return list;
+ }
+
+ public List<Rect2D> getSubgraphBoundingBoxes() {
+ List<Rect2D> list = new ArrayList<>();
+ for (Element r : getElementsByTag("rect")) {
+ if ("4,4".equals(r.getAttribute("stroke-dasharray"))) {
+ double x = Double.parseDouble(r.getAttribute("x"));
+ double y = Double.parseDouble(r.getAttribute("y"));
+ double w = Double.parseDouble(r.getAttribute("width"));
+ double h = Double.parseDouble(r.getAttribute("height"));
+ list.add(new Rect2D(x, y, w, h, "SubgraphBox"));
+ }
+ }
+ return list;
+ }
+
+ public void assertNoNodeOverlaps() {
+ List<Rect2D> nodes = getNodeBoundingBoxes();
+ for (int i = 0; i < nodes.size(); i++) {
+ for (int j = i + 1; j < nodes.size(); j++) {
+ Rect2D a = nodes.get(i);
+ Rect2D b = nodes.get(j);
+ assertWithMessage("Node overlap detected between " + a + " and " + b)
+ .that(a.overlaps(b, 2.0))
+ .isFalse();
+ }
+ }
+ }
+
+ public void assertNoLabelNodeOverlaps() {
+ List<Rect2D> nodes = getNodeBoundingBoxes();
+ List<Rect2D> labels = getEdgeLabelBadgeBoundingBoxes();
+ for (Rect2D badge : labels) {
+ for (Rect2D node : nodes) {
+ assertWithMessage("Edge label badge " + badge + " overlaps node " + node)
+ .that(badge.overlaps(node, 2.0))
+ .isFalse();
+ }
+ }
+ }
+
+ public void assertSubgraphsDoNotOverlap() {
+ List<Rect2D> sgs = getSubgraphBoundingBoxes();
+ for (int i = 0; i < sgs.size(); i++) {
+ for (int j = i + 1; j < sgs.size(); j++) {
+ Rect2D a = sgs.get(i);
+ Rect2D b = sgs.get(j);
+ boolean oneContainsOther = a.contains(b, 0) || b.contains(a, 0);
+ if (!oneContainsOther) {
+ assertWithMessage("Sibling subgraphs overlap: " + a + " and " + b)
+ .that(a.overlaps(b, 2.0))
+ .isFalse();
+ }
+ }
+ }
+ }
+}
diff --git a/modules/jgit b/modules/jgit
index 200be16..fe01de6 160000
--- a/modules/jgit
+++ b/modules/jgit
@@ -1 +1 @@
-Subproject commit 200be16661d3b9983bd34affd8e424daf5ad23d7
+Subproject commit fe01de69f44611049b664188652bf9e340f0b1f8
diff --git a/resources/com/google/gitiles/static/doc.css b/resources/com/google/gitiles/static/doc.css
index 3164268..dde3aa7 100644
--- a/resources/com/google/gitiles/static/doc.css
+++ b/resources/com/google/gitiles/static/doc.css
@@ -376,3 +376,13 @@
.col-11 { width: 88.690%; }
.col-12 { width: 97.024%; }
.cols hr { width: 80%; }
+
+.mermaid-container {
+ margin: 1.5em 0;
+ text-align: center;
+ overflow-x: auto;
+}
+.mermaid-container svg {
+ display: inline-block;
+ vertical-align: middle;
+}