zombie-mermaid 2.2.2 → 2.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/dist/ascii.cjs +8 -8
  3. package/dist/ascii.cjs.map +1 -1
  4. package/dist/ascii.js +1137 -1070
  5. package/dist/ascii.js.map +1 -1
  6. package/dist/cli.js +3380 -3295
  7. package/dist/index.cjs +59 -52
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.js +1780 -1712
  10. package/dist/index.js.map +1 -1
  11. package/dist/mcp.cjs +246 -52
  12. package/dist/mcp.cjs.map +1 -1
  13. package/dist/mcp.js +246 -52
  14. package/dist/mcp.js.map +1 -1
  15. package/package.json +25 -21
  16. package/packages/ascii-renderer/src/canvas.ts +7 -4
  17. package/packages/ascii-renderer/src/class-diagram.ts +192 -12
  18. package/packages/ascii-renderer/src/draw-arrows.ts +79 -35
  19. package/packages/ascii-renderer/src/sequence.ts +42 -8
  20. package/packages/ascii-renderer/src/shapes/hexagon.ts +7 -3
  21. package/packages/ascii-renderer/src/shapes/special.ts +12 -4
  22. package/packages/core/src/generated/mono-font-subset.ts +32 -0
  23. package/packages/core/src/text-metrics.ts +22 -0
  24. package/packages/core/src/theme.ts +29 -13
  25. package/packages/mermaid-parser/src/xychart/parser.ts +44 -1
  26. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  27. package/src/__tests__/ascii-class-box-occupancy.test.ts +82 -43
  28. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +8 -8
  29. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  30. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  31. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +56 -0
  32. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +3 -3
  33. package/src/__tests__/ascii-sequence-form-invariants.test.ts +51 -0
  34. package/src/__tests__/text-metrics.test.ts +13 -0
  35. package/src/__tests__/theme.test.ts +30 -0
  36. package/src/__tests__/workspace-package-boundaries.test.ts +2 -2
  37. package/src/__tests__/xychart-parser-categories.test.ts +78 -0
  38. package/src/cli/html-viewer.ts +5 -0
@@ -0,0 +1,32 @@
1
+ /**
2
+ * GENERATED FILE — do not edit by hand.
3
+ *
4
+ * Regenerate with `pnpm run build:mono-font`
5
+ * (scripts/build-mono-font-subset.ts), which subsets
6
+ * third_party/fonts/jetbrains-mono-nl/JetBrainsMonoNL-Regular.ttf down to
7
+ * Basic Latin + Latin-1 Supplement — the self-hosted, embeddable
8
+ * `@font-face` packages/core/src/theme.ts's `buildStyleBlock()` inlines
9
+ * into an SVG's own `<style>` for its `.mono` rule (class-diagram method
10
+ * signatures, ER-diagram attribute types), replacing a Google Fonts CDN
11
+ * `@import` this library's SVG output previously depended on by default
12
+ * (#1061). See that script's header comment for why this subset is
13
+ * deliberately narrower than the site's own (demo/components/generated/
14
+ * mono-font-subset.ts).
15
+ */
16
+
17
+ /** The self-hosted family name every consumer's font stack should list
18
+ * first — matches this subset's own `name` table entry. */
19
+ export const MONO_FONT_FAMILY = 'JetBrains Mono NL'
20
+
21
+ /** The `@font-face` rule embedding the subset as a base64 `woff2` data
22
+ * URI — no separate network request, no page-depth-relative path to get
23
+ * wrong (see the script header comment for why data-URI over a static
24
+ * asset file). */
25
+ export const MONO_FONT_FACE_CSS = `@font-face {
26
+ font-family: 'JetBrains Mono NL';
27
+ font-style: normal;
28
+ font-weight: 400;
29
+ font-display: swap;
30
+ src: url(data:font/woff2;base64,d09GMgABAAAAABvAAAwAAAAAPWAAABtwAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGkAGYACDNhEICuRsz2ELgyYAATYCJAOGPgQgBYREByAbWjAjEcHGARCEduZRlMfRhcj+eWAbMx8MXsFY1AMYgxQLh2GYMVuWSbQZOjzvQhHrXnbH28KzVPg3QpJZomrI6lliLI+KSSeVPYryGIWmPDYrgaI8OLLE8fIV9/c6y/e+ZNB7khbIPiKu+INtefkIygBJXcZuFw+Bd3fmquSqTJ+0GZ7fZg+wWJs7K0AUEQHJD5//iQ+S309/SlLBBtTtTnc7t3bhLlxduEx3nbtd9Lz0omqTc5mf7DQLP00WgKwmeiAKtX1uOXD1m1fn56okB8lB6wJO7Z8Opg7bqe/enmXs3EmWrFiWq4A+OTnKTz74OFCwi3by/3t2iVFFoK29qd2IthumsRuv5Xn6e1P/ycxLsdVgFa8K29qdH9DYWoB6t0RxwVKIsolbF+tMMrFCOoWXa82cBTkvrXqlGUCkBSmWlle2L8Cg7fNXktAIFKNoUCxjQbGaHYUWR7FZBsVRWY/ipJrCrkymZYO6nIcBR5tZcoafBSsL9MHZOmiHe0D1qMY169//GZD7sGUAoFx/yQfatPi2mSk5ooSsSbQx17Jy+Db1SxKE8aTWuFYD7VPayMwe5T74XysImHYWcGebWTc76pJ7HHCbGbucsMV+m91iqylnnXbGDnejcBCXr0KRYiXKVahUpVotlmZsLThaCbQTEpHY61b7XHSH50ihVDQwRiZmFnZOLm4eXmFRMXE9EgYMGjIsY7eH7HHBIdsddswRxz3sPs+634g7nXe7B7zgQeess97z5txr2mWTRt1low022SmbYEmwxSWxcsuVyldmpeuUWmoAhjr1GjU4iYmPqw2PWJNuCjIQJTkYQs1AR6+DDa4TQYsU5OMXEnBKl35JvfqkRKRRYGuWAzwD5AHwBgy9AuPzAHQsA6g2F22ArcaiJK4FLOGUTfjczAOi09DoW3devO+kaxMaMa8gmnNPE88T2D03jpQesXjo+WQRF27Tt5wdHcEoo7TDA4JxlJjjLGRDj8UmgjlF4RiYpa9Nr1NqlzlOnwlLqNR3aYDrlnrxyvJlfnUiwY7vVPolzqhkudc/tZz68DiHq/w+E/R2Bropp6LvsiryPX9oFRUdp8BvtcqR7CIEljGybitd4ruc7rukT/USp5/rkpd79y6sKFzS297oZdW3bV9AVbVqbEVWvNXyckZjXtK24167T7KvPySRHH0TsyHX4Hml8T5eBXzPCH+oy/YcP2IVSWmpc4SYu18gsbiXXbG/z+0x13MtaZIB68OiuoUN4HoPCFiBkC7evAOLOMZFDiFbAtd4cUuU46LESBDZpLWxINCKJI9F1FJgIEL+yPhsWiHIncwpme575DUoqWOTewANVUiYELRy/RO3pFfZItSupWVguD7kgQsIROA4PoueKBIyhIxjLNhimywSjBwj9IBsBpHtmAsdCvuxKeBMlnKiv2Y8kNlAyI1kz+BMSP9L+EnW7nZPJ1nXeWUqJwYLvTVsWDjXnC9fdrmY5Tmajwpnsl4El4XXsg1kznl9lEhYzay/WxAZZCqcsVEXnknVgaxQOfWpbhIXTCHqqHHqTNPdLCMpbjZYPaRTVaWcd/AGN0oQXW8dZw/x9B45OOD8Zlrtvv31r989wDzV7Re9OlifPAc5Vp2FqvZSb+NPhd5drG7JpyRbIS3b+aP99Q5uWjBuc9GDzR6ogjm7ZoyUgtGHepyrE9JBzNDVHQhdFpIxBJv3gSLZ98DXrgEIQshFnGarRL+9LOBhmQAWBiRNG29iEAHFGCQfEcER6/ERIw8A/t02Sq0rOixukCY4wmD+muTjZAhCQI4zkPWziP1rZCRyXUAjDcUDMNPcVh2nJRrNCw1I9eiOsNIIBe0a7gJw1SX69+e8n0n/If5h4GzQw/X/i107vaGDjEzRGfdx+kiNxz1AHPF7LqUkY41tnmBQ2BuZu8HVCKMR2gqB1yY53axDZkje4gE7kWbfb1oXKEbzO8zQWRH9dbmghC1bONNchvbTxSwvAehKLQtMuBCcptpZxrHVzKnbWgkdR/uICkx8z0wlGCTaE5HtZaO3OoQtaP0YEEZZlUOToc3G6E4/T1Zc9SZpt0VUMCDcVV3jH+1/ZMjlzzIEA4RkEmJ5wm5Yxg0JJmleMuYIHujwjnTO9L5IJtYfnyQ7QW8TIdRyu6MbhFOZdOvHjK+adNvnNrxXSmXq2GZObgLrtU0Fi9sZ68KCdoriWgKAhR3jcT0LMetcKHpIzLeBWwmdlVuj862GEKqYoUYXYI7J1aB0GDw7kQZT6GLUg1oHwvbQPSTRMI8yBl88Q8cJdO8IUnpDuuFBbozvlV0RXqMeuriJ1wMjC6aVlgeuagm0NzZPDjhTtWdmdO6CNd7mwhgpNrNJgzEOhtJgnzcXy1Z1g+qWMd0L8R9+Qsai1gJky00glgdozpR8yAyQBzSy60cQkhmSYVM7Ixm3UJJIGmV400psSVeuloAjOWB6Bs3kEYIuErS3mIG17CGtXHpqOgSgw3vBJ0oAZ665B5LZxtbQWEAYYb58CGFfBZzZdB7NHGmZQUqUv7SquY7z8jlZCNFB/vj1XGv6Bo3JgS6BCrFirdTDI8uRoeHfCgajzR5t9PHhMG3UscnQ3VFEhjCcOjEbbpQ8qg85RdNK6t1qrw6SYkP4MZ3ldKA0GSswd61+4R8bf+afGmeDuoGnbOxo61qqsEem1mi3xq5WVTfmCLJ8GA/S3UP0TRt91SEmu/gAudfkEfZG7X76sFz+XAw2ByxOTOk9CB8y/YHyKw1fvRQHK3HYFA/QA6XtzdqwVG4+d7PRrKS7g005zjSigW8fMnRxRfu9aeKG35+/Mgm5kE+1rjrQrG8qhS251jwORdt6nbozX9w0a+QlEJZTqql9cmMe5e/qatQcukutYVOwThBJmBoZHRJ1ACu1L/xlxz1VM57xn2C0Ssi7q5dnT2M09YWVe2kG6nzi+zjEA4OMZ0KrBf2Aw0eRMDXHwi8HZq3R65RZI9VqnWpc9oTgeSKQDVjrL9l8olXT1hzWhI2wqwmboeFovbK1MbSRkbPlDM3o8mITq5N7hVOIWwmeK6rT+8XT/W0CCALOUm73b3XLdyoTlBsMxsO0V8SD8Y9tOJqECYjqcq0DNobGQHPOSU/SgbXd6RhyRU1aH1Us0oxrerqYAx7oyzUVfA/pBXeZLm4Lna76lVVrleOc0PBMVfeW6esxHFyOU9ZUVYRDBZPaFDhmFh4KrkmM1GnXubN05M22m2OZMVjSlo8q2hsiZmIXZL65XOsiajVdjrO4VNa0UgkP1GrDWIVVskQjIAHiT6YxUHFq+lWC44HFIq/RuhSI0hHa2qxZbTUs0VGkk2Cdi9YWsb/bQlhX6J4Ou03T3EDnVIuQzta2ZS4O+3nC+m/v/Kz3mvhZs/rZsPxZ1/z9rJh0L/jpbhCMf/R+q0C9m+t/XctXffrIe+5/VdNB8er6/76rfeD6lfumU3J74wvC4htTEuCffzk6FPFIMM9W2mGE12BqDaT6ohsOjspUCqEQVcjGJONKvUom1xuU40BAj6YHw4awsCioh0IkjKggSKWCDTCgCSBFMmT9iIBXXxBjJUeX5if4KxH9ciJBMD+GMI2y+ea0rawObRMB3/zDurhQElLXqyVhvB5d2BpRGTpU9Wh3tUaAz0oYCYT6O/+xz9gHovOHCePHLJyVZSRAcP4s4fxZTsg0gGu6hsLGhLpVDUdvJb6/x/xG/zwp+VpC9lX8axzky28T3UZsBDVYSk+MjcRJNaZHEH0vqfPIvPiqEeCbJ2V9B6ThS8FHVnefwBOdYHKe3GwggH5+gw/qgjbMuzw86prsmwQjqyxDg7kd2eciHkDOr/pg6LosPRVv2OdaN3t/aCVNxdgH2PTw8HBSlMyscJlJiGshHIJ9BmApUwKiBwQajRv6FIbw8GDYN7TIrh3PExpPrs+RZOllUsWH2z7SnZOr8yqpm67e9AfwzX8TX5UJh/W1i+9qmdgFYXoFjOmgXYCcJ2f2qrp4Qo/cO9IX2Xspa68MRcQSlVK6FxyZi5/Mms6OjWbCSFjs2O4+qKURfeaxhb8grEOBwJgegtCBEYX3uQX++QU01C70Kw3GxKe3wpEdwtWiTZslakwGqbWSzYBLT04MhkmFWYPAHTpZSyvy4ECVptASx2QCl79NAQfahE4pyVfDQiEK81nHk8PV+kJ8wNS1ehhc2zwmviae2wzY9HjvUKiALJDLtAo5pJXHz2E4OQi+f/Cqb/zHJ1e4RKIC95NjP/gfugp6nVdtkvI2GpZVlIO16cpDaLRFLlZUlPXay8qt7qvDKINHQ/LzaQiKMQDr1eFnunuG3l7LJA3Mtam3I8+EXh3rHv9iLdNoZKyd+BJcy3/3ua7t7PteW7ny4/tK5id/PnCQeo6GuCv/w7UR4B+6igTMAo6xs1l4kt070v5bPpmFDNh90WTQeb0nSk05ZG1GlVkpNs+2RFtanwIX6Gin32iwkepOY5O8ptpZY0UNuJynbtPpkLZW1OrWTX+QZJlqanQ1dtTaibIR3tiYlpcxbzU1mmuq/Y27LbX9jUlrta2FvQ4Y6bBVHqMkyHlyljJOyeAK2BbqDx4IhPqHzNJ/1LOav6ViRAvPwIi2P4MxbSY8pgxmYlQX2YBKJBAmX3s3jKkhCSqtJ6muTAy8QoBjYuyHC9KoT090MZ0I9wcOBEP9NhhXZCiU8VlynkxQYlZ5tVmiNAR1r7VE+rdmVv0PaKZrusWSmFrdHg6IlC1HDiyT/noJIQ8a8TGxLtzz6C0aPNFmHnq0fTuCSXviUq0VMVlRlPsQuPACNj00mume7MYPegjtEgj9SNjdrtBiMj7C7CLzNGn8LSOoQFWYQqHCUPD2llliNo12GvQHiAOILL1ALGwBs3PEfq6dw6d4aEPmdsgR6g1kAqF+B2QWD1GDHqYt3uVZ0WB2KuHCqGRCAngc+FxWUuJGSGkLxdwpMgLDKpEE0SIB4CCenDxH0N+4nwCeOWIc1qgVd2cRgdzQ2HAMhObJ3Ym9H3efz36gOnHXr72/gfEOpx377hHiOmWds+YkAeRMSwdVs5TZUHqIEqEzhbBFedB+qPb3ivLfag8bpkx5xemBj0kmdK8bVirZuZFMumdNIp2KRtVJrOnxPGErpEKRm5GsAsnUOwStgIMPn1fPjCI7N/E+9NpGFnS7T54m7tjC/Syfc7n69za5Sg2vWSlXaRQzxfyXG/4G3rk4roBtob5ghDezKqK0Ax37o7UdufPE+jZp21rrkRaOfcdYOjDnNqJFraPBcB+R2Pt79Y8285n9CzmJ/AWGmGSCGjUqEKcvHuSS3FUjkEKNBEBxri9KmEgmyWvh97J0SN7u4jGFSakPd7pa8KhF6a1gNLW0Shtkuu2FU4XT5qjdB3wlinZfQKBU+lvbO8UQ4pHT5s3dernYGxaoZEQbbNLJZDqdU4q3ClWo8PkVqbSSEhDRdFpU6juJO/eT+xeIBXUFtXyWAM304PBQKEAG5EpMLEEw+YJnGeVPDzBCY8Oz/7Yh702XLOjdJljtYVjE6xh2sEIj1Kwj26IwWj5hO0O6zuBnROAboIEVYI0RvQHwnyi7Z1Gy70TyeXHj81h9z+LHS5Mjc8kX9ZJvrx8/kuDCHP7mi7TQTbb0u1zuu5FVtmD+VUCSgtUtxC5SulpLgPzHOYHGxgkeWbmyuapMWMvXPs8Tftj8JFdM2891NM5Vl5FiTkkpYD/U/hUX537Vni0VVwoqcTXgCQDf3DwZgCGtWAJr4QDJdCdoDX4Y/q8h9EcC2PRZQ4EKu8LtKpNcb+KiQj/miA/6uXxpRfmS8vYNr1gKnohnZE/f0n34+hs2HySA0/YzbTnb1SzXYJAC08MkNTyWjsRGh8PtkrRG99QVEwFq6LGRoRAp12rlhPPRWsFqufhwC3Gz/dwDnpzuAS8/NDoEpmcN2+mn48WPNf54kTwMGFUeAEeu1sqejWwgeJRHRmP9AzHO9mVsoWNJGdSjbauPfn68NTonqX2MV412SUQRFQYleyDMiuIOrRZ3oMULBR6hbSoZJZ97xU7ZANh0bUIo61JqLRF0EW2b2i2TCp1dbUokOMsxmV/dRkMXhYta5bG3R6g9J1GjQqGWJWLNiGBWUQCxGuQKC4G0Qv4NgSfnJLV3ETXKgEQYUemgRALS6hKm7WnAjs/jbRPotGabvSONdd2YRsH+CfM9d7RlSuq/rq9b3KAru0vT0NSiqdvR3HAHPDX6HgrYq3SeTpWOUcViVTEYlawmbcr1Jlqpz3jv0RtlKk8nyF556zX+6LeiohNvYlq5o7A394lSx59XF5AxjbaRXcmvbBvkhRqFSCJrnF99eaNPsOjAbSX9/EqbhYpfbHxMG7Fz+MRZ7caHVqQ98lfB3p0yrZQrLzVXV5lLi/tSWSXF4dv5b4vt6NNjzVyt9MrO813ufGw643I94wqjfTD1l8Grd4l4fVoeYO9v2l1Xt7sJ75915KQMavc2QzUna4oife7rkXK71LcWU5PtluJ3y/LLnKrKj4aVBWWg4uK5Hq+1Shcq1fHFVGIxFZhvneQjBgyCDBg64ZjA4u2SoErKJe3CVFBe3bVcrPipdEJg3JKFcc6eLlTG9QiyFs2IIc8HOB/XtCG+x58UzH1Ykmwst/Q2Zq/u78AKJ+wThc6OiURxr4FMX8mHc4I3ziO+k/fFf778/vvLLgAb81UXGq6dC6F9M8MSPvvV+dLhFczGvllg6C8RnA7qIDiz9346tqP69sv7Lu3LUKpy/R+w8cetlyz8icn7P0iNuZBlftvGsVyyWggRFlyM9wVc7nd3O3Msyw5p1qBpPI2u0eRo6+12ujk4hyuTkWtAMUNO46DmjhT5jGuVQaDfViaT1x3aLLWwGqXazTPydLORA7mZLR0Z8GXOohP4BPJI7KRbDIYCAi/2n/4N379k/01wGy6eyG+nwVQtjuAAihg/MoLLjY/+8MgPPU9+9+RPjjyZLIL8QO3Hx75PXHAHSnbfFwDO5LLjOWywYmq9nR7p88x4OpT+aVkvX2fPiUWonSRH/PTM02IOSe2MRew56/bZ2vnI/bfez20H3w85ne8p4G/t9vcJG1AiKxyO712ufyDl/zbbiwSxoKjO+8JuB0pJh1Eq7TBKGBfZTCb7IiiYWufPTfZQHCTvjDPnmKEE7+3Aw9223PXydURupBt0ktxFz/w5ri+x9huInl5X3ro9d3O46JXP7snesyTX29bSijw8B4zRVBGrtFzXgxuvviJhOtKRcCQVBe4gH4XEsj+F9anq7+/HD9WXsOv0DRMpHEBjxifLI8zet3bdijx79taNP7mKXAWuBbRH8UefdH+5YP+m+m114/a67dump+sjNDDBRgXYlxnv1NW9w2A+l17uc98dNk4bn/3hygfse/Gz+Jkf0vsH7IhLuJu1ZPimylzhHvLMW1FARRDF9Fa6Im81wLAHFvQ9toUHMHDnfZMQPv0a0P6PQTpMxigtOXq1iCvGHOsSTAfdPSZgtV3cYD+/oXOQkn6qSfrGuhQFvO9EDPql6NLORnYAdi/v/OmqId81+npVfTVaXV/1WELfOWLwqZQxCdeUmqq6p0x15X/JZXVAM+eYwDSTbvumnfvWpCYnU8stDtLQ4XTpMgYtjjQFp6iYw+7ByUi6xx5NpvwDxjoOc5o90nInk3lXS4Y9Dd7Kc8fibinuCRppc0JMrAu6zGKC22qXuvmWFsZ3zMannviHHI7uODf9UKTrxiOlBIuBS1MCdPyuKm5SIBWcBP/Pfe3o8Ztwc4/Pge8gd+A7QMFeb5LQBevGuHz+dlZD3UrvsrfVAkYHSybTiaiQ2CgxxJwuvjVmzlEFFQWN/FYjs1bx21gJtgQPKWRqzcdUtKsJEVgSpC/Lfo8YFdbfzehYcu+NAuaxYsDuN6UgZa9+Rq/sTUEm8/AJGL4tntzTynNIZ6Q8u4crlysaMWKOgAexSzRw+2R750gk6smHloOyclA+qW4ZKEfbX8cts7foB/G9kku+f56/AinRl23a+9HbENmUaNsjorL3ylq1CLAfVxM4ghJWtVqKKLIqVGLmRyzWApPxUzHrj+D//tPk6abHx8lxD5eV4KHNlbWVVXWVlXVVU/Qf7AL25trOaFOitpZ4jjqBan1qKto/BQbXJ6ZCA1Pgo/Vbp2xTU5FOs9UM2EERCgsEKkSF114OKioo5eUU99UB+GxLlX1/Jr+qvgqt4uSzTvZIAbM7jIgRbP3zdLy6gNT0p+hgvc4zbGGRoNmWhStpzeQIVYXNZL7/O5rLIlzyxeaYgAIAyAUWNQzOsm2qGzayTaol7pQLF8QyeaIPDIiGATaEKRIvsQMugUvgkni15nitBLgWroVr4Vq4FW6FW+HWuAhS0sNTavdZtkr1wyibUG1xrVzoIDB5qfBkLOoGGCFIWVJWge0wUDalDsfV8jgCQbBwSEgdj3usgr2wF/bCXuGFlBtOwAk4ASfiwyLRYS4tdyH8OUuGRRz2bFaWD1wNu/vGOeqdHx4refhUHpVnC89n2/n7+2FtJvN9/89F5G0AuOhdawXKP2Bd40fWdfzQpfP6e5mo3vPoLFSJj/ML9QVAjv78NXf7XYDzqIfOoYpcwf/Po7NorR6oLeqv/rXy4SE6FE1jCchlkXUR1A7MHEAt+D3Ug0vxYTPlSRjnXerILH+eKx5ycFz8UD6q6ehI1I6rDr7bgW/Jwt9z1LbU4duk7MzZPF/z/M2UfwQTJmpL3u2gw+HakAyNvoRqrnl1ue78o/rKlSbE3cpQgDp5aQzG+4PL5f/1Lrf+Afj6if8Ec6Vv/DP5/1D9Z3cBugmAQPcfnwd1erB8LfgM8doZB0ek7pevin+7D27RXlHBuHkfS0A4oibpQ8JebvVxYyQ8fpEivhDhK8Iq9Rz7Vau+69PJBXvMDnfF9jzHAUKqyItvL7v62GlxgaE4rp/CdZNIdfjHPkY6537+4JgpGDBCEFFhM24xdTcbzHvNVVfKgsmkaRrUxwjuTxonKGIm2xC5Kgw7/VUAN3b0ouqhEg9F/Rv0+i7WNrAuw0kpjTkcZGZ87WMVwB/DdgbTqvlK+2iETDiYL20eXxFpXm66pmu6lvs/CvCc+wywyupZ6VijjHP7Y50OgKc05FtoUMSW4Pt3y7LApS3bJJNbMVIrs+Jwf4FDMRkqKSEJA9LMBh3SJ2dhwkGI6TaiT0iKU0xKWoJ+/TX4Wgm0aQd5/oyMuJAREoP/k7YyIT/LKJ5WEk20zy7GIRQ9qrAm7YWa3K7Jt6YmrPHcNFCBHAifTVS3pYbykNWHyxO69bhj8Ql/ILLsb4nYhF0o41JgJS/ZEQkc9ZPuegalpIlkRNaQt3MZLq5uCap+ZWlYq0h0VP24evYJ1/16E5rXbdc24AMC) format('woff2');
31
+ unicode-range: U+0020-007E, U+00A0-00FF;
32
+ }`
@@ -273,6 +273,27 @@ function isGeometricShapesTextDefault(code: number): boolean {
273
273
  return code >= 0x25a0 && code <= 0x25ff
274
274
  }
275
275
 
276
+ /**
277
+ * Arrows-block glyphs that carry Extended_Pictographic (emoji-capable) but
278
+ * no default Emoji_Presentation, and render as a single narrow column in
279
+ * every real terminal — the same misclassification the Geometric Shapes
280
+ * block above hits. Covers:
281
+ *
282
+ * - ↖ U+2196, ↗ U+2197, ↘ U+2198, ↙ U+2199 — drawn by the ASCII renderer
283
+ * itself for diagonal edge-routing arrowheads
284
+ * (packages/ascii-renderer/src/draw-arrows.ts's `drawArrowHead`) in place
285
+ * of the filled triangles (◢◣◤◥) JetBrains Mono NL has no glyph for at
286
+ * all — see issue #1062.
287
+ * - ↔ U+2194, ↕ U+2195, ↩ U+21A9, ↪ U+21AA — not drawn by the renderer
288
+ * itself, but reachable if a diagram author types one into node/edge/note
289
+ * label text, which passes through to the ASCII grid verbatim.
290
+ */
291
+ function isArrowsBlockTextDefault(code: number): boolean {
292
+ return (
293
+ (code >= 0x2194 && code <= 0x2199) || code === 0x21a9 || code === 0x21aa
294
+ )
295
+ }
296
+
276
297
  /**
277
298
  * Check if a character is an emoji (fullwidth)
278
299
  */
@@ -280,6 +301,7 @@ function isEmoji(char: string): boolean {
280
301
  if (EMOJI_PRESENTATION_REGEX.test(char)) return true
281
302
  const code = char.codePointAt(0)
282
303
  if (code !== undefined && isGeometricShapesTextDefault(code)) return false
304
+ if (code !== undefined && isArrowsBlockTextDefault(code)) return false
283
305
  return EXTENDED_PICTOGRAPHIC_REGEX.test(char)
284
306
  }
285
307
 
@@ -13,6 +13,10 @@
13
13
 
14
14
  import { escapeXml, escapeAttr } from './multiline-utils.ts'
15
15
  import { parseHexColor } from './color-utils.ts'
16
+ import {
17
+ MONO_FONT_FAMILY as SVG_MONO_FONT_FAMILY,
18
+ MONO_FONT_FACE_CSS as SVG_MONO_FONT_FACE_CSS,
19
+ } from './generated/mono-font-subset.ts'
16
20
 
17
21
  // ============================================================================
18
22
  // Types
@@ -412,6 +416,21 @@ export function styleOpenTag(nonce?: string): string {
412
416
  *
413
417
  * Any other value is treated as a literal font name: sanitized and quoted
414
418
  * exactly as before.
419
+ *
420
+ * `hasMonoFont`'s `.mono` rule (class-diagram method signatures, ER-diagram
421
+ * attribute types) never reaches out to Google Fonts at all — it used to
422
+ * `@import` 'JetBrains Mono' from Google's CDN, a third-party network
423
+ * dependency a published library's default SVG output had no business
424
+ * making (#1061, filed as a follow-up from #1059's ASCII-side self-hosting
425
+ * fix). It now embeds {@link SVG_MONO_FONT_FACE_CSS} — a small, subsetted
426
+ * (Basic Latin + Latin-1 Supplement), self-hosted `@font-face` as a base64
427
+ * `woff2` data URI, generated by scripts/build-mono-font-subset.ts into
428
+ * ./generated/mono-font-subset.ts — directly in the SVG's own `<style>`
429
+ * block. Unlike `font` above, this isn't caller-configurable: it's the same
430
+ * embed for every consumer, on by default, with no network request either
431
+ * way — a strict improvement over the dead-or-alive CDN fetch it replaces,
432
+ * and the only way a *standalone* SVG (no surrounding host page providing
433
+ * its own copy of the font) renders `.mono` text correctly out of the box.
415
434
  */
416
435
  export function buildStyleBlock(
417
436
  font: string,
@@ -426,18 +445,11 @@ export function buildStyleBlock(
426
445
  const safeFont = isVarReference ? font.trim() : font.replace(/[<>{};'"]/g, '')
427
446
  const skipImport = isVarReference || isFontStackOrGenericFamily(font)
428
447
 
429
- const fontImports = [
430
- ...(skipImport
431
- ? []
432
- : [
433
- `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(safeFont)}:wght@400;500;600;700&amp;display=swap');`,
434
- ]),
435
- ...(hasMonoFont
436
- ? [
437
- `@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&amp;display=swap');`,
438
- ]
439
- : []),
440
- ]
448
+ const fontImports = skipImport
449
+ ? []
450
+ : [
451
+ `@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(safeFont)}:wght@400;500;600;700&amp;display=swap');`,
452
+ ]
441
453
 
442
454
  // Derived CSS variables: use override if set, else mix from bg+fg.
443
455
  // The --_ prefix signals "private/derived" — not meant for external override.
@@ -459,10 +471,14 @@ export function buildStyleBlock(
459
471
  return [
460
472
  styleOpenTag(nonce),
461
473
  ...(fontImports.length > 0 ? [` ${fontImports.join('\n ')}`] : []),
474
+ // Self-hosted, embedded @font-face for `.mono` text — see the doc
475
+ // comment above for why this never goes through the CDN-import/skip
476
+ // logic `font` above does.
477
+ ...(hasMonoFont ? [` ${SVG_MONO_FONT_FACE_CSS}`] : []),
462
478
  ` text { font-family: ${isVarReference ? safeFont : `'${safeFont}'`}, system-ui, sans-serif; }`,
463
479
  ...(hasMonoFont
464
480
  ? [
465
- ` .mono { font-family: 'JetBrains Mono', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`,
481
+ ` .mono { font-family: '${SVG_MONO_FONT_FAMILY}', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`,
466
482
  ]
467
483
  : []),
468
484
  ` svg {${derivedVars}`,
@@ -48,7 +48,9 @@ export function parseXYChart(lines: Statement[]): XYChart {
48
48
  const xCatMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/)
49
49
  if (xCatMatch) {
50
50
  if (xCatMatch[1]) xAxis.title = xCatMatch[1]
51
- xAxis.categories = xCatMatch[2]!.split(',').map((s) => s.trim())
51
+ xAxis.categories = splitCategoryList(xCatMatch[2]!).map((s) =>
52
+ unquote(s.trim()),
53
+ )
52
54
  continue
53
55
  }
54
56
 
@@ -148,6 +150,47 @@ export function parseXYChart(lines: Statement[]): XYChart {
148
150
  return { title, horizontal, xAxis, yAxis, series }
149
151
  }
150
152
 
153
+ /**
154
+ * Split a categorical x-axis bracket's comma-separated contents into raw
155
+ * item strings, treating a comma *inside* a double-quoted item as literal
156
+ * text rather than a delimiter. A naive `.split(',')` would break
157
+ * `["Jan, Feb", "Mar"]` into three pieces instead of two — quoting a value
158
+ * that itself contains a comma is exactly the case quoting exists for. Each
159
+ * returned raw item is still trimmed and unquoted by the caller.
160
+ */
161
+ function splitCategoryList(str: string): string[] {
162
+ const items: string[] = []
163
+ let current = ''
164
+ let inQuotes = false
165
+ for (const char of str) {
166
+ if (char === '"') {
167
+ inQuotes = !inQuotes
168
+ current += char
169
+ } else if (char === ',' && !inQuotes) {
170
+ items.push(current)
171
+ current = ''
172
+ } else {
173
+ current += char
174
+ }
175
+ }
176
+ items.push(current)
177
+ return items
178
+ }
179
+
180
+ /**
181
+ * Strip one matching pair of leading/trailing double quotes from a category
182
+ * item, if present — mirroring how an axis *title* capture group already
183
+ * unquotes via its regex. `x-axis [A, B, C]` items are only split and
184
+ * trimmed, so a quoted item like `"CLI output / logs"` previously kept its
185
+ * literal quote characters in the rendered label. See issue #1087.
186
+ */
187
+ function unquote(value: string): string {
188
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
189
+ return value.slice(1, -1)
190
+ }
191
+ return value
192
+ }
193
+
151
194
  /**
152
195
  * Parse a `bar`/`line` series' comma-separated bracket contents into
153
196
  * numbers, throwing on any element that isn't a valid number instead of
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Regression tests for #1083: the last edge in a mixed-style fan-out got a
3
+ * bogus diagonal arrowhead (↘) instead of a straight one (▼), even though
4
+ * its line was perfectly vertical with zero horizontal drift.
5
+ *
6
+ * Root cause: that edge's route fell through to determinePath's Case-4
7
+ * direct fallback (see #418), producing a single non-axis-aligned grid
8
+ * segment. draw-lines.ts's drawLine draws that as an L (horizontal run,
9
+ * then vertical run) folded into one `linesDrawn` entry. drawArrowHead used
10
+ * to derive direction from that entry's *first* and *last* point, which
11
+ * spans both legs of the L and reads as diagonal — even when the actual
12
+ * final approach into the arrowhead is a plain vertical step. Fixed by
13
+ * deriving direction from the last two points (the final step) instead.
14
+ */
15
+ import { describe, it, expect } from 'vitest'
16
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
17
+
18
+ const ARROWHEAD_CHARS = '▼▲◄►↖↗↘↙'
19
+
20
+ function arrowCharsInLastRowBeforeTargets(source: string): string[] {
21
+ const lines = renderMermaidASCII(source, { colorMode: 'none' }).split('\n')
22
+ const arrowRow = lines.find((l) =>
23
+ [...l].some((ch) => ARROWHEAD_CHARS.includes(ch)),
24
+ )!
25
+ return [...arrowRow].filter((ch) => ARROWHEAD_CHARS.includes(ch))
26
+ }
27
+
28
+ describe('arrowhead direction on a Case-4 direct-fallback (diagonal) path', () => {
29
+ it('renders every arrowhead as ▼ when thick is the last mixed-style edge', () => {
30
+ const source = `graph TD
31
+ A[Source] -->|solid| B[Target 1]
32
+ A -.->|dotted| C[Target 2]
33
+ A ==>|thick| D[Target 3]`
34
+
35
+ const arrows = arrowCharsInLastRowBeforeTargets(source)
36
+ expect(arrows).toEqual(['▼', '▼', '▼'])
37
+ })
38
+
39
+ it('renders every arrowhead as ▼ regardless of which style is last', () => {
40
+ // Same three styles, reordered so `solid` (not `thick`) is the one that
41
+ // falls through to the Case-4 fallback — the bug followed *position*,
42
+ // not the specific style.
43
+ const source = `graph TD
44
+ A[Source] ==>|thick| B[Target 1]
45
+ A -.->|dotted| C[Target 2]
46
+ A -->|solid| D[Target 3]`
47
+
48
+ const arrows = arrowCharsInLastRowBeforeTargets(source)
49
+ expect(arrows).toEqual(['▼', '▼', '▼'])
50
+ })
51
+ })
@@ -137,70 +137,109 @@ ${manyAttrs('b', 6)}
137
137
  // pair via the level-BFS (Root has no parent, Kid is its level-1 child),
138
138
  // placed on a different row from the Alpha/Mid/Charlie cycle.
139
139
  //
140
- // Two orderings of the same Root/Kid pair are compared: declaring Root
141
- // first places Kid's column off to the side, outside the Alpha-to-Charlie
142
- // detour span; declaring Root between Alpha and Mid places Kid's column
143
- // so it overlaps Alpha's own range instead (confirmed below via
144
- // `findBoxRect`). If the different-row filter worked, both orderings
140
+ // Two variants of the same Root/Kid pair are compared: a narrow `Kid`
141
+ // never reaches the cycle's column span (baseline); a `Kid` wide enough
142
+ // to need real space, with `Root` declared right after `Alpha`, overlaps
143
+ // Alpha's (and Mid's) column range instead (confirmed below via
144
+ // `findBoxRect`). If the different-row filter worked, both variants
145
145
  // render the cycle identically — Root/Kid's actual row is irrelevant to
146
- // it either way. A test that only used the non-overlapping ordering would
146
+ // it either way. A test that only used the non-overlapping variant would
147
147
  // pass even with the filter deleted (verified: an earlier version of this
148
148
  // test did exactly that, and CodeRabbit correctly flagged it as not
149
- // actually exercising the filter — see the PR discussion). Sabotage-
150
- // verified: deleting the filter leaves both orderings' `+ bN: String`
151
- // lines intact but makes the overlapping ordering one row taller than the
152
- // non-overlapping one (19 vs. 18), which the equality assertion below
149
+ // actually exercising the filter — see the PR discussion).
150
+ //
151
+ // Declaration order alone no longer controls Kid's column (issue #971:
152
+ // `Kid`, as the sole occupant of its level with exactly one qualifying
153
+ // parent, now aligns under Root's own — already-placed — column instead
154
+ // of always landing at the leftmost slot). This test used to force the
155
+ // overlapping/non-overlapping split by moving `Root`/`Kid`'s declaration
156
+ // point around the cycle alone; that no longer produces reliably
157
+ // different geometry, since Kid's position now tracks wherever Root
158
+ // ends up rather than its own declaration slot. Widening `Kid` (so its
159
+ // box needs more room than the gap next to Root provides) is what
160
+ // reliably produces a real overlap now, confirmed empirically against
161
+ // the current renderer rather than assumed.
162
+ //
163
+ // Sabotage-verified: deleting the filter leaves both variants' `+ bN:
164
+ // String` lines intact but makes the overlapping variant one row taller
165
+ // than the baseline (19 vs. 18), which the equality assertion below
153
166
  // catches.
154
167
  it('3-node cycle, unrelated Root->Kid pair on a different level: obstruction search ignores it regardless of whether it overlaps the detour span', () => {
155
- function withRootKid(rootDeclaredBetweenAlphaAndMid: boolean): string {
156
- const rootAndKid = ` class Root
168
+ // Baseline: Root/Kid declared after the whole cycle, Kid narrow enough
169
+ // to never need more room than the gap next to Root provides.
170
+ const nonOverlapping = renderMermaidASCII(
171
+ `classDiagram
172
+ class Alpha
173
+ class Mid {
174
+ ${manyAttrs('b', 6)}
175
+ }
176
+ class Charlie
177
+ class Root
157
178
  class Kid
158
- `
159
- return `classDiagram
160
- ${rootDeclaredBetweenAlphaAndMid ? '' : rootAndKid} class Alpha
161
- ${rootDeclaredBetweenAlphaAndMid ? rootAndKid : ''} class Mid {
179
+ Alpha --> Mid
180
+ Mid --> Charlie
181
+ Charlie --> Alpha
182
+ Root --> Kid`,
183
+ { useAscii: true },
184
+ )
185
+ // Overlapping: Root declared immediately after Alpha, Kid wide enough
186
+ // that centering it under Root's (narrow) column reaches into Alpha's.
187
+ const overlapping = renderMermaidASCII(
188
+ `classDiagram
189
+ class Alpha
190
+ class Root
191
+ class VeryLongKidName
192
+ class Mid {
162
193
  ${manyAttrs('b', 6)}
163
194
  }
164
195
  class Charlie
165
196
  Alpha --> Mid
166
197
  Mid --> Charlie
167
198
  Charlie --> Alpha
168
- Root --> Kid`
169
- }
170
-
171
- const nonOverlapping = renderMermaidASCII(withRootKid(false), {
172
- useAscii: true,
173
- })
174
- const overlapping = renderMermaidASCII(withRootKid(true), {
175
- useAscii: true,
176
- })
199
+ Root --> VeryLongKidName`,
200
+ { useAscii: true },
201
+ )
177
202
 
178
203
  // Confirms the geometry this test relies on: in the "overlapping"
179
- // ordering, Kid really does sit on a different row from the cycle, and
204
+ // variant, Kid really does sit on a different row from the cycle, and
180
205
  // really does overlap Alpha's own column range (i.e. the obstruction
181
206
  // search has something to incorrectly notice if the different-row
182
- // filter is missing); in the "non-overlapping" ordering, it doesn't.
207
+ // filter is missing); in the baseline, it doesn't.
183
208
  const overlappingAlphaRect = findBoxRect(overlapping, 'Alpha')
184
- const overlappingKidRect = findBoxRect(overlapping, 'Kid')
209
+ const overlappingKidRect = findBoxRect(overlapping, 'VeryLongKidName')
185
210
  expect(overlappingKidRect.y0).toBeGreaterThan(overlappingAlphaRect.y1)
186
- expect(overlappingKidRect.x0).toBeLessThan(overlappingAlphaRect.x1)
211
+ expect(overlappingKidRect.x0).toBeLessThanOrEqual(overlappingAlphaRect.x1)
187
212
  const nonOverlappingAlphaRect = findBoxRect(nonOverlapping, 'Alpha')
188
213
  const nonOverlappingKidRect = findBoxRect(nonOverlapping, 'Kid')
189
- expect(nonOverlappingKidRect.x1).toBeLessThanOrEqual(
190
- nonOverlappingAlphaRect.x0,
191
- )
214
+ expect(
215
+ nonOverlappingKidRect.x1 < nonOverlappingAlphaRect.x0 ||
216
+ nonOverlappingKidRect.x0 > nonOverlappingAlphaRect.x1,
217
+ ).toBe(true)
192
218
 
193
- for (const ascii of [nonOverlapping, overlapping]) {
194
- expectAllPresentOnce(ascii, [
195
- '+ b0: String',
196
- '+ b1: String',
197
- '+ b2: String',
198
- '+ b3: String',
199
- '+ b4: String',
200
- '+ b5: String',
201
- ])
202
- expectNoBoxOverlap(ascii, ['Alpha', 'Mid', 'Charlie', 'Root', 'Kid'])
203
- }
219
+ expectAllPresentOnce(nonOverlapping, [
220
+ '+ b0: String',
221
+ '+ b1: String',
222
+ '+ b2: String',
223
+ '+ b3: String',
224
+ '+ b4: String',
225
+ '+ b5: String',
226
+ ])
227
+ expectNoBoxOverlap(nonOverlapping, [
228
+ 'Alpha',
229
+ 'Mid',
230
+ 'Charlie',
231
+ 'Root',
232
+ 'Kid',
233
+ ])
234
+ expectAllPresentOnce(overlapping, [
235
+ '+ b0: String',
236
+ '+ b1: String',
237
+ '+ b2: String',
238
+ '+ b3: String',
239
+ '+ b4: String',
240
+ '+ b5: String',
241
+ ])
242
+ expectNoBoxOverlap(overlapping, ['Alpha', 'Mid', 'Charlie', 'Root'])
204
243
 
205
244
  // The real check: Kid overlapping the detour span must not change the
206
245
  // cycle's own rendered height compared to Kid sitting clear of it.
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { describe, it, expect } from 'vitest'
28
28
  import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
29
+ import { findBoxRect } from './helpers/ascii-form.ts'
29
30
 
30
31
  const MVC_SOURCE = `classDiagram
31
32
  class Model {
@@ -85,17 +86,16 @@ describe('ASCII class diagram — detour-aware relationship label placement (iss
85
86
  const ascii = renderMermaidASCII(MVC_SOURCE, { colorMode: 'none' })
86
87
  const lines = ascii.split('\n')
87
88
 
88
- // Model's top border row (e.g. "┌───────────────────────────┐│") gives
89
- // its right-edge column directly. `refreshes` — the label for the
89
+ // Model's box right edge (column-based, not a literal-column-0
90
+ // assumption — issue #971 can legitimately shift Model's own column
91
+ // when it has a real single qualifying parent to align under, same as
92
+ // it does here against Controller). `refreshes` — the label for the
90
93
  // relationship that detours around Model to reach View — must land
91
- // strictly to the right of that border on every row it occupies, never
94
+ // strictly to the right of that edge on every row it occupies, never
92
95
  // overlapping Model's own footprint the way the straight-line midpoint
93
96
  // used to (issue #487).
94
- const modelTopRow = lines.findIndex(
95
- (l, i) => /^┌─+┐/.test(l) && (lines[i + 1]?.includes('Model') ?? false),
96
- )
97
- expect(modelTopRow).toBeGreaterThanOrEqual(0)
98
- const modelRightBorder = lines[modelTopRow]!.indexOf('┐')
97
+ const modelRect = findBoxRect(ascii, 'Model')
98
+ const modelRightBorder = modelRect.x1
99
99
  expect(modelRightBorder).toBeGreaterThan(0)
100
100
 
101
101
  const refreshesRow = lines.findIndex((l) => l.includes('refreshes'))
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Regression tests for issue #971 — "ASCII class-diagram layout: single-
3
+ * parent x-alignment," the first implementation stage of #964's fix
4
+ * (docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md).
5
+ *
6
+ * Before this fix, `class-diagram.ts` packed each level's classes strictly
7
+ * left-to-right in declaration order, with no reference to any parent's
8
+ * column — a level with exactly one occupant always landed at column 0,
9
+ * even when its real parent sat far to the right. This fix aligns a
10
+ * "singleton block" (no sibling at its level shares its exact parent-id
11
+ * set) with exactly one qualifying (strictly-shallower) parent under that
12
+ * parent's own box center instead. Every other shape (no parents, an
13
+ * all-same-level/rootless-cycle parent set, or more than one class sharing
14
+ * a parent set) is out of scope here — it keeps its pre-fix left-to-right
15
+ * position, tracked separately by issue #972.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest'
19
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
20
+ import { findBoxRect } from './helpers/ascii-form.ts'
21
+
22
+ describe('ASCII class diagram — single-parent x-alignment (issue #971)', () => {
23
+ it("#964's exact repro: a lone child aligns under its real parent's center, not the leftmost column", () => {
24
+ const src = `classDiagram
25
+ class Left
26
+ class TallMid {
27
+ +String b0
28
+ +String b1
29
+ +String b2
30
+ +String b3
31
+ +String b4
32
+ +String b5
33
+ }
34
+ class RightSrc
35
+ class Kid
36
+ RightSrc --> Kid`
37
+ const ascii = renderMermaidASCII(src, { useAscii: true })
38
+
39
+ const rightSrc = findBoxRect(ascii, 'RightSrc')
40
+ const kid = findBoxRect(ascii, 'Kid')
41
+ const rightSrcCenter =
42
+ rightSrc.x0 + Math.floor((rightSrc.x1 - rightSrc.x0) / 2)
43
+ const kidCenter = kid.x0 + Math.floor((kid.x1 - kid.x0) / 2)
44
+
45
+ // Kid's box center lands on (or within 1 cell of, for odd-width
46
+ // rounding) RightSrc's box center -- not at column 0, where every
47
+ // sole-occupant level used to land regardless of its real parent.
48
+ expect(Math.abs(kidCenter - rightSrcCenter)).toBeLessThanOrEqual(1)
49
+ expect(kid.x0).toBeGreaterThan(0)
50
+
51
+ // Left is the leftmost root and must stay exactly where declaration
52
+ // order has always put it -- this fix only ever moves a class that has
53
+ // a real single parent to align to.
54
+ const left = findBoxRect(ascii, 'Left')
55
+ expect(left.x0).toBe(0)
56
+ })
57
+
58
+ it('a root class with no parent at all is unaffected', () => {
59
+ const src = `classDiagram
60
+ class Lonely`
61
+ const ascii = renderMermaidASCII(src, { useAscii: true })
62
+ const lonely = findBoxRect(ascii, 'Lonely')
63
+ expect(lonely.x0).toBe(0)
64
+ })
65
+
66
+ it('a class whose only parent edges are same-level (a rootless relationship cycle) is unaffected', () => {
67
+ // A -> B -> C -> A has no external root: the level-BFS puts every
68
+ // member at level 0 together, but the `parents` map still records
69
+ // each one's in-cycle predecessor. That predecessor is same-level, not
70
+ // strictly shallower, so it doesn't qualify -- every member keeps its
71
+ // plain declaration-order position, same as before this fix.
72
+ const src = `classDiagram
73
+ class A
74
+ class B
75
+ class C
76
+ A --> B
77
+ B --> C
78
+ C --> A`
79
+ const ascii = renderMermaidASCII(src, { useAscii: true })
80
+ const a = findBoxRect(ascii, 'A')
81
+ const b = findBoxRect(ascii, 'B')
82
+ const c = findBoxRect(ascii, 'C')
83
+ expect(a.x0).toBe(0)
84
+ expect(a.x0).toBeLessThan(b.x0)
85
+ expect(b.x0).toBeLessThan(c.x0)
86
+ })
87
+
88
+ // Two children sharing the exact same single parent are a *multi-member*
89
+ // block -- explicitly out of #971's scope (see #972) -- so this sample
90
+ // must render byte-for-byte the way it always has. Chosen because
91
+ // docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md
92
+ // names this exact samples-data.ts sample ("Class: Inheritance (<|--)")
93
+ // as one it verified stays unchanged.
94
+ it('an existing catalog sample with a multi-child parent (Class: Inheritance) keeps its exact current positions', () => {
95
+ const src = `classDiagram
96
+ class Animal {
97
+ +String name
98
+ +eat() void
99
+ }
100
+ class Dog {
101
+ +String breed
102
+ +bark() void
103
+ }
104
+ class Cat {
105
+ +bool isIndoor
106
+ +meow() void
107
+ }
108
+ Animal <|-- Dog
109
+ Animal <|-- Cat`
110
+ const ascii = renderMermaidASCII(src, { useAscii: true })
111
+ const animal = findBoxRect(ascii, 'Animal')
112
+ const dog = findBoxRect(ascii, 'Dog')
113
+ const cat = findBoxRect(ascii, 'Cat')
114
+ expect(animal).toEqual({ x0: 0, x1: 17, y0: 0, y1: 6 })
115
+ expect(dog).toEqual({ x0: 0, x1: 18, y0: 10, y1: 16 })
116
+ expect(cat).toEqual({ x0: 23, x1: 42, y0: 10, y1: 16 })
117
+ })
118
+
119
+ it('two independent single-parent children at the same level both align, and compaction keeps them from overlapping', () => {
120
+ // ParentA and ParentB are both roots; KidA is ParentA's only child,
121
+ // KidB is ParentB's only child. Each is its own singleton block with
122
+ // exactly one qualifying parent, so both get a real desired center --
123
+ // this exercises the compaction pass itself (#971 must implement it
124
+ // for every block shape, not just multi-parent ones): two different
125
+ // singleton blocks can still want colliding desired positions.
126
+ const src = `classDiagram
127
+ class ParentA
128
+ class ParentB
129
+ class KidA
130
+ class KidB
131
+ ParentA --> KidA
132
+ ParentB --> KidB`
133
+ const ascii = renderMermaidASCII(src, { useAscii: true })
134
+ const kidA = findBoxRect(ascii, 'KidA')
135
+ const kidB = findBoxRect(ascii, 'KidB')
136
+ // Declaration order is preserved -- KidA never lands right of KidB.
137
+ expect(kidA.x1).toBeLessThan(kidB.x0)
138
+ })
139
+ })