@graphty/graph-io 0.3.48 → 0.3.50

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 (600) hide show
  1. package/dist/chunks/{approximations-C2riR4NT.js → approximations-BRXsll0T.js} +2 -2
  2. package/dist/chunks/{approximations-C2riR4NT.js.map → approximations-BRXsll0T.js.map} +1 -1
  3. package/dist/chunks/bends-BGXjv_U4.js +129 -0
  4. package/dist/chunks/bends-BGXjv_U4.js.map +1 -0
  5. package/dist/chunks/{block-Cu22ewuS.js → block-yl0f_qLO.js} +931 -164
  6. package/dist/chunks/block-yl0f_qLO.js.map +1 -0
  7. package/dist/chunks/choose-CczZJaX_.js +717 -0
  8. package/dist/chunks/choose-CczZJaX_.js.map +1 -0
  9. package/dist/chunks/{codes-CV_Bhcyi.js → codes-BE2xir2k.js} +6 -2
  10. package/dist/chunks/codes-BE2xir2k.js.map +1 -0
  11. package/dist/chunks/{constants-D0LqcsLJ.js → constants-CKkYLPtt.js} +2 -2
  12. package/dist/chunks/{constants-D0LqcsLJ.js.map → constants-CKkYLPtt.js.map} +1 -1
  13. package/dist/chunks/{context-cgmmOxZ2.js → context-DizmppHy.js} +5 -4
  14. package/dist/chunks/{context-cgmmOxZ2.js.map → context-DizmppHy.js.map} +1 -1
  15. package/dist/chunks/{convert-BcQ8_jMo.js → convert-Ct2zelbQ.js} +7 -2
  16. package/dist/chunks/convert-Ct2zelbQ.js.map +1 -0
  17. package/dist/chunks/{credentials-BiQsnMTb.js → credentials-CmvJXYeH.js} +67 -16
  18. package/dist/chunks/credentials-CmvJXYeH.js.map +1 -0
  19. package/dist/chunks/{cx-export-BEeF6ymo.js → cx-export-Bt6NoqTQ.js} +3 -3
  20. package/dist/chunks/{cx-export-BEeF6ymo.js.map → cx-export-Bt6NoqTQ.js.map} +1 -1
  21. package/dist/chunks/descriptor-B28U25x4.js +60 -0
  22. package/dist/chunks/descriptor-B28U25x4.js.map +1 -0
  23. package/dist/chunks/{descriptor-BJ-W0Jd-.js → descriptor-Dhez_Cew.js} +11 -2
  24. package/dist/chunks/descriptor-Dhez_Cew.js.map +1 -0
  25. package/dist/chunks/{emit-CD8xnY8s.js → emit-7Jkpg0yd.js} +1788 -133
  26. package/dist/chunks/emit-7Jkpg0yd.js.map +1 -0
  27. package/dist/chunks/{escape-DH2Egt-l.js → escape-CuCIzNMB.js} +8 -8
  28. package/dist/chunks/{escape-DH2Egt-l.js.map → escape-CuCIzNMB.js.map} +1 -1
  29. package/dist/chunks/{export-CZ7VRm4I.js → export-Cha4dRFz.js} +45 -387
  30. package/dist/chunks/export-Cha4dRFz.js.map +1 -0
  31. package/dist/chunks/{exporter-ChjL5fmP.js → exporter-1Ajud15J.js} +9 -8
  32. package/dist/chunks/{exporter-ChjL5fmP.js.map → exporter-1Ajud15J.js.map} +1 -1
  33. package/dist/chunks/{exporter-BIdaoIgh.js → exporter-B6yvL3wy.js} +7 -7
  34. package/dist/chunks/{exporter-BIdaoIgh.js.map → exporter-B6yvL3wy.js.map} +1 -1
  35. package/dist/chunks/{exporter-Dcw7Ldtq.js → exporter-BP6otMO6.js} +6 -6
  36. package/dist/chunks/{exporter-Dcw7Ldtq.js.map → exporter-BP6otMO6.js.map} +1 -1
  37. package/dist/chunks/{exporter-DtVU80Fa.js → exporter-CA5q8ROP.js} +9 -8
  38. package/dist/chunks/{exporter-DtVU80Fa.js.map → exporter-CA5q8ROP.js.map} +1 -1
  39. package/dist/chunks/{exporter-bxxjOrDZ.js → exporter-CQQZFJ2v.js} +1284 -156
  40. package/dist/chunks/exporter-CQQZFJ2v.js.map +1 -0
  41. package/dist/chunks/format-BXiAb-1h.js +408 -0
  42. package/dist/chunks/format-BXiAb-1h.js.map +1 -0
  43. package/dist/chunks/{generic-CP6YD34u.js → generic-Dc21MAdg.js} +387 -31
  44. package/dist/chunks/generic-Dc21MAdg.js.map +1 -0
  45. package/dist/chunks/{importer-B87_fmVx.js → importer-C6NVpCXp.js} +327 -84
  46. package/dist/chunks/importer-C6NVpCXp.js.map +1 -0
  47. package/dist/chunks/{importer-DPZDN40-.js → importer-CKFe4q54.js} +25 -17
  48. package/dist/chunks/importer-CKFe4q54.js.map +1 -0
  49. package/dist/chunks/{importer-Bji2MeWr.js → importer-CKbUbsJA.js} +13 -10
  50. package/dist/chunks/{importer-Bji2MeWr.js.map → importer-CKbUbsJA.js.map} +1 -1
  51. package/dist/chunks/{importer-CB_zV5_L.js → importer-ClCsdgTJ.js} +208 -1104
  52. package/dist/chunks/importer-ClCsdgTJ.js.map +1 -0
  53. package/dist/chunks/{importer-e3huMZbm.js → importer-CyVZTrf0.js} +14 -10
  54. package/dist/chunks/importer-CyVZTrf0.js.map +1 -0
  55. package/dist/chunks/{importer-CVT-JyDE.js → importer-DgG4v1Ms.js} +1537 -355
  56. package/dist/chunks/importer-DgG4v1Ms.js.map +1 -0
  57. package/dist/chunks/{importer-Cyy6UseH.js → importer-asFUrIsj.js} +66 -13
  58. package/dist/chunks/importer-asFUrIsj.js.map +1 -0
  59. package/dist/chunks/{input-Ccg9I22A.js → input-CAxt_aV-.js} +4 -4
  60. package/dist/chunks/{input-Ccg9I22A.js.map → input-CAxt_aV-.js.map} +1 -1
  61. package/dist/chunks/{json-elements-By172SVq.js → json-elements-Cq76WjnZ.js} +4 -4
  62. package/dist/chunks/{json-elements-By172SVq.js.map → json-elements-Cq76WjnZ.js.map} +1 -1
  63. package/dist/chunks/{lexicon-u2SI5xcx.js → lexicon-IVRq2Uoo.js} +282 -74
  64. package/dist/chunks/lexicon-IVRq2Uoo.js.map +1 -0
  65. package/dist/chunks/{ontology-export-BwgMyuF0.js → ontology-export-OMo91mb0.js} +4 -4
  66. package/dist/chunks/{ontology-export-BwgMyuF0.js.map → ontology-export-OMo91mb0.js.map} +1 -1
  67. package/dist/chunks/{records-Cgvxz-Vx.js → records-NJiYvekn.js} +2 -2
  68. package/dist/chunks/{records-Cgvxz-Vx.js.map → records-NJiYvekn.js.map} +1 -1
  69. package/dist/chunks/{report-CgdsL9Hb.js → report-CwMfYa9J.js} +23 -23
  70. package/dist/chunks/{report-CgdsL9Hb.js.map → report-CwMfYa9J.js.map} +1 -1
  71. package/dist/chunks/round-trip-Cy3_SHRZ.js +79 -0
  72. package/dist/chunks/round-trip-Cy3_SHRZ.js.map +1 -0
  73. package/dist/chunks/{sigv4-LkAzsLAu.js → sigv4-BXwVbza3.js} +5 -5
  74. package/dist/chunks/sigv4-BXwVbza3.js.map +1 -0
  75. package/dist/chunks/{styles-CaABMoO1.js → styles-CAVmBLgp.js} +1237 -138
  76. package/dist/chunks/styles-CAVmBLgp.js.map +1 -0
  77. package/dist/chunks/styles-CPczw1Jz.js +1928 -0
  78. package/dist/chunks/styles-CPczw1Jz.js.map +1 -0
  79. package/dist/chunks/styles-boK6Ln54.js +2847 -0
  80. package/dist/chunks/styles-boK6Ln54.js.map +1 -0
  81. package/dist/chunks/{styles-yed-Cs1nmrjn.js → styles-yed-XeFBQ6VS.js} +506 -175
  82. package/dist/chunks/styles-yed-XeFBQ6VS.js.map +1 -0
  83. package/dist/chunks/{text-BuKQVMz2.js → text-ystE2bYi.js} +2 -2
  84. package/dist/chunks/{text-BuKQVMz2.js.map → text-ystE2bYi.js.map} +1 -1
  85. package/dist/csv.js +7 -7
  86. package/dist/cx.js +2 -2
  87. package/dist/cx2.js +2 -2
  88. package/dist/cys.js +1 -1
  89. package/dist/dot.js +8 -4
  90. package/dist/dot.js.map +1 -1
  91. package/dist/gexf.js +42 -12
  92. package/dist/gexf.js.map +1 -1
  93. package/dist/gml.js +848 -194
  94. package/dist/gml.js.map +1 -1
  95. package/dist/graph-io.js +230 -193
  96. package/dist/graph-io.js.map +1 -1
  97. package/dist/graphml.js +3 -3
  98. package/dist/json.js +3 -2
  99. package/dist/neo4j.js +6 -6
  100. package/dist/obo.js +1 -1
  101. package/dist/pajek.js +1 -1
  102. package/dist/positions.js +45 -3
  103. package/dist/positions.js.map +1 -1
  104. package/dist/sources-cypher.js +141 -23
  105. package/dist/sources-cypher.js.map +1 -1
  106. package/dist/sources-ndex.js +2 -2
  107. package/dist/sources-rest.js +32 -6
  108. package/dist/sources-rest.js.map +1 -1
  109. package/dist/sources-sparql.d.ts +1 -0
  110. package/dist/sources-sparql.js +439 -0
  111. package/dist/sources-sparql.js.map +1 -0
  112. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  113. package/dist/src/formats/cx/importer.js +5 -1
  114. package/dist/src/formats/cx/importer.js.map +1 -1
  115. package/dist/src/formats/cx/styles.d.ts +24 -1
  116. package/dist/src/formats/cx/styles.d.ts.map +1 -1
  117. package/dist/src/formats/cx/styles.js +117 -11
  118. package/dist/src/formats/cx/styles.js.map +1 -1
  119. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  120. package/dist/src/formats/cx2/importer.js +1 -0
  121. package/dist/src/formats/cx2/importer.js.map +1 -1
  122. package/dist/src/formats/cx2/styles.d.ts.map +1 -1
  123. package/dist/src/formats/cx2/styles.js +3 -1
  124. package/dist/src/formats/cx2/styles.js.map +1 -1
  125. package/dist/src/formats/cys/importer.d.ts.map +1 -1
  126. package/dist/src/formats/cys/importer.js +1 -0
  127. package/dist/src/formats/cys/importer.js.map +1 -1
  128. package/dist/src/formats/cys/styles.d.ts +2 -0
  129. package/dist/src/formats/cys/styles.d.ts.map +1 -1
  130. package/dist/src/formats/cys/styles.js +15 -7
  131. package/dist/src/formats/cys/styles.js.map +1 -1
  132. package/dist/src/formats/dot/drawing.d.ts +98 -0
  133. package/dist/src/formats/dot/drawing.d.ts.map +1 -0
  134. package/dist/src/formats/dot/drawing.js +734 -0
  135. package/dist/src/formats/dot/drawing.js.map +1 -0
  136. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  137. package/dist/src/formats/dot/exporter.js +34 -2
  138. package/dist/src/formats/dot/exporter.js.map +1 -1
  139. package/dist/src/formats/dot/importer.d.ts +6 -1
  140. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  141. package/dist/src/formats/dot/importer.js +170 -33
  142. package/dist/src/formats/dot/importer.js.map +1 -1
  143. package/dist/src/formats/dot/index.d.ts +3 -1
  144. package/dist/src/formats/dot/index.d.ts.map +1 -1
  145. package/dist/src/formats/dot/index.js +2 -1
  146. package/dist/src/formats/dot/index.js.map +1 -1
  147. package/dist/src/formats/dot/label-places.d.ts +41 -0
  148. package/dist/src/formats/dot/label-places.d.ts.map +1 -0
  149. package/dist/src/formats/dot/label-places.js +185 -0
  150. package/dist/src/formats/dot/label-places.js.map +1 -0
  151. package/dist/src/formats/dot/names.d.ts +13 -0
  152. package/dist/src/formats/dot/names.d.ts.map +1 -1
  153. package/dist/src/formats/dot/names.js +13 -0
  154. package/dist/src/formats/dot/names.js.map +1 -1
  155. package/dist/src/formats/dot/record.d.ts +50 -0
  156. package/dist/src/formats/dot/record.d.ts.map +1 -0
  157. package/dist/src/formats/dot/record.js +275 -0
  158. package/dist/src/formats/dot/record.js.map +1 -0
  159. package/dist/src/formats/dot/styles.d.ts +72 -2
  160. package/dist/src/formats/dot/styles.d.ts.map +1 -1
  161. package/dist/src/formats/dot/styles.js +1500 -119
  162. package/dist/src/formats/dot/styles.js.map +1 -1
  163. package/dist/src/formats/gexf/dialog-metrics.d.ts +15 -0
  164. package/dist/src/formats/gexf/dialog-metrics.d.ts.map +1 -0
  165. package/dist/src/formats/gexf/dialog-metrics.js +59 -0
  166. package/dist/src/formats/gexf/dialog-metrics.js.map +1 -0
  167. package/dist/src/formats/gexf/importer.d.ts +7 -0
  168. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  169. package/dist/src/formats/gexf/importer.js +41 -5
  170. package/dist/src/formats/gexf/importer.js.map +1 -1
  171. package/dist/src/formats/gexf/preview.d.ts +77 -0
  172. package/dist/src/formats/gexf/preview.d.ts.map +1 -0
  173. package/dist/src/formats/gexf/preview.js +191 -0
  174. package/dist/src/formats/gexf/preview.js.map +1 -0
  175. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  176. package/dist/src/formats/gexf/schema.js +2 -1
  177. package/dist/src/formats/gexf/schema.js.map +1 -1
  178. package/dist/src/formats/gexf/styles.d.ts +58 -31
  179. package/dist/src/formats/gexf/styles.d.ts.map +1 -1
  180. package/dist/src/formats/gexf/styles.js +848 -225
  181. package/dist/src/formats/gexf/styles.js.map +1 -1
  182. package/dist/src/formats/gml/importer.d.ts +5 -2
  183. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  184. package/dist/src/formats/gml/importer.js +258 -71
  185. package/dist/src/formats/gml/importer.js.map +1 -1
  186. package/dist/src/formats/gml/index.d.ts +1 -1
  187. package/dist/src/formats/gml/index.d.ts.map +1 -1
  188. package/dist/src/formats/gml/index.js +1 -1
  189. package/dist/src/formats/gml/index.js.map +1 -1
  190. package/dist/src/formats/gml/positions.d.ts +7 -0
  191. package/dist/src/formats/gml/positions.d.ts.map +1 -1
  192. package/dist/src/formats/gml/positions.js +29 -7
  193. package/dist/src/formats/gml/positions.js.map +1 -1
  194. package/dist/src/formats/gml/styles.d.ts.map +1 -1
  195. package/dist/src/formats/gml/styles.js +754 -102
  196. package/dist/src/formats/gml/styles.js.map +1 -1
  197. package/dist/src/formats/graphml/styles-plain.d.ts.map +1 -1
  198. package/dist/src/formats/graphml/styles-plain.js +6 -10
  199. package/dist/src/formats/graphml/styles-plain.js.map +1 -1
  200. package/dist/src/formats/graphml/styles-yed.d.ts +21 -15
  201. package/dist/src/formats/graphml/styles-yed.d.ts.map +1 -1
  202. package/dist/src/formats/graphml/styles-yed.js +695 -164
  203. package/dist/src/formats/graphml/styles-yed.js.map +1 -1
  204. package/dist/src/formats/graphml/styles-yfiles3.d.ts +1 -1
  205. package/dist/src/formats/graphml/styles-yfiles3.d.ts.map +1 -1
  206. package/dist/src/formats/graphml/styles-yfiles3.js +431 -64
  207. package/dist/src/formats/graphml/styles-yfiles3.js.map +1 -1
  208. package/dist/src/formats/graphml/yfiles.d.ts +3 -3
  209. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  210. package/dist/src/formats/graphml/yfiles.js +16 -6
  211. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  212. package/dist/src/formats/json/dialect.d.ts +3 -2
  213. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  214. package/dist/src/formats/json/dialect.js +39 -5
  215. package/dist/src/formats/json/dialect.js.map +1 -1
  216. package/dist/src/formats/json/importer.d.ts +13 -2
  217. package/dist/src/formats/json/importer.d.ts.map +1 -1
  218. package/dist/src/formats/json/importer.js +42 -6
  219. package/dist/src/formats/json/importer.js.map +1 -1
  220. package/dist/src/formats/json/index.d.ts +1 -1
  221. package/dist/src/formats/json/index.d.ts.map +1 -1
  222. package/dist/src/formats/json/index.js +1 -1
  223. package/dist/src/formats/json/index.js.map +1 -1
  224. package/dist/src/formats/json/positions.d.ts +5 -2
  225. package/dist/src/formats/json/positions.d.ts.map +1 -1
  226. package/dist/src/formats/json/positions.js +20 -4
  227. package/dist/src/formats/json/positions.js.map +1 -1
  228. package/dist/src/formats/json/styles.d.ts +12 -1
  229. package/dist/src/formats/json/styles.d.ts.map +1 -1
  230. package/dist/src/formats/json/styles.js +1843 -136
  231. package/dist/src/formats/json/styles.js.map +1 -1
  232. package/dist/src/formats/pajek/draw.d.ts +104 -0
  233. package/dist/src/formats/pajek/draw.d.ts.map +1 -0
  234. package/dist/src/formats/pajek/draw.js +382 -0
  235. package/dist/src/formats/pajek/draw.js.map +1 -0
  236. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  237. package/dist/src/formats/pajek/exporter.js +70 -51
  238. package/dist/src/formats/pajek/exporter.js.map +1 -1
  239. package/dist/src/formats/pajek/importer.d.ts +9 -0
  240. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  241. package/dist/src/formats/pajek/importer.js +233 -102
  242. package/dist/src/formats/pajek/importer.js.map +1 -1
  243. package/dist/src/formats/pajek/index.d.ts +1 -0
  244. package/dist/src/formats/pajek/index.d.ts.map +1 -1
  245. package/dist/src/formats/pajek/index.js.map +1 -1
  246. package/dist/src/formats/pajek/styles.d.ts +15 -7
  247. package/dist/src/formats/pajek/styles.d.ts.map +1 -1
  248. package/dist/src/formats/pajek/styles.js +1395 -114
  249. package/dist/src/formats/pajek/styles.js.map +1 -1
  250. package/dist/src/formats/pajek/syntax.d.ts +10 -1
  251. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  252. package/dist/src/formats/pajek/syntax.js +26 -2
  253. package/dist/src/formats/pajek/syntax.js.map +1 -1
  254. package/dist/src/formats/xgmml/emit.d.ts +1 -0
  255. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  256. package/dist/src/formats/xgmml/emit.js +65 -5
  257. package/dist/src/formats/xgmml/emit.js.map +1 -1
  258. package/dist/src/formats/xgmml/styles.d.ts +23 -3
  259. package/dist/src/formats/xgmml/styles.d.ts.map +1 -1
  260. package/dist/src/formats/xgmml/styles.js +277 -60
  261. package/dist/src/formats/xgmml/styles.js.map +1 -1
  262. package/dist/src/index.d.ts +6 -5
  263. package/dist/src/index.d.ts.map +1 -1
  264. package/dist/src/index.js +6 -5
  265. package/dist/src/index.js.map +1 -1
  266. package/dist/src/positions/convert.d.ts.map +1 -1
  267. package/dist/src/positions/convert.js +11 -1
  268. package/dist/src/positions/convert.js.map +1 -1
  269. package/dist/src/positions/extract.d.ts.map +1 -1
  270. package/dist/src/positions/extract.js +53 -1
  271. package/dist/src/positions/extract.js.map +1 -1
  272. package/dist/src/positions/index.d.ts +1 -1
  273. package/dist/src/positions/index.d.ts.map +1 -1
  274. package/dist/src/positions/types.d.ts +59 -0
  275. package/dist/src/positions/types.d.ts.map +1 -1
  276. package/dist/src/sources/builtins.d.ts +1 -1
  277. package/dist/src/sources/builtins.d.ts.map +1 -1
  278. package/dist/src/sources/builtins.js +8 -2
  279. package/dist/src/sources/builtins.js.map +1 -1
  280. package/dist/src/sources/context.d.ts.map +1 -1
  281. package/dist/src/sources/context.js +1 -0
  282. package/dist/src/sources/context.js.map +1 -1
  283. package/dist/src/sources/credentials.d.ts +50 -1
  284. package/dist/src/sources/credentials.d.ts.map +1 -1
  285. package/dist/src/sources/credentials.js +69 -0
  286. package/dist/src/sources/credentials.js.map +1 -1
  287. package/dist/src/sources/cypher/descriptor.d.ts.map +1 -1
  288. package/dist/src/sources/cypher/descriptor.js +5 -0
  289. package/dist/src/sources/cypher/descriptor.js.map +1 -1
  290. package/dist/src/sources/cypher/index.d.ts.map +1 -1
  291. package/dist/src/sources/cypher/index.js +125 -18
  292. package/dist/src/sources/cypher/index.js.map +1 -1
  293. package/dist/src/sources/cypher/params.d.ts +1 -1
  294. package/dist/src/sources/cypher/params.d.ts.map +1 -1
  295. package/dist/src/sources/cypher/query-builder.d.ts +17 -0
  296. package/dist/src/sources/cypher/query-builder.d.ts.map +1 -1
  297. package/dist/src/sources/cypher/query-builder.js +31 -0
  298. package/dist/src/sources/cypher/query-builder.js.map +1 -1
  299. package/dist/src/sources/ids.d.ts +5 -0
  300. package/dist/src/sources/ids.d.ts.map +1 -1
  301. package/dist/src/sources/ids.js +5 -0
  302. package/dist/src/sources/ids.js.map +1 -1
  303. package/dist/src/sources/params.d.ts +3 -0
  304. package/dist/src/sources/params.d.ts.map +1 -1
  305. package/dist/src/sources/rest/descriptor.d.ts.map +1 -1
  306. package/dist/src/sources/rest/descriptor.js +9 -0
  307. package/dist/src/sources/rest/descriptor.js.map +1 -1
  308. package/dist/src/sources/rest/openalex.d.ts +4 -2
  309. package/dist/src/sources/rest/openalex.d.ts.map +1 -1
  310. package/dist/src/sources/rest/openalex.js +4 -2
  311. package/dist/src/sources/rest/openalex.js.map +1 -1
  312. package/dist/src/sources/rest/source.d.ts.map +1 -1
  313. package/dist/src/sources/rest/source.js +38 -0
  314. package/dist/src/sources/rest/source.js.map +1 -1
  315. package/dist/src/sources/run.d.ts +2 -0
  316. package/dist/src/sources/run.d.ts.map +1 -1
  317. package/dist/src/sources/run.js +30 -3
  318. package/dist/src/sources/run.js.map +1 -1
  319. package/dist/src/sources/sigv4.d.ts +5 -3
  320. package/dist/src/sources/sigv4.d.ts.map +1 -1
  321. package/dist/src/sources/sigv4.js +6 -4
  322. package/dist/src/sources/sigv4.js.map +1 -1
  323. package/dist/src/sources/sparql/descriptor.d.ts +10 -0
  324. package/dist/src/sources/sparql/descriptor.d.ts.map +1 -0
  325. package/dist/src/sources/sparql/descriptor.js +64 -0
  326. package/dist/src/sources/sparql/descriptor.js.map +1 -0
  327. package/dist/src/sources/sparql/index.d.ts +28 -0
  328. package/dist/src/sources/sparql/index.d.ts.map +1 -0
  329. package/dist/src/sources/sparql/index.js +327 -0
  330. package/dist/src/sources/sparql/index.js.map +1 -0
  331. package/dist/src/sources/sparql/ntriples.d.ts +69 -0
  332. package/dist/src/sources/sparql/ntriples.d.ts.map +1 -0
  333. package/dist/src/sources/sparql/ntriples.js +180 -0
  334. package/dist/src/sources/sparql/ntriples.js.map +1 -0
  335. package/dist/src/sources/sparql/params.d.ts +27 -0
  336. package/dist/src/sources/sparql/params.d.ts.map +1 -0
  337. package/dist/src/sources/sparql/params.js +5 -0
  338. package/dist/src/sources/sparql/params.js.map +1 -0
  339. package/dist/src/sources/sparql/query.d.ts +73 -0
  340. package/dist/src/sources/sparql/query.d.ts.map +1 -0
  341. package/dist/src/sources/sparql/query.js +164 -0
  342. package/dist/src/sources/sparql/query.js.map +1 -0
  343. package/dist/src/sources/types.d.ts +60 -0
  344. package/dist/src/sources/types.d.ts.map +1 -1
  345. package/dist/src/styles/annotations.d.ts +61 -5
  346. package/dist/src/styles/annotations.d.ts.map +1 -1
  347. package/dist/src/styles/annotations.js +130 -22
  348. package/dist/src/styles/annotations.js.map +1 -1
  349. package/dist/src/styles/canon/bends.d.ts +46 -0
  350. package/dist/src/styles/canon/bends.d.ts.map +1 -1
  351. package/dist/src/styles/canon/bends.js +123 -0
  352. package/dist/src/styles/canon/bends.js.map +1 -1
  353. package/dist/src/styles/canon/color-tables.d.ts +6 -1
  354. package/dist/src/styles/canon/color-tables.d.ts.map +1 -1
  355. package/dist/src/styles/canon/color-tables.js +10 -4
  356. package/dist/src/styles/canon/color-tables.js.map +1 -1
  357. package/dist/src/styles/canon/color.d.ts +3 -1
  358. package/dist/src/styles/canon/color.d.ts.map +1 -1
  359. package/dist/src/styles/canon/color.js +103 -3
  360. package/dist/src/styles/canon/color.js.map +1 -1
  361. package/dist/src/styles/canon/enums.d.ts +2 -2
  362. package/dist/src/styles/canon/enums.d.ts.map +1 -1
  363. package/dist/src/styles/canon/enums.js +89 -7
  364. package/dist/src/styles/canon/enums.js.map +1 -1
  365. package/dist/src/styles/canon/graphviz.d.ts +12 -3
  366. package/dist/src/styles/canon/graphviz.d.ts.map +1 -1
  367. package/dist/src/styles/canon/graphviz.js +57 -9
  368. package/dist/src/styles/canon/graphviz.js.map +1 -1
  369. package/dist/src/styles/canon/number.d.ts +8 -0
  370. package/dist/src/styles/canon/number.d.ts.map +1 -1
  371. package/dist/src/styles/canon/number.js +18 -0
  372. package/dist/src/styles/canon/number.js.map +1 -1
  373. package/dist/src/styles/canon/text.d.ts +29 -2
  374. package/dist/src/styles/canon/text.d.ts.map +1 -1
  375. package/dist/src/styles/canon/text.js +156 -16
  376. package/dist/src/styles/canon/text.js.map +1 -1
  377. package/dist/src/styles/codes.d.ts +2 -0
  378. package/dist/src/styles/codes.d.ts.map +1 -1
  379. package/dist/src/styles/codes.js +2 -0
  380. package/dist/src/styles/codes.js.map +1 -1
  381. package/dist/src/styles/cyjs/cascade.d.ts.map +1 -1
  382. package/dist/src/styles/cyjs/cascade.js +1139 -79
  383. package/dist/src/styles/cyjs/cascade.js.map +1 -1
  384. package/dist/src/styles/cyjs/defaults-table.d.ts.map +1 -1
  385. package/dist/src/styles/cyjs/defaults-table.js +4 -0
  386. package/dist/src/styles/cyjs/defaults-table.js.map +1 -1
  387. package/dist/src/styles/cyjs/emit.d.ts.map +1 -1
  388. package/dist/src/styles/cyjs/emit.js +232 -10
  389. package/dist/src/styles/cyjs/emit.js.map +1 -1
  390. package/dist/src/styles/cyjs/face.d.ts +20 -0
  391. package/dist/src/styles/cyjs/face.d.ts.map +1 -0
  392. package/dist/src/styles/cyjs/face.js +332 -0
  393. package/dist/src/styles/cyjs/face.js.map +1 -0
  394. package/dist/src/styles/cyjs/mappers.d.ts +44 -1
  395. package/dist/src/styles/cyjs/mappers.d.ts.map +1 -1
  396. package/dist/src/styles/cyjs/mappers.js +475 -26
  397. package/dist/src/styles/cyjs/mappers.js.map +1 -1
  398. package/dist/src/styles/cyjs/routes.d.ts +53 -0
  399. package/dist/src/styles/cyjs/routes.d.ts.map +1 -0
  400. package/dist/src/styles/cyjs/routes.js +360 -0
  401. package/dist/src/styles/cyjs/routes.js.map +1 -0
  402. package/dist/src/styles/cytoscape/charts.d.ts +23 -0
  403. package/dist/src/styles/cytoscape/charts.d.ts.map +1 -0
  404. package/dist/src/styles/cytoscape/charts.js +560 -0
  405. package/dist/src/styles/cytoscape/charts.js.map +1 -0
  406. package/dist/src/styles/cytoscape/emit.d.ts.map +1 -1
  407. package/dist/src/styles/cytoscape/emit.js +156 -48
  408. package/dist/src/styles/cytoscape/emit.js.map +1 -1
  409. package/dist/src/styles/cytoscape/lexicon.d.ts +91 -3
  410. package/dist/src/styles/cytoscape/lexicon.d.ts.map +1 -1
  411. package/dist/src/styles/cytoscape/lexicon.js +387 -77
  412. package/dist/src/styles/cytoscape/lexicon.js.map +1 -1
  413. package/dist/src/styles/cytoscape/mappings.d.ts +30 -0
  414. package/dist/src/styles/cytoscape/mappings.d.ts.map +1 -1
  415. package/dist/src/styles/cytoscape/mappings.js +92 -0
  416. package/dist/src/styles/cytoscape/mappings.js.map +1 -1
  417. package/dist/src/styles/cytoscape/resolve.d.ts +42 -2
  418. package/dist/src/styles/cytoscape/resolve.d.ts.map +1 -1
  419. package/dist/src/styles/cytoscape/resolve.js +623 -17
  420. package/dist/src/styles/cytoscape/resolve.js.map +1 -1
  421. package/dist/src/styles/defaults.d.ts +46 -26
  422. package/dist/src/styles/defaults.d.ts.map +1 -1
  423. package/dist/src/styles/defaults.js +308 -71
  424. package/dist/src/styles/defaults.js.map +1 -1
  425. package/dist/src/styles/index.d.ts +4 -3
  426. package/dist/src/styles/index.d.ts.map +1 -1
  427. package/dist/src/styles/index.js +3 -2
  428. package/dist/src/styles/index.js.map +1 -1
  429. package/dist/src/styles/internal.d.ts +1 -0
  430. package/dist/src/styles/internal.d.ts.map +1 -1
  431. package/dist/src/styles/internal.js +1 -0
  432. package/dist/src/styles/internal.js.map +1 -1
  433. package/dist/src/styles/io/generic.d.ts.map +1 -1
  434. package/dist/src/styles/io/generic.js +56 -16
  435. package/dist/src/styles/io/generic.js.map +1 -1
  436. package/dist/src/styles/io/round-trip.d.ts +56 -0
  437. package/dist/src/styles/io/round-trip.d.ts.map +1 -0
  438. package/dist/src/styles/io/round-trip.js +138 -0
  439. package/dist/src/styles/io/round-trip.js.map +1 -0
  440. package/dist/src/styles/io/writer.d.ts.map +1 -1
  441. package/dist/src/styles/io/writer.js +6 -1
  442. package/dist/src/styles/io/writer.js.map +1 -1
  443. package/dist/src/styles/notes.d.ts.map +1 -1
  444. package/dist/src/styles/notes.js +1 -0
  445. package/dist/src/styles/notes.js.map +1 -1
  446. package/dist/src/styles/plan/features.d.ts +1 -1
  447. package/dist/src/styles/plan/features.d.ts.map +1 -1
  448. package/dist/src/styles/plan/features.js +22 -2
  449. package/dist/src/styles/plan/features.js.map +1 -1
  450. package/dist/src/styles/plan/index.d.ts +1 -1
  451. package/dist/src/styles/plan/index.d.ts.map +1 -1
  452. package/dist/src/styles/plan/index.js +1 -1
  453. package/dist/src/styles/plan/index.js.map +1 -1
  454. package/dist/src/styles/plan/plan.d.ts.map +1 -1
  455. package/dist/src/styles/plan/plan.js +61 -4
  456. package/dist/src/styles/plan/plan.js.map +1 -1
  457. package/dist/src/styles/plan/resolve.d.ts +11 -0
  458. package/dist/src/styles/plan/resolve.d.ts.map +1 -1
  459. package/dist/src/styles/plan/resolve.js +40 -4
  460. package/dist/src/styles/plan/resolve.js.map +1 -1
  461. package/dist/src/styles/slots.d.ts +14 -0
  462. package/dist/src/styles/slots.d.ts.map +1 -0
  463. package/dist/src/styles/slots.js +20 -0
  464. package/dist/src/styles/slots.js.map +1 -0
  465. package/dist/src/styles/types.d.ts +687 -6
  466. package/dist/src/styles/types.d.ts.map +1 -1
  467. package/dist/src/styles/vocabulary.d.ts +3 -2
  468. package/dist/src/styles/vocabulary.d.ts.map +1 -1
  469. package/dist/src/styles/vocabulary.js +224 -0
  470. package/dist/src/styles/vocabulary.js.map +1 -1
  471. package/dist/styles.js +30 -239
  472. package/dist/styles.js.map +1 -1
  473. package/dist/xgmml.js +3 -3
  474. package/package.json +27 -7
  475. package/src/formats/cx/importer.ts +6 -1
  476. package/src/formats/cx/styles.ts +143 -9
  477. package/src/formats/cx2/importer.ts +1 -0
  478. package/src/formats/cx2/styles.ts +3 -1
  479. package/src/formats/cys/importer.ts +1 -0
  480. package/src/formats/cys/styles.ts +27 -5
  481. package/src/formats/dot/drawing.ts +799 -0
  482. package/src/formats/dot/exporter.ts +37 -1
  483. package/src/formats/dot/importer.ts +192 -36
  484. package/src/formats/dot/index.ts +3 -1
  485. package/src/formats/dot/label-places.ts +241 -0
  486. package/src/formats/dot/names.ts +15 -0
  487. package/src/formats/dot/record.ts +336 -0
  488. package/src/formats/dot/styles.ts +1702 -149
  489. package/src/formats/gexf/dialog-metrics.ts +61 -0
  490. package/src/formats/gexf/importer.ts +50 -5
  491. package/src/formats/gexf/preview.ts +217 -0
  492. package/src/formats/gexf/schema.ts +2 -1
  493. package/src/formats/gexf/styles.ts +1099 -245
  494. package/src/formats/gml/importer.ts +291 -76
  495. package/src/formats/gml/index.ts +1 -1
  496. package/src/formats/gml/positions.ts +29 -7
  497. package/src/formats/gml/styles.ts +852 -110
  498. package/src/formats/graphml/styles-plain.ts +6 -15
  499. package/src/formats/graphml/styles-yed.ts +765 -176
  500. package/src/formats/graphml/styles-yfiles3.ts +487 -63
  501. package/src/formats/graphml/yfiles.ts +19 -6
  502. package/src/formats/json/dialect.ts +41 -5
  503. package/src/formats/json/importer.ts +59 -7
  504. package/src/formats/json/index.ts +1 -0
  505. package/src/formats/json/positions.ts +22 -5
  506. package/src/formats/json/styles.ts +2123 -171
  507. package/src/formats/pajek/draw.ts +469 -0
  508. package/src/formats/pajek/exporter.ts +81 -49
  509. package/src/formats/pajek/importer.ts +278 -103
  510. package/src/formats/pajek/index.ts +1 -0
  511. package/src/formats/pajek/styles.ts +1506 -133
  512. package/src/formats/pajek/syntax.ts +27 -2
  513. package/src/formats/xgmml/emit.ts +73 -5
  514. package/src/formats/xgmml/styles.ts +330 -71
  515. package/src/index.ts +12 -1
  516. package/src/positions/convert.ts +12 -1
  517. package/src/positions/extract.ts +55 -1
  518. package/src/positions/index.ts +1 -0
  519. package/src/positions/types.ts +60 -0
  520. package/src/sources/builtins.ts +8 -1
  521. package/src/sources/context.ts +1 -0
  522. package/src/sources/credentials.ts +90 -1
  523. package/src/sources/cypher/descriptor.ts +7 -1
  524. package/src/sources/cypher/index.ts +149 -18
  525. package/src/sources/cypher/params.ts +1 -1
  526. package/src/sources/cypher/query-builder.ts +33 -0
  527. package/src/sources/ids.ts +6 -0
  528. package/src/sources/params.ts +3 -0
  529. package/src/sources/rest/descriptor.ts +9 -0
  530. package/src/sources/rest/openalex.ts +4 -2
  531. package/src/sources/rest/source.ts +41 -0
  532. package/src/sources/run.ts +35 -3
  533. package/src/sources/sigv4.ts +6 -4
  534. package/src/sources/sparql/descriptor.ts +67 -0
  535. package/src/sources/sparql/index.ts +371 -0
  536. package/src/sources/sparql/ntriples.ts +219 -0
  537. package/src/sources/sparql/params.ts +28 -0
  538. package/src/sources/sparql/query.ts +216 -0
  539. package/src/sources/types.ts +68 -0
  540. package/src/styles/annotations.ts +201 -27
  541. package/src/styles/canon/bends.ts +137 -0
  542. package/src/styles/canon/color-tables.ts +10 -4
  543. package/src/styles/canon/color.ts +105 -3
  544. package/src/styles/canon/enums.ts +107 -8
  545. package/src/styles/canon/graphviz.ts +71 -10
  546. package/src/styles/canon/number.ts +19 -0
  547. package/src/styles/canon/text.ts +187 -16
  548. package/src/styles/codes.ts +2 -0
  549. package/src/styles/cyjs/cascade.ts +1303 -84
  550. package/src/styles/cyjs/defaults-table.ts +4 -0
  551. package/src/styles/cyjs/emit.ts +268 -14
  552. package/src/styles/cyjs/face.ts +372 -0
  553. package/src/styles/cyjs/mappers.ts +506 -27
  554. package/src/styles/cyjs/routes.ts +432 -0
  555. package/src/styles/cytoscape/charts.ts +684 -0
  556. package/src/styles/cytoscape/emit.ts +170 -48
  557. package/src/styles/cytoscape/lexicon.ts +431 -78
  558. package/src/styles/cytoscape/mappings.ts +108 -0
  559. package/src/styles/cytoscape/resolve.ts +741 -17
  560. package/src/styles/defaults.ts +417 -108
  561. package/src/styles/index.ts +5 -0
  562. package/src/styles/internal.ts +1 -0
  563. package/src/styles/io/generic.ts +79 -23
  564. package/src/styles/io/round-trip.ts +202 -0
  565. package/src/styles/io/writer.ts +6 -1
  566. package/src/styles/notes.ts +1 -0
  567. package/src/styles/plan/features.ts +23 -2
  568. package/src/styles/plan/index.ts +1 -1
  569. package/src/styles/plan/plan.ts +69 -5
  570. package/src/styles/plan/resolve.ts +47 -4
  571. package/src/styles/slots.ts +25 -0
  572. package/src/styles/types.ts +698 -5
  573. package/src/styles/vocabulary.ts +232 -2
  574. package/dist/chunks/block-Cu22ewuS.js.map +0 -1
  575. package/dist/chunks/choose-Cdx6Enl2.js +0 -57
  576. package/dist/chunks/choose-Cdx6Enl2.js.map +0 -1
  577. package/dist/chunks/codes-CV_Bhcyi.js.map +0 -1
  578. package/dist/chunks/convert-BcQ8_jMo.js.map +0 -1
  579. package/dist/chunks/credentials-BiQsnMTb.js.map +0 -1
  580. package/dist/chunks/descriptor-BJ-W0Jd-.js.map +0 -1
  581. package/dist/chunks/emit-CD8xnY8s.js.map +0 -1
  582. package/dist/chunks/export-CZ7VRm4I.js.map +0 -1
  583. package/dist/chunks/exporter-bxxjOrDZ.js.map +0 -1
  584. package/dist/chunks/format-BMS-1Nxb.js +0 -64
  585. package/dist/chunks/format-BMS-1Nxb.js.map +0 -1
  586. package/dist/chunks/generic-CP6YD34u.js.map +0 -1
  587. package/dist/chunks/importer-B87_fmVx.js.map +0 -1
  588. package/dist/chunks/importer-CB_zV5_L.js.map +0 -1
  589. package/dist/chunks/importer-CVT-JyDE.js.map +0 -1
  590. package/dist/chunks/importer-Cyy6UseH.js.map +0 -1
  591. package/dist/chunks/importer-DPZDN40-.js.map +0 -1
  592. package/dist/chunks/importer-e3huMZbm.js.map +0 -1
  593. package/dist/chunks/lexicon-u2SI5xcx.js.map +0 -1
  594. package/dist/chunks/sigv4-LkAzsLAu.js.map +0 -1
  595. package/dist/chunks/styles-CaABMoO1.js.map +0 -1
  596. package/dist/chunks/styles-P_gmFAOi.js +0 -770
  597. package/dist/chunks/styles-P_gmFAOi.js.map +0 -1
  598. package/dist/chunks/styles-yed-Cs1nmrjn.js.map +0 -1
  599. package/dist/chunks/writer-DvHTl36J.js +0 -449
  600. package/dist/chunks/writer-DvHTl36J.js.map +0 -1
@@ -13,7 +13,7 @@
13
13
  * `none` and `invis` are transparent, and a color list is read as Graphviz draws it for that use.
14
14
  * - Outlines: `plain`, `plaintext` and `none` draw none. `setlinewidth(n)` is a pen width, `bold` a width of 2.
15
15
  * - Sizes: exact with `fixedsize`, an empty label or a `point`; otherwise minimums Graphviz grows to fit the label,
16
- * carried and noted as `W_STYLE_SIZE_MINIMUM`.
16
+ * carried as `labelFit: "inside"` with the label's padding (`margin`, else 8 by 4 points).
17
17
  * - Arrows per `dir`, `arrowhead` and `arrowtail`, with the graph's kind deciding the default: a target arrow on
18
18
  * directed edges, as one tool-default rule.
19
19
  * - A cluster's container node holds the cluster's attributes: its fill (`bgcolor`, or the fill when `filled`) and
@@ -30,25 +30,27 @@ import type { GraphSnapshot } from "@graphty/graph-format";
30
30
 
31
31
  import { isWritableDotText } from "../../common/escape.js";
32
32
  import { inferTextDtype } from "../../common/text.js";
33
+ import { type BendPoint, canonBends, canonNodeOffsets, flattenCubic } from "../../styles/canon/bends.js";
33
34
  import { canonColor, colorOpacity } from "../../styles/canon/color.js";
34
35
  import { canonEnum } from "../../styles/canon/enums.js";
35
36
  import { canonNumber, DOT_POINTS_PER_INCH, num6 } from "../../styles/canon/number.js";
36
37
  import { type CanonSite, MAX_STYLE_VALUE_LENGTH, noteAt } from "../../styles/canon/site.js";
37
- import { type DotEscapeNames, dotLabelText, htmlLabelText, recordLabelText } from "../../styles/canon/text.js";
38
+ import {
39
+ type DotEscapeNames,
40
+ dotLabelAlign,
41
+ dotLabelText,
42
+ htmlLabelSpans,
43
+ htmlLabelText,
44
+ recordLabelText,
45
+ } from "../../styles/canon/text.js";
38
46
  import { STYLE_ISSUE } from "../../styles/codes.js";
39
47
  import { toolDefaults } from "../../styles/defaults.js";
40
48
  import { StyleApproximations } from "../../styles/io/approximations.js";
41
- import {
42
- blockStyles,
43
- parseStyleBlock,
44
- STYLE_BLOCK_NAME,
45
- styleBlockLosses,
46
- writeStyleBlock,
47
- } from "../../styles/io/block.js";
49
+ import { STYLE_BLOCK_NAME, styleBlockLosses, writeStyleBlock } from "../../styles/io/block.js";
50
+ import { elementValues, takeFromBlock } from "../../styles/io/round-trip.js";
48
51
  import { type ExporterStyleInput, sharedInExportCall } from "../../styles/io/writer.js";
49
52
  import { StyleNotes } from "../../styles/notes.js";
50
- import { styleFingerprint } from "../../styles/plan/features.js";
51
- import { plainCell, resolveStyles } from "../../styles/plan/resolve.js";
53
+ import { plainCell } from "../../styles/plan/resolve.js";
52
54
  import type {
53
55
  ImporterStyles,
54
56
  StyleColumn,
@@ -61,7 +63,19 @@ import type {
61
63
  } from "../../styles/types.js";
62
64
  import type { LossNote } from "../../types.js";
63
65
  import { type ValueColumns, valueColumns } from "../graphml/styles-yed.js";
64
- import { CLUSTER_COLUMN, LABEL_ATTRIBUTE } from "./names.js";
66
+ import {
67
+ GRAPHVIZ_DECORATED_BOXES,
68
+ GRAPHVIZ_GLYPH_SHAPES,
69
+ GRAPHVIZ_POLYGON_SHAPES,
70
+ graphvizFont,
71
+ graphvizFontName,
72
+ graphvizGlyph,
73
+ graphvizPolygonCorners,
74
+ isGraphvizBox,
75
+ } from "./drawing.js";
76
+ import { type LabelPlaceContext, readLabelPlaces } from "./label-places.js";
77
+ import { CLUSTER_COLUMN, LABEL_ATTRIBUTE, POS_ATTRIBUTE } from "./names.js";
78
+ import { layoutRecord } from "./record.js";
65
79
 
66
80
  /**
67
81
  * The graph attribute holding the `graphty.styles` block of a DOT file graphty wrote.
@@ -89,6 +103,14 @@ export const DOT_STYLE_CAPABILITIES: StyleWriteCapabilities = Object.freeze({
89
103
  "labelColor",
90
104
  "labelSize",
91
105
  "labelFont",
106
+ "labelWeight",
107
+ "labelItalic",
108
+ "labelPosition",
109
+ "shapePoints",
110
+ "shapePointsStretch",
111
+ "shapeParts",
112
+ "shapeLines",
113
+ "shapeLineWidth",
92
114
  "tooltip",
93
115
  "visible",
94
116
  ] as const),
@@ -99,6 +121,8 @@ export const DOT_STYLE_CAPABILITIES: StyleWriteCapabilities = Object.freeze({
99
121
  "lineStyle",
100
122
  "sourceArrow",
101
123
  "targetArrow",
124
+ "sourceArrowColor",
125
+ "targetArrowColor",
102
126
  "arrowSize",
103
127
  "label",
104
128
  "sourceLabel",
@@ -106,6 +130,8 @@ export const DOT_STYLE_CAPABILITIES: StyleWriteCapabilities = Object.freeze({
106
130
  "labelColor",
107
131
  "labelSize",
108
132
  "labelFont",
133
+ "labelWeight",
134
+ "labelItalic",
109
135
  "tooltip",
110
136
  "visible",
111
137
  ] as const),
@@ -135,7 +161,12 @@ const NODE_ATTRIBUTES: readonly string[] = [
135
161
  "height",
136
162
  "fixedsize",
137
163
  "regular",
164
+ "sides",
165
+ "distortion",
166
+ "skew",
167
+ "orientation",
138
168
  "penwidth",
169
+ "gradientangle",
139
170
  "fontname",
140
171
  "fontsize",
141
172
  "fontcolor",
@@ -157,8 +188,13 @@ const EDGE_ATTRIBUTES: readonly string[] = [
157
188
  "arrowtail",
158
189
  "dir",
159
190
  "arrowsize",
191
+ "fillcolor",
160
192
  "headlabel",
161
193
  "taillabel",
194
+ "labelfontsize",
195
+ "labelfontcolor",
196
+ "labelfontname",
197
+ "xlabel",
162
198
  ];
163
199
 
164
200
  /** Attributes the reader notes as not carried. */
@@ -196,6 +232,10 @@ interface NodeContext {
196
232
  readonly cluster: boolean;
197
233
  /** The HTML strings of the file, for Graphviz's shared string pool. */
198
234
  readonly html: ReadonlySet<string>;
235
+ /** Whether the graph's `rankdir` is LR or RL, which turns a record's fields top to bottom. */
236
+ readonly flip: boolean;
237
+ /** The graph's `quantum` in points (a node's padded label box is rounded up to a multiple of it), 0 for none. */
238
+ readonly quantum: number;
199
239
  }
200
240
 
201
241
  /** What an edge's label escapes depend on. */
@@ -336,13 +376,255 @@ function labelText(
336
376
  return dotLabelText(record ? recordLabelText(raw, at) : raw, names, at);
337
377
  }
338
378
 
379
+ /**
380
+ * Reads how a plain label's lines line up (`\l`, `\r`); an HTML-like or a record label lines up its own way.
381
+ * @param raw - the label as the file gives it
382
+ * @param names - what the label's escapes stand for
383
+ * @param html - the file's HTML strings
384
+ * @param record - whether the node is a record
385
+ * @param put - receives the value
386
+ */
387
+ function readLabelAlign(
388
+ raw: string,
389
+ names: DotEscapeNames,
390
+ html: ReadonlySet<string>,
391
+ record: boolean,
392
+ put: Put,
393
+ ): void {
394
+ if (isHtmlString(raw) || html.has(`<${raw}>`)) {
395
+ // each <BR ALIGN> lines up the line before it: one alignment on every break lines up the label
396
+ const aligns = new Set(
397
+ [...raw.matchAll(/<br\b([^>]*)>/gi)].map(
398
+ (m) => /\balign\s*=\s*["']?(left|right|center)/i.exec(m[1])?.[1].toLowerCase() ?? "center",
399
+ ),
400
+ );
401
+ // words after the last break are a line of their own, centered
402
+ const body = isHtmlString(raw) ? raw.slice(1, -1) : raw;
403
+ const last = /<br\b[^>]*>(?![\s\S]*<br\b)/i.exec(body);
404
+ const tail = last === null ? "" : body.slice(last.index + last[0].length);
405
+ if (stripTags(tail).trim() !== "") {
406
+ aligns.add("center");
407
+ }
408
+ const [only] = aligns;
409
+ if (aligns.size === 1 && only !== "center") {
410
+ put("labelTextAlign", only);
411
+ }
412
+ return;
413
+ }
414
+ if (record) {
415
+ return;
416
+ }
417
+ const align = dotLabelAlign(raw, names);
418
+ if (align !== null) {
419
+ put("labelTextAlign", align);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Text with its tags (`<` to the next `>`) dropped; a `<` with no `>` after it, and the rest, kept.
425
+ * @param text - the text
426
+ * @returns the text without tags
427
+ */
428
+ function stripTags(text: string): string {
429
+ let out = "";
430
+ let i = 0;
431
+ while (i < text.length) {
432
+ const open = text.indexOf("<", i);
433
+ const close = open < 0 ? -1 : text.indexOf(">", open + 1);
434
+ if (close < 0) {
435
+ return out + text.slice(i);
436
+ }
437
+ out += text.slice(i, open);
438
+ i = close + 1;
439
+ }
440
+ return out;
441
+ }
442
+
443
+ /**
444
+ * Reads the font runs of an HTML-like label (`<B>`, `<I>`, `<FONT COLOR FACE POINT-SIZE>`) as Graphviz draws them:
445
+ * one run in a font of its own sets the label's own font (color, size, family, weight, slant), several are
446
+ * `labelRuns`. Underline, overline, strike-through, `<SUB>` and `<SUP>` have no neutral slot and draw plain.
447
+ * @param raw - the label as the file gives it
448
+ * @param html - the file's HTML strings
449
+ * @param scheme - the element's color scheme
450
+ * @param put - receives the values
451
+ * @param at - the sites
452
+ */
453
+ function readHtmlRuns(raw: string, html: ReadonlySet<string>, scheme: string | null, put: Put, at: At): void {
454
+ if (!isHtmlString(raw) && !html.has(`<${raw}>`)) {
455
+ return;
456
+ }
457
+ const markup = isHtmlString(raw) ? raw : `<${raw}>`;
458
+ const runs: Record<string, StyleValue>[] = [];
459
+ for (const span of htmlLabelSpans(markup)) {
460
+ const run = { text: span.text, ...htmlRunFont(span.tags, scheme, at("labelRuns")) };
461
+ const last = runs.at(-1);
462
+ if (last !== undefined && JSON.stringify({ ...last, text: "" }) === JSON.stringify({ ...run, text: "" })) {
463
+ last.text = `${String(last.text)}${run.text}`;
464
+ } else {
465
+ runs.push(run);
466
+ }
467
+ }
468
+ trimBlankRuns(runs);
469
+ if (runs.every((r) => Object.keys(r).length === 1)) {
470
+ return;
471
+ }
472
+ if (runs.length === 1) {
473
+ const { text: _text, ...font } = runs[0];
474
+ for (const [property, value] of Object.entries(font)) {
475
+ put(RUN_PROPERTY[property], value);
476
+ }
477
+ return;
478
+ }
479
+ runs[0].text = String(runs[0].text).trimStart();
480
+ const end = runs.at(-1);
481
+ if (end !== undefined) {
482
+ end.text = String(end.text).trimEnd();
483
+ }
484
+ put("labelRuns", JSON.stringify(runs));
485
+ }
486
+
487
+ /**
488
+ * Drops the blank runs at a label's ends, as htmlLabelText trims its blanks.
489
+ * @param runs - the runs, trimmed in place
490
+ */
491
+ function trimBlankRuns(runs: Record<string, StyleValue>[]): void {
492
+ while (runs.length > 0 && String(runs[0].text).trim() === "") {
493
+ runs.shift();
494
+ }
495
+ while (runs.length > 0 && String(runs.at(-1)?.text).trim() === "") {
496
+ runs.pop();
497
+ }
498
+ }
499
+
500
+ /** The label property a run's own font field sets when the whole label is one run. */
501
+ const RUN_PROPERTY: Readonly<Record<string, string>> = {
502
+ weight: "labelWeight",
503
+ italic: "labelItalic",
504
+ font: "labelFont",
505
+ size: "labelSize",
506
+ color: "labelColor",
507
+ };
508
+
509
+ /**
510
+ * The font of a run inside HTML font tags, as `labelRuns` fields: bold, italic, and the innermost `FONT`'s face,
511
+ * point size and color.
512
+ * @param tags - the open tags, outermost first
513
+ * @param scheme - the element's color scheme
514
+ * @param site - where a bad color is noted
515
+ * @returns the fields that differ from the label's own font
516
+ */
517
+ function htmlRunFont(tags: readonly string[], scheme: string | null, site: CanonSite): Record<string, StyleValue> {
518
+ const out: Record<string, StyleValue> = {};
519
+ for (const tag of tags) {
520
+ const name = /^<(\w+)/.exec(tag)?.[1].toLowerCase();
521
+ if (name === "b") {
522
+ out.weight = "bold";
523
+ } else if (name === "i") {
524
+ out.italic = true;
525
+ } else if (name === "font") {
526
+ for (const [attr, value] of tagAttributes(tag)) {
527
+ readFontAttribute(attr.toLowerCase(), value, scheme, site, out);
528
+ }
529
+ }
530
+ }
531
+ return out;
532
+ }
533
+
534
+ /**
535
+ * Reads one attribute of a `FONT` tag into a run's font fields: its face, point size or color.
536
+ * @param attr - the attribute's name, lowercase
537
+ * @param value - its value
538
+ * @param scheme - the element's color scheme
539
+ * @param site - where a bad color is noted
540
+ * @param out - the run's font fields, set
541
+ */
542
+ function readFontAttribute(
543
+ attr: string,
544
+ value: string,
545
+ scheme: string | null,
546
+ site: CanonSite,
547
+ out: Record<string, StyleValue>,
548
+ ): void {
549
+ if (attr === "face" && value.trim() !== "" && value.length <= MAX_STYLE_VALUE_LENGTH) {
550
+ const drawn = graphvizFont(value);
551
+ out.font = drawn.family;
552
+ if (drawn.bold) {
553
+ out.weight = "bold";
554
+ }
555
+ if (drawn.italic) {
556
+ out.italic = true;
557
+ }
558
+ } else if (attr === "point-size" && Number(value) > 0) {
559
+ out.size = Number(value);
560
+ } else if (attr === "color") {
561
+ const c = canonColor(value, "graphviz", site, { scheme, use: "text" });
562
+ if (c !== null) {
563
+ out.color = c.color;
564
+ }
565
+ }
566
+ }
567
+
568
+ /** Whether a character can be part of an HTML attribute's name (a word character or `-`). */
569
+ const ATTRIBUTE_NAME_CHAR = /^[\w-]$/;
570
+
571
+ /**
572
+ * The quoted attributes of a tag, `name = "value"` or `name = 'value'`, in order; an unquoted or unclosed value is
573
+ * skipped. Scans the tag once (an attribute regex would backtrack on a long name with no `=`).
574
+ * @param tag - the tag
575
+ * @returns each attribute's name and value
576
+ */
577
+ function tagAttributes(tag: string): [string, string][] {
578
+ const attributes: [string, string][] = [];
579
+ let i = 0;
580
+ while (i < tag.length) {
581
+ if (!ATTRIBUTE_NAME_CHAR.test(tag[i])) {
582
+ i++;
583
+ continue;
584
+ }
585
+ let end = i;
586
+ while (end < tag.length && ATTRIBUTE_NAME_CHAR.test(tag[end])) {
587
+ end++;
588
+ }
589
+ const value = quotedValueAt(tag, end);
590
+ if (value !== null) {
591
+ attributes.push([tag.slice(i, end), value.text]);
592
+ }
593
+ i = value === null ? end : value.next;
594
+ }
595
+ return attributes;
596
+ }
597
+
598
+ /**
599
+ * The quoted value of an attribute whose name ends at `at`: blanks, `=`, blanks, then text between matching quotes.
600
+ * @param tag - the tag
601
+ * @param at - where the name ends
602
+ * @returns the value and where reading goes on after its closing quote, or null when there is none
603
+ */
604
+ function quotedValueAt(tag: string, at: number): { text: string; next: number } | null {
605
+ let i = at;
606
+ while (i < tag.length && /\s/.test(tag[i])) {
607
+ i++;
608
+ }
609
+ if (tag[i] !== "=") {
610
+ return null;
611
+ }
612
+ i++;
613
+ while (i < tag.length && /\s/.test(tag[i])) {
614
+ i++;
615
+ }
616
+ const quote = tag[i];
617
+ const close = quote === '"' || quote === "'" ? tag.indexOf(quote, i + 1) : -1;
618
+ return close < 0 ? null : { text: tag.slice(i + 1, close), next: close + 1 };
619
+ }
620
+
339
621
  /**
340
622
  * Reads a color into a property and its alpha into the property's opacity.
341
623
  * @param raw - the color
342
624
  * @param scheme - the object's color scheme
343
625
  * @param use - how Graphviz draws it
344
626
  * @param property - the color property
345
- * @param opacity - its opacity property, or null when the alpha is dropped
627
+ * @param opacity - its opacity property, or null when the alpha is dropped (an outline's is its borderOpacity)
346
628
  * @param put - receives the values
347
629
  * @param at - the sites
348
630
  * @returns the alpha, or null when the color is absent
@@ -350,7 +632,7 @@ function labelText(
350
632
  function readColor(
351
633
  raw: string | null,
352
634
  scheme: string | null,
353
- use: "line" | "fill" | "edge",
635
+ use: "line" | "text" | "fill" | "edge",
354
636
  property: string,
355
637
  opacity: string | null,
356
638
  put: Put,
@@ -364,10 +646,13 @@ function readColor(
364
646
  return null;
365
647
  }
366
648
  put(property, c.color);
367
- if (opacity !== null) {
649
+ if (property === "borderColor") {
650
+ // a transparent outline is no outline (the caller sets its width to 0); a part-transparent one is drawn so
651
+ put("borderOpacity", c.alpha > 0 && c.alpha < 1 ? c.alpha : null);
652
+ } else if (opacity !== null) {
368
653
  put(opacity, c.alpha);
369
- } else if (c.alpha > 0 || property !== "borderColor") {
370
- // a transparent outline is no outline (the caller sets its width to 0); any other alpha is dropped, noted
654
+ } else {
655
+ // any other alpha is dropped, noted
371
656
  colorOpacity(c, null, at(property), false);
372
657
  }
373
658
  return c.alpha;
@@ -424,15 +709,23 @@ function readCommon(
424
709
  put: Put,
425
710
  at: At,
426
711
  ): void {
427
- if (tokens.includes("invis")) {
712
+ // Graphviz takes `invisible` for `invis` too (emit.c `parse_style`)
713
+ if (tokens.includes("invis") || tokens.includes("invisible")) {
428
714
  put("visible", false);
429
715
  }
430
- readColor(nonEmpty(get("fontcolor")), scheme, "line", "labelColor", null, put, at);
716
+ // a part-transparent font color draws its text so (`#377eb880`); an opaque one needs no opacity
717
+ const putFont: Put = (property, value) => {
718
+ put(property, property === "labelOpacity" && value === 1 ? null : value);
719
+ };
720
+ readColor(nonEmpty(get("fontcolor")), scheme, "text", "labelColor", "labelOpacity", putFont, at);
431
721
  const size = get("fontsize");
432
722
  put("labelSize", size === null ? null : canonNumber(size, at("labelSize")));
433
723
  const font = get("fontname");
434
724
  if (font !== null && font.length <= MAX_STYLE_VALUE_LENGTH) {
435
- put("labelFont", font);
725
+ const drawn = graphvizFont(font);
726
+ put("labelFont", drawn.family);
727
+ put("labelWeight", drawn.bold ? "bold" : null);
728
+ put("labelItalic", drawn.italic ? true : null);
436
729
  }
437
730
  const tip = get("tooltip") ?? get("edgetooltip");
438
731
  if (tip !== null) {
@@ -457,6 +750,143 @@ interface NodeRead {
457
750
  readonly color: string | null;
458
751
  }
459
752
 
753
+ /**
754
+ * Reads a node's label and, for a node that is not a cluster, its size and how it grows to hold the label.
755
+ * @param n - the node
756
+ * @param names - what the label's escapes stand for
757
+ */
758
+ function readNodeLabelAndSize(n: NodeRead, names: DotEscapeNames): void {
759
+ const { get, cx, put, at, shape } = n;
760
+ // label: a node shows its name unless it has a label (the tool-default rule); a cluster and a point show none
761
+ const label = n.point ? null : get(LABEL_ATTRIBUTE);
762
+ const shown = label === null ? cx.name : labelText(label, names, cx.html, RECORD.has(shape), at("label"));
763
+ readNodeLabel(n, label, shown, names);
764
+ // plain is its label alone at size 0: with an empty label Graphviz draws nothing, filled or not; an HTML label
765
+ // with no words (a table holding an IMG) still draws its cells
766
+ const htmlLabel = label !== null && (isHtmlString(label) || cx.html.has(`<${label}>`));
767
+ if (shape === "plain" && shown === "" && !htmlLabel) {
768
+ put("visible", false);
769
+ }
770
+ if (cx.cluster) {
771
+ readClusterBox(get, put);
772
+ } else {
773
+ readNodeSize(n, label, shown, htmlLabel, names);
774
+ }
775
+ }
776
+
777
+ /**
778
+ * Reads a node's label, its alignment and runs, and where it sits.
779
+ * @param n - the node
780
+ * @param label - its label, or null when it has none
781
+ * @param shown - the text it shows
782
+ * @param names - what the label's escapes stand for
783
+ */
784
+ function readNodeLabel(n: NodeRead, label: string | null, shown: string, names: DotEscapeNames): void {
785
+ const { get, cx, put, at, shape } = n;
786
+ // a cluster has no label of its own unless one is given, so an empty one is none
787
+ if (label !== null && !(cx.cluster && label === "")) {
788
+ put("label", shown);
789
+ readLabelAlign(label, names, cx.html, RECORD.has(shape), put);
790
+ readHtmlRuns(label, cx.html, n.scheme, put, at);
791
+ if (cx.cluster) {
792
+ put("labelPosition", clusterLabelPosition(get));
793
+ readClusterLabelPlace(get, put);
794
+ }
795
+ }
796
+ if (!cx.cluster && !n.point) {
797
+ const position = nodeLabelPosition(get);
798
+ put("labelPosition", position === "center" ? null : position);
799
+ }
800
+ }
801
+
802
+ /**
803
+ * Reads the size of a node that is not a cluster and how it grows to hold its label (a record's fields, or its
804
+ * label's fit).
805
+ * @param n - the node
806
+ * @param label - its label, or null when it has none
807
+ * @param shown - the text it shows
808
+ * @param htmlLabel - whether the label is HTML-like
809
+ * @param names - what the label's escapes stand for
810
+ */
811
+ function readNodeSize(
812
+ n: NodeRead,
813
+ label: string | null,
814
+ shown: string,
815
+ htmlLabel: boolean,
816
+ names: DotEscapeNames,
817
+ ): void {
818
+ const { get, put, at, shape } = n;
819
+ readSize(get, shape, n.point, put, at);
820
+ if (RECORD.has(shape) && label !== null && !htmlLabel) {
821
+ readRecord(n, label, names);
822
+ } else if (!n.point) {
823
+ readLabelFit(get, shape, label, shown, put);
824
+ readQuantum(n, label);
825
+ }
826
+ }
827
+
828
+ /** Graphviz's default node width and height, in points (0.75 and 0.5 inch). */
829
+ const DEFAULT_NODE_SIZE = { w: 54, h: 36 } as const;
830
+
831
+ /**
832
+ * Reads a record node as Graphviz lays it out: its size (at least its fields' unless `fixedsize`), the dividers
833
+ * between its fields as lines over the node, and each field's text lines where Graphviz centers or justifies them,
834
+ * in place of one label.
835
+ * @param n - the node
836
+ * @param label - its record label
837
+ * @param names - what the label's escapes stand for
838
+ */
839
+ function readRecord(n: NodeRead, label: string, names: DotEscapeNames): void {
840
+ const { get, put, at, cx } = n;
841
+ const pt = (attr: string, fallback: number): number => {
842
+ const v = inches(get(attr), at(attr === "width" ? "width" : "height"));
843
+ return v === null ? fallback : v * DOT_POINTS_PER_INCH;
844
+ };
845
+ const size = Number(get("fontsize") ?? 14);
846
+ const lay = layoutRecord(
847
+ label,
848
+ !cx.flip,
849
+ pt("width", DEFAULT_NODE_SIZE.w),
850
+ pt("height", DEFAULT_NODE_SIZE.h),
851
+ isTrue(get("fixedsize")),
852
+ Number.isFinite(size) && size > 0 ? size : 14,
853
+ (line) => dotLabelText(line, names),
854
+ );
855
+ const hw = lay.width / 2;
856
+ const hh = lay.height / 2;
857
+ const fx = (x: number): string => String(num6(hw > 0 ? x / hw : 0));
858
+ const fy = (y: number): string => String(num6(hh > 0 ? y / hh : 0));
859
+ put("width", num6(lay.width));
860
+ put("height", num6(lay.height));
861
+ put("label", "");
862
+ put(
863
+ "shapeLines",
864
+ lay.dividers.length === 0
865
+ ? null
866
+ : lay.dividers.map(([x0, y0, x1, y1]) => `${fx(x0)},${fy(y0)} ${fx(x1)},${fy(y1)}`).join("; "),
867
+ );
868
+ put(
869
+ "shapeTexts",
870
+ lay.texts.length === 0
871
+ ? null
872
+ : JSON.stringify(lay.texts.map((t) => [Number(fx(t.x)), Number(fy(t.y)), t.text])),
873
+ );
874
+ }
875
+
876
+ /**
877
+ * Reads a laid-out cluster's size from its `bb` ("llx,lly,urx,ury" in points), the box Graphviz draws round its
878
+ * members and its label; a cluster with no `bb` is drawn round its members.
879
+ * @param get - the cluster's attributes
880
+ * @param put - receives the values
881
+ */
882
+ function readClusterBox(get: Attr, put: Put): void {
883
+ const parts = (get("bb") ?? "").split(",").map(Number);
884
+ if (parts.length === 4 && parts.every(Number.isFinite) && parts[2] >= parts[0] && parts[3] >= parts[1]) {
885
+ put("width", num6(parts[2] - parts[0]));
886
+ put("height", num6(parts[3] - parts[1]));
887
+ }
888
+ }
889
+
460
890
  /**
461
891
  * Reads one node, or one cluster's container, the way Graphviz draws it.
462
892
  * @param get - its attributes
@@ -488,21 +918,72 @@ function readNode(get: Attr, cx: NodeContext, put: Put, at: At): void {
488
918
  } else {
489
919
  readNodeFill(n);
490
920
  }
921
+ readNodeBands(n);
491
922
  readNodeOutline(n);
492
923
  readNodeShape(n);
493
- // label: a node shows its name unless it has a label (the tool-default rule); a cluster and a point show none
494
- const label = n.point ? null : get(LABEL_ATTRIBUTE);
495
- if (label !== null) {
496
- put("label", labelText(label, names, cx.html, RECORD.has(shape), at("label")));
924
+ readNodeLabelAndSize(n, names);
925
+ if (nonEmpty(get("xlabel")) !== null) {
926
+ noteAt(at("label"), STYLE_ISSUE.NOT_CARRIED, "xlabel");
497
927
  }
498
- if (!cx.cluster) {
499
- readSize(get, shape, n.point, label ?? (n.point ? null : cx.name), put, at);
928
+ }
929
+
930
+ /**
931
+ * Where a node's label sits: its `labelloc` puts it against the top or bottom of the node's box, inside it; it is
932
+ * centered by default.
933
+ * @param get - the node's attributes
934
+ * @returns the position
935
+ */
936
+ function nodeLabelPosition(get: Attr): "inside-top" | "inside-bottom" | "center" {
937
+ const loc = (get("labelloc") ?? "").trim().toLowerCase();
938
+ if (loc.startsWith("t")) {
939
+ return "inside-top";
500
940
  }
501
- if (Number(get("peripheries")) > 1 || nonEmpty(get("xlabel")) !== null) {
502
- noteAt(at("label"), STYLE_ISSUE.NOT_CARRIED, nonEmpty(get("xlabel")) === null ? "peripheries" : "xlabel");
941
+ return loc.startsWith("b") ? "inside-bottom" : "center";
942
+ }
943
+
944
+ /** How far in from the cluster's side Graphviz draws a label justified left or right: half its label's padding. */
945
+ const CLUSTER_LABEL_INSET = 8;
946
+
947
+ /**
948
+ * Where Graphviz draws a cluster's label beyond its position: at a stored `lp` (its center) inside the cluster's
949
+ * `bb`, else `CLUSTER_LABEL_INSET` points in from the side `labeljust` puts it against.
950
+ * @param get - the cluster's attributes
951
+ * @param put - receives the values
952
+ */
953
+ function readClusterLabelPlace(get: Attr, put: Put): void {
954
+ const lp = (get("lp") ?? "").split(",").map(Number);
955
+ const bb = (get("bb") ?? "").split(",").map(Number);
956
+ if (lp.length === 2 && bb.length === 4 && [...lp, ...bb].every(Number.isFinite)) {
957
+ put("labelPosition", "center");
958
+ put("labelOffsetX", num6(lp[0] - (bb[0] + bb[2]) / 2));
959
+ put("labelOffsetY", num6((bb[1] + bb[3]) / 2 - lp[1]));
960
+ return;
961
+ }
962
+ const just = (get("labeljust") ?? "").trim().toLowerCase();
963
+ if (just.startsWith("l")) {
964
+ put("labelOffsetX", CLUSTER_LABEL_INSET);
965
+ } else if (just.startsWith("r")) {
966
+ put("labelOffsetX", -CLUSTER_LABEL_INSET);
503
967
  }
504
968
  }
505
969
 
970
+ /**
971
+ * Where a cluster's label sits: inside the cluster's box (Graphviz grows the box to hold it), at the top unless
972
+ * `labelloc` is `b`, centered unless `labeljust` is `l` or `r`.
973
+ * @param get - the cluster's attributes
974
+ * @returns the position
975
+ */
976
+ function clusterLabelPosition(
977
+ get: Attr,
978
+ ): `inside-${"top" | "top-left" | "top-right" | "bottom" | "bottom-left" | "bottom-right"}` {
979
+ const loc = (get("labelloc") ?? "").trim().toLowerCase().startsWith("b") ? "inside-bottom" : "inside-top";
980
+ const just = (get("labeljust") ?? "").trim().toLowerCase();
981
+ if (just.startsWith("l")) {
982
+ return `${loc}-left`;
983
+ }
984
+ return just.startsWith("r") ? `${loc}-right` : loc;
985
+ }
986
+
506
987
  /**
507
988
  * Reads a cluster's fill: its fill color when filled, else its background.
508
989
  * @param n - the cluster
@@ -513,6 +994,53 @@ function readClusterFill(n: NodeRead): void {
513
994
  const bg = nonEmpty(get("bgcolor"));
514
995
  const raw = filled ? (nonEmpty(get("fillcolor")) ?? n.color ?? bg ?? "lightgrey") : bg;
515
996
  readColor(raw, n.scheme, "fill", "fill", "fillOpacity", n.put, n.at);
997
+ if (!tokens.includes("striped")) {
998
+ readFillGradient(n, raw);
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Reads a gradient fill (`fillcolor="a:b"`, `gradientangle`): Graphviz runs it from the list's first color to
1004
+ * its second, `gradientangle` degrees counter-clockwise from left to right (0 by default), which is the neutral
1005
+ * angle's clockwise turn negated, from 0 up to 360. `style=radial` runs it out from the middle instead. A second color
1006
+ * left out is black; colors after the second are not drawn. A first color's `;fraction` makes Graphviz switch colors
1007
+ * at that fraction, which its SVG writes as stops at the fraction less 0.001 and at the fraction.
1008
+ * @param n - the node or cluster
1009
+ * @param raw - its fill color value
1010
+ */
1011
+ function readFillGradient(n: NodeRead, raw: string | null): void {
1012
+ const colon = raw === null ? -1 : raw.indexOf(":");
1013
+ if (raw === null || colon < 0) {
1014
+ return;
1015
+ }
1016
+ const radial = n.tokens.includes("radial");
1017
+ if (radial) {
1018
+ // ponytail: a radial gradientangle moves Graphviz's focus off the middle; graphty's radial ramp has no focus
1019
+ n.put("fillGradientKind", "radial");
1020
+ }
1021
+ const frac = Number(raw.slice(0, colon).split(";")[1]);
1022
+ if (frac > 0 && frac < 1) {
1023
+ n.put("fillGradientStops", `${String(num6(frac - 0.001))} ${String(num6(frac))}`);
1024
+ }
1025
+ const second = raw
1026
+ .slice(colon + 1)
1027
+ .split(":")[0]
1028
+ .split(";")[0];
1029
+ const c = canonColor(second === "" ? "black" : second, "graphviz", n.at("fillGradient"), {
1030
+ scheme: n.scheme,
1031
+ use: "fill",
1032
+ });
1033
+ if (c === null) {
1034
+ return;
1035
+ }
1036
+ n.put("fillGradient", c.color);
1037
+ if (radial) {
1038
+ return;
1039
+ }
1040
+ const angle = Number(nonEmpty(n.get("gradientangle")) ?? "0");
1041
+ // 0 up to 360: the neutral angle turns clockwise on screen, Graphviz's counter-clockwise
1042
+ const turned = Number.isFinite(angle) ? ((-angle % 360) + 360) % 360 : 0;
1043
+ n.put("fillGradientAngle", num6(turned) + 0);
516
1044
  }
517
1045
 
518
1046
  /**
@@ -520,9 +1048,104 @@ function readClusterFill(n: NodeRead): void {
520
1048
  * @param n - the node
521
1049
  */
522
1050
  function readNodeFill(n: NodeRead): void {
523
- const { shape, point } = n;
1051
+ const { point } = n;
524
1052
  const fillcolor = nonEmpty(n.get("fillcolor"));
525
- const filled =
1053
+ if (isFilled(n)) {
1054
+ const raw = fillcolor ?? n.color ?? (point ? "black" : "lightgrey");
1055
+ readColor(raw, n.scheme, "fill", "fill", "fillOpacity", n.put, n.at);
1056
+ // stripes on a box and wedges on an ellipse take the color list in place of a gradient
1057
+ const banded =
1058
+ (n.tokens.includes("striped") && BOX_LIKE.has(n.shape)) ||
1059
+ (n.tokens.includes("wedged") && ELLIPSE_LIKE.has(n.shape));
1060
+ if ((n.tokens.includes("filled") || n.tokens.includes("radial")) && !banded) {
1061
+ readFillGradient(n, raw);
1062
+ }
1063
+ } else if (fillcolor !== null) {
1064
+ noteAt(n.at("fill"), STYLE_ISSUE.UNFILLED, fillcolor.slice(0, MAX_STYLE_VALUE_LENGTH));
1065
+ }
1066
+ }
1067
+
1068
+ /**
1069
+ * Reads a node's striped or wedged fill (`style=striped` on a box, `style=wedged` on an ellipse) from its color
1070
+ * list, `color;fraction` separated by `:`: a color with no fraction shares what the others leave, the last color takes
1071
+ * what sized colors leave when none is unsized, and the shares stop at the whole
1072
+ * (https://graphviz.org/docs/attr-types/colorList/). Graphviz draws stripes left to right and
1073
+ * wedges counter-clockwise from 3 o'clock, which is the same pie as the wedges in reverse order clockwise from there.
1074
+ * @param n - the node
1075
+ */
1076
+ function readNodeBands(n: NodeRead): void {
1077
+ // a cluster is a box, so it takes stripes too (and never wedges)
1078
+ const striped = n.tokens.includes("striped") && (n.cx.cluster || BOX_LIKE.has(n.shape));
1079
+ const wedged = !n.cx.cluster && n.tokens.includes("wedged") && ELLIPSE_LIKE.has(n.shape);
1080
+ const list = nonEmpty(n.get("fillcolor")) ?? n.color;
1081
+ if ((!striped && !wedged) || !list?.includes(":")) {
1082
+ return;
1083
+ }
1084
+ const bands = colorBands(list, n.scheme, "fill", n.at(striped ? "stripeColors" : "pieColors"));
1085
+ if (bands.length === 0) {
1086
+ return;
1087
+ }
1088
+ const order = striped ? bands : [...bands].reverse();
1089
+ const prefix = striped ? "stripe" : "pie";
1090
+ n.put(`${prefix}Colors`, order.map((b) => b.color).join(" "));
1091
+ n.put(`${prefix}Sizes`, order.map((b) => String(b.size)).join(" "));
1092
+ if (striped) {
1093
+ n.put("stripeDirection", "right");
1094
+ } else {
1095
+ n.put("pieFitBox", true);
1096
+ n.put("pieRotation", 90);
1097
+ }
1098
+ }
1099
+
1100
+ /**
1101
+ * The bands of a Graphviz color list (`color;fraction` separated by `:`), in order: a color with no fraction shares
1102
+ * what the others leave, the last color takes what sized colors leave when none is unsized, and the shares stop at
1103
+ * the whole (https://graphviz.org/docs/attr-types/colorList/, emit.c `parseSegs`).
1104
+ * @param list - the color list
1105
+ * @param scheme - the color scheme
1106
+ * @param use - how Graphviz draws the colors
1107
+ * @param site - where it was read
1108
+ * @returns each band's color and share of the whole
1109
+ */
1110
+ function colorBands(
1111
+ list: string,
1112
+ scheme: string | null,
1113
+ use: "fill" | "edge",
1114
+ site: CanonSite,
1115
+ ): { color: string; size: number }[] {
1116
+ const parts = list.split(":").map((part) => {
1117
+ const [color, fraction] = part.split(";");
1118
+ const c = canonColor(color.trim(), "graphviz", site, { scheme, use });
1119
+ const f = fraction === undefined ? Number.NaN : Number(fraction);
1120
+ return { color: c?.color ?? null, fraction: Number.isFinite(f) && f >= 0 ? f : null };
1121
+ });
1122
+ const kept = parts.flatMap((p) => (p.color === null ? [] : [{ color: p.color, fraction: p.fraction }]));
1123
+ const given = kept.reduce((sum, p) => sum + (p.fraction ?? 0), 0);
1124
+ const unsized = kept.filter((p) => p.fraction === null).length;
1125
+ const share = unsized > 0 ? Math.max(0, 1 - given) / unsized : 0;
1126
+ let used = 0;
1127
+ const bands = kept.flatMap((p) => {
1128
+ const size = Math.min(p.fraction ?? share, 1 - used);
1129
+ used += Math.max(size, 0);
1130
+ return size > 0 ? [{ color: p.color, size: num6(size) }] : [];
1131
+ });
1132
+ // every color sized and short of the whole: Graphviz gives what is left to the last one (emit.c parseSegs)
1133
+ const last = bands.at(-1);
1134
+ if (last !== undefined && unsized === 0 && used < 1) {
1135
+ last.size = num6(last.size + 1 - used);
1136
+ }
1137
+ return bands;
1138
+ }
1139
+
1140
+ /**
1141
+ * Whether Graphviz fills a node: a point always; `filled` and `radial` any shape, `striped` a box and `wedged` an
1142
+ * ellipse only.
1143
+ * @param n - the node
1144
+ * @returns true when its box or shape is filled
1145
+ */
1146
+ function isFilled(n: NodeRead): boolean {
1147
+ const { shape, point } = n;
1148
+ return (
526
1149
  point ||
527
1150
  n.tokens.some(
528
1151
  (t) =>
@@ -530,13 +1153,8 @@ function readNodeFill(n: NodeRead): void {
530
1153
  t === "radial" ||
531
1154
  (t === "striped" && BOX_LIKE.has(shape)) ||
532
1155
  (t === "wedged" && ELLIPSE_LIKE.has(shape)),
533
- );
534
- if (filled) {
535
- const raw = fillcolor ?? n.color ?? (point ? "black" : "lightgrey");
536
- readColor(raw, n.scheme, "fill", "fill", "fillOpacity", n.put, n.at);
537
- } else if (fillcolor !== null) {
538
- noteAt(n.at("fill"), STYLE_ISSUE.UNFILLED, fillcolor.slice(0, MAX_STYLE_VALUE_LENGTH));
539
- }
1156
+ )
1157
+ );
540
1158
  }
541
1159
 
542
1160
  /**
@@ -551,7 +1169,8 @@ function readNodeOutline(n: NodeRead): void {
551
1169
  const outline = cx.cluster ? (nonEmpty(get("pencolor")) ?? n.color) : n.color;
552
1170
  const alpha = readColor(outline, n.scheme, "line", "borderColor", null, put, at);
553
1171
  const width = nonEmpty(get("penwidth")) ?? setLineWidth(tokens);
554
- if (alpha === 0) {
1172
+ // peripheries=0 draws no outline, on a node and on a cluster alike
1173
+ if (alpha === 0 || Number(nonEmpty(get("peripheries")) ?? "1") === 0) {
555
1174
  put("borderWidth", 0);
556
1175
  } else if (width !== null) {
557
1176
  put("borderWidth", penWidth(width, get("penwidth") !== null, at("borderWidth")));
@@ -564,24 +1183,331 @@ function readNodeOutline(n: NodeRead): void {
564
1183
  } else if (tokens.includes("dotted")) {
565
1184
  put("borderStyle", "dotted");
566
1185
  }
1186
+ if (!cx.cluster) {
1187
+ readNodeRings(n);
1188
+ }
1189
+ }
1190
+
1191
+ /** The outlines Graphviz draws round a node of these shapes when `peripheries` is not set. */
1192
+ export const SHAPE_PERIPHERIES: ReadonlyMap<string, number> = new Map([
1193
+ ["doublecircle", 2],
1194
+ ["doubleoctagon", 2],
1195
+ ["tripleoctagon", 3],
1196
+ ]);
1197
+
1198
+ /** How far apart Graphviz draws a node's outlines, center to center (`GAP`, lib/common/shapes.c). */
1199
+ export const PERIPHERY_GAP = 4;
1200
+
1201
+ /**
1202
+ * Reads a node's second outline (`peripheries=2`, `doublecircle`): Graphviz draws it `PERIPHERY_GAP` points outside
1203
+ * the first, in the same color, width and dashes. graphty draws one outer ring, so a third and later outline is
1204
+ * noted as not carried.
1205
+ * @param n - the node
1206
+ */
1207
+ function readNodeRings(n: NodeRead): void {
1208
+ const raw = nonEmpty(n.get("peripheries"));
1209
+ const count = raw === null ? (SHAPE_PERIPHERIES.get(n.shape) ?? 1) : Math.trunc(Number(raw));
1210
+ if (Number.isNaN(count) || count <= 1 || NO_OUTLINE.has(n.shape)) {
1211
+ return;
1212
+ }
1213
+ const c =
1214
+ n.color === null
1215
+ ? null
1216
+ : canonColor(n.color, "graphviz", n.at("outerBorderColor"), {
1217
+ scheme: n.scheme,
1218
+ use: "line",
1219
+ });
1220
+ if (c !== null && c.alpha === 0) {
1221
+ return;
1222
+ }
1223
+ const widthRaw = nonEmpty(n.get("penwidth")) ?? setLineWidth(n.tokens);
1224
+ const parsed = widthRaw === null ? Number.NaN : Number(widthRaw);
1225
+ const unset = n.tokens.includes("bold") ? 2 : 1;
1226
+ const pen = Number.isFinite(parsed) ? Math.max(0, parsed) : unset;
1227
+ n.put("outerBorderColor", c?.color ?? "#000000");
1228
+ n.put("outerBorderOpacity", c !== null && c.alpha < 1 ? c.alpha : null);
1229
+ n.put("outerBorderWidth", num6(pen));
1230
+ // the gap between the outline's outer edge and the ring's inner edge
1231
+ n.put("outerBorderOffset", num6(Math.max(0, PERIPHERY_GAP - pen)));
1232
+ if (n.tokens.includes("dashed") || n.tokens.includes("dotted")) {
1233
+ n.put("outerBorderStyle", n.tokens.includes("dashed") ? "dashed" : "dotted");
1234
+ }
1235
+ if (count > 2) {
1236
+ noteAt(n.at("outerBorderWidth"), STYLE_ISSUE.NOT_CARRIED, `peripheries=${String(count)}`);
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * Reads a node's or cluster's shape: a cluster is a rectangle, rounded by the `rounded` style as a box is. Graphviz draws
1242
+ * a striped box with square corners whatever `rounded` says.
1243
+ * @param n - the node
1244
+ */
1245
+ function readNodeShape(n: NodeRead): void {
1246
+ const rounded = n.tokens.includes("rounded") && !n.tokens.includes("striped");
1247
+ const turned = turnedPolygon(n);
1248
+ if (n.cx.cluster) {
1249
+ n.put("shape", rounded ? "round-rectangle" : "rectangle");
1250
+ } else if (n.shape === "polygon") {
1251
+ readPolygon(n, rounded);
1252
+ } else if (turned !== null) {
1253
+ n.put("shape", "polygon");
1254
+ n.put("shapePoints", cornersText(graphvizDrawnCorners(turned)));
1255
+ n.put("shapePointsStretch", false);
1256
+ } else if (NAMED_OUTLINES.has(n.shape)) {
1257
+ n.put("shape", "polygon");
1258
+ n.put("shapePoints", NAMED_OUTLINES.get(n.shape) ?? null);
1259
+ // a polygon shape's corners are where Graphviz draws them, one reaching past its box too
1260
+ n.put("shapePointsStretch", false);
1261
+ } else if (GRAPHVIZ_GLYPH_SHAPES.has(n.shape)) {
1262
+ const size = (name: string): number | null => {
1263
+ const v = inches(n.get(name), n.at(name));
1264
+ return v === null ? null : v * DOT_POINTS_PER_INCH;
1265
+ };
1266
+ putGlyph(
1267
+ n.shape,
1268
+ size("width"),
1269
+ size("height"),
1270
+ n.put,
1271
+ () => {
1272
+ const raw = nonEmpty(n.get("penwidth")) ?? setLineWidth(n.tokens);
1273
+ const pen = raw === null ? Number.NaN : Number(raw);
1274
+ if (Number.isFinite(pen)) {
1275
+ return Math.max(0, pen);
1276
+ }
1277
+ return n.tokens.includes("bold") ? 2 : 1;
1278
+ },
1279
+ () =>
1280
+ n.color === null
1281
+ ? null
1282
+ : (canonColor(n.color, "graphviz", n.at("outerBorderColor"), { scheme: n.scheme, use: "line" })
1283
+ ?.color ?? null),
1284
+ );
1285
+ } else if (n.shapeRaw !== null) {
1286
+ const shape =
1287
+ rounded && BOX_LIKE.has(n.shape)
1288
+ ? "round-rectangle"
1289
+ : canonEnum("shape", n.shapeRaw, "graphviz", n.at("shape"));
1290
+ // a filled shape=none is its box filled, with no outline (its border is 0): a rectangle, not a node with no body
1291
+ n.put("shape", shape === "none" && isFilled(n) ? "rectangle" : shape);
1292
+ }
1293
+ }
1294
+
1295
+ /** The polygon shapes `orientation` turns, by their own sides and orientation (`shapes.c`). */
1296
+ const TURNABLE_POLYGONS: ReadonlyMap<string, { readonly sides: number; readonly orientation: number }> = new Map([
1297
+ ...[...BOX_LIKE].map((name) => [name, { sides: 4, orientation: 0 }] as const),
1298
+ ["diamond", { sides: 4, orientation: 45 }],
1299
+ ["hexagon", { sides: 6, orientation: 0 }],
1300
+ ["octagon", { sides: 8, orientation: 0 }],
1301
+ ]);
1302
+
1303
+ /**
1304
+ * The polygon a named polygon shape is when its `orientation` turns it off its own axes (a box turned 30 degrees),
1305
+ * or null when it is drawn as its shape: Graphviz adds the attribute to the shape's own orientation.
1306
+ * @param n - the node
1307
+ * @returns the polygon, or null
1308
+ */
1309
+ function turnedPolygon(n: NodeRead): Parameters<typeof graphvizPolygonCorners>[0] | null {
1310
+ const turn = Number(nonEmpty(n.get("orientation")) ?? "0");
1311
+ const own = TURNABLE_POLYGONS.get(n.shape) ?? GRAPHVIZ_POLYGON_SHAPES.get(n.shape);
1312
+ if (own === undefined || !Number.isFinite(turn) || turn % 360 === 0) {
1313
+ return null;
1314
+ }
1315
+ const polygon = {
1316
+ distortion: 0,
1317
+ skew: 0,
1318
+ ...own,
1319
+ orientation: own.orientation + Math.max(-360, turn),
1320
+ };
1321
+ // a quarter turn of a box is a box again
1322
+ return isGraphvizBox(polygon) ? null : polygon;
1323
+ }
1324
+
1325
+ /**
1326
+ * Reads a `shape=polygon` node: a box when it is one (four sides, no distortion or skew, square to the axes), an
1327
+ * ellipse below three sides, else its corners as Graphviz places them.
1328
+ * @param n - the node
1329
+ * @param rounded - whether its style rounds a box's corners
1330
+ */
1331
+ function readPolygon(n: NodeRead, rounded: boolean): void {
1332
+ const number = (name: string, fallback: number, least: number): number => {
1333
+ const raw = nonEmpty(n.get(name));
1334
+ const v = raw === null ? Number.NaN : Number(raw);
1335
+ return Number.isFinite(v) ? Math.max(least, v) : fallback;
1336
+ };
1337
+ const polygon = {
1338
+ sides: Math.trunc(number("sides", 4, 0)),
1339
+ distortion: number("distortion", 0, -100),
1340
+ skew: number("skew", 0, -100),
1341
+ orientation: number("orientation", 0, -360),
1342
+ };
1343
+ if (isGraphvizBox(polygon)) {
1344
+ n.put("shape", rounded ? "round-rectangle" : "rectangle");
1345
+ } else if (polygon.sides < 3) {
1346
+ n.put("shape", "ellipse");
1347
+ } else {
1348
+ n.put("shape", "polygon");
1349
+ n.put("shapePoints", cornersText(graphvizDrawnCorners(polygon)));
1350
+ n.put("shapePointsStretch", false);
1351
+ }
1352
+ }
1353
+
1354
+ /**
1355
+ * A Graphviz polygon's corners where Graphviz draws them, as fractions of its box's half width and half height: it
1356
+ * grows a polygon that falls short of the box along an axis until it reaches the box's sides, and keeps one that
1357
+ * reaches past them (a distortion, a skew, an egg) as it is (shapes.c `poly_init`: `scalex = bb.x / xmax`).
1358
+ * @param polygon - the polygon's attributes
1359
+ * @returns the corners
1360
+ */
1361
+ function graphvizDrawnCorners(polygon: Parameters<typeof graphvizPolygonCorners>[0]): [number, number][] {
1362
+ const corners = graphvizPolygonCorners(polygon);
1363
+ const grow = (axis: 0 | 1): number => Math.max(1, 1 / Math.max(...corners.map((c) => Math.abs(c[axis]))));
1364
+ const [gx, gy] = [grow(0), grow(1)];
1365
+ return corners.map(([x, y]) => [x * gx, y * gy]);
1366
+ }
1367
+
1368
+ /**
1369
+ * Corners as a `shapePoints` value.
1370
+ * @param corners - the corners
1371
+ * @returns `x,y x,y ...`
1372
+ */
1373
+ function cornersText(corners: readonly (readonly [number, number])[]): string {
1374
+ return corners.map(([x, y]) => `${num6(x)},${num6(y)}`).join(" ");
1375
+ }
1376
+
1377
+ /** The outline of each Graphviz polygon shape graphty draws as a polygon of its corners, by shape name. */
1378
+ const NAMED_OUTLINES: ReadonlyMap<string, string> = new Map(
1379
+ [...GRAPHVIZ_POLYGON_SHAPES].map(([name, polygon]) => [name, cornersText(graphvizDrawnCorners(polygon))] as const),
1380
+ );
1381
+
1382
+ /**
1383
+ * A Graphviz shape drawn as pieces of its own (`graphvizGlyph`) at a node's size, as the neutral values that draw it:
1384
+ * a polygon of its outline drawn where it says, its further pieces (`shapeParts`) and lines (`shapeLines`), each as
1385
+ * fractions of the node's half width and half height. An `underline` is filled with no line round it, its line alone
1386
+ * drawn in the pen's width.
1387
+ * @param shape - the shape name, lowercase
1388
+ * @param width - the node's width in points, or null for Graphviz's default
1389
+ * @param height - its height, or null
1390
+ * @param put - receives the values
1391
+ * @param pen - the node's pen width, for an outline drawn as a line alone and a ring
1392
+ * @param color - the node's pen color, for a ring
1393
+ */
1394
+ export function putGlyph(
1395
+ shape: string,
1396
+ width: number | null,
1397
+ height: number | null,
1398
+ put: (property: string, value: StyleValue | null) => void,
1399
+ pen: () => number,
1400
+ color: () => string | null,
1401
+ ): void {
1402
+ const text = glyphTexts(shape, width, height);
1403
+ if (text === null) {
1404
+ return;
1405
+ }
1406
+ put("shape", "polygon");
1407
+ put("shapePoints", text.outline);
1408
+ put("shapePointsStretch", false);
1409
+ put("shapeParts", text.parts);
1410
+ put("shapeLines", text.lines);
1411
+ if (text.unstroked) {
1412
+ put("shapeLineWidth", pen());
1413
+ put("borderWidth", 0);
1414
+ }
1415
+ if (text.ring !== null) {
1416
+ // drawn in the pen, its gap the distance less a pen's width (the outline's half and its own)
1417
+ put("outerBorderColor", color() ?? "#000000");
1418
+ put("outerBorderWidth", num6(pen()));
1419
+ put("outerBorderOffset", num6(Math.max(0, text.ring - pen())));
1420
+ }
1421
+ }
1422
+
1423
+ /**
1424
+ * A Graphviz glyph's pieces as `shapePoints`, `shapeParts` and `shapeLines` text at a node's size, or null for a shape
1425
+ * that is not one.
1426
+ * @param shape - the shape name, lowercase
1427
+ * @param width - the node's width in points, or null for Graphviz's default
1428
+ * @param height - its height, or null
1429
+ * @returns the texts (parts and lines null when there are none)
1430
+ */
1431
+ function glyphTexts(
1432
+ shape: string,
1433
+ width: number | null,
1434
+ height: number | null,
1435
+ ): { outline: string; parts: string | null; lines: string | null; unstroked: boolean; ring: number | null } | null {
1436
+ const glyph = graphvizGlyph(shape, width, height);
1437
+ if (glyph === null) {
1438
+ return null;
1439
+ }
1440
+ // fractions of the half width and half height the glyph was laid out at
1441
+ const half = (v: number | null, fallback: number): number => (v !== null && v > 0 ? v : fallback) / 2;
1442
+ const [W, H] = [half(width, 54), half(height, 36)];
1443
+ const list = (points: readonly (readonly [number, number])[]): string =>
1444
+ cornersText(points.map(([x, y]) => [x / W, y / H] as const));
1445
+ const lists = (all: readonly (readonly (readonly [number, number])[])[]): string | null =>
1446
+ all.length === 0 ? null : all.map(list).join("; ");
1447
+ return {
1448
+ outline: list(glyph.outline),
1449
+ parts: lists(glyph.parts),
1450
+ lines: lists(glyph.lines),
1451
+ unstroked: glyph.unstroked === true,
1452
+ ring: glyph.ring ?? null,
1453
+ };
1454
+ }
1455
+
1456
+ /**
1457
+ * The corners of a Graphviz shape graphty draws as a polygon (`house`, `cylinder`, `note` ...), as a `shapePoints`
1458
+ * value, or undefined for a shape graphty draws by name.
1459
+ * @param shape - the shape name, lowercase
1460
+ * @returns the corners, or undefined
1461
+ */
1462
+ export function graphvizOutline(shape: string): string | undefined {
1463
+ return NAMED_OUTLINES.get(shape);
1464
+ }
1465
+
1466
+ /**
1467
+ * Whether Graphviz draws a shape with no outline (`plain`, `plaintext`, `none`).
1468
+ * @param shape - the shape name, lowercase
1469
+ * @returns true for a shape with no outline
1470
+ */
1471
+ export function graphvizHasNoOutline(shape: string): boolean {
1472
+ return NO_OUTLINE.has(shape);
567
1473
  }
568
1474
 
1475
+ /** The shape name of each outline in `NAMED_OUTLINES`, so a written node keeps its Graphviz shape. */
1476
+ const OUTLINE_NAMES: ReadonlyMap<string, string> = new Map([...NAMED_OUTLINES].map(([name, text]) => [text, name]));
1477
+
569
1478
  /**
570
- * Reads a node's or cluster's shape: a cluster is a rectangle, rounded by the `rounded` style as a box is.
571
- * @param n - the node
1479
+ * The Graphviz glyph shape a polygon is at a node's size, its outline, parts and lines alike, so a written node keeps
1480
+ * its shape (a ribosome site and a protease site share their outline and differ in their stem).
1481
+ * @param get - the node's values
1482
+ * @param width - the node's width in points, or null
1483
+ * @param height - its height, or null
1484
+ * @returns the shape name and whether it is filled with no line round it, or undefined
572
1485
  */
573
- function readNodeShape(n: NodeRead): void {
574
- const rounded = n.tokens.includes("rounded");
575
- if (n.cx.cluster) {
576
- n.put("shape", rounded ? "round-rectangle" : "rectangle");
577
- } else if (n.shapeRaw !== null) {
578
- n.put(
579
- "shape",
580
- rounded && BOX_LIKE.has(n.shape)
581
- ? "round-rectangle"
582
- : canonEnum("shape", n.shapeRaw, "graphviz", n.at("shape")),
583
- );
1486
+ function glyphOf(
1487
+ get: Get,
1488
+ width: number | null,
1489
+ height: number | null,
1490
+ ): { name: string; unstroked: boolean } | undefined {
1491
+ const text = (property: string): string | null => {
1492
+ const v = get(property);
1493
+ return typeof v === "string" ? v.trim() : null;
1494
+ };
1495
+ const outline = text("shapePoints");
1496
+ if (outline === null) {
1497
+ return undefined;
1498
+ }
1499
+ // a glyph with another number of corners cannot match: skip writing its texts, which is most of the work
1500
+ const corners = outline === "" ? 0 : outline.split(/\s+/).length;
1501
+ for (const name of GRAPHVIZ_GLYPH_SHAPES) {
1502
+ if (graphvizGlyph(name, width, height)?.outline.length !== corners) {
1503
+ continue;
1504
+ }
1505
+ const g = glyphTexts(name, width, height);
1506
+ if (g !== null && g.outline === outline && g.parts === text("shapeParts") && g.lines === text("shapeLines")) {
1507
+ return { name, unstroked: g.unstroked };
1508
+ }
584
1509
  }
1510
+ return undefined;
585
1511
  }
586
1512
 
587
1513
  /**
@@ -602,15 +1528,14 @@ function penWidth(raw: string, attribute: boolean, site: CanonSite): number | nu
602
1528
 
603
1529
  /**
604
1530
  * Reads a node's width and height: exact where Graphviz draws them exactly (`fixedsize`, an empty label that is not
605
- * a record's, a point), otherwise minimums it grows to fit the label, noted.
1531
+ * a record's, a point), otherwise minimums it grows to fit the label (`readLabelFit`).
606
1532
  * @param get - the node's attributes
607
1533
  * @param shape - its shape, lowercase
608
1534
  * @param point - whether it is a point
609
- * @param label - the label it shows, or null
610
1535
  * @param put - receives the values
611
1536
  * @param at - the sites
612
1537
  */
613
- function readSize(get: Attr, shape: string, point: boolean, label: string | null, put: Put, at: At): void {
1538
+ function readSize(get: Attr, shape: string, point: boolean, put: Put, at: At): void {
614
1539
  let w = inches(get("width"), at("width"));
615
1540
  let h = inches(get("height"), at("height"));
616
1541
  const same = point || CIRCLE_LIKE.has(shape) || isTrue(get("regular"));
@@ -636,14 +1561,86 @@ function readSize(get: Attr, shape: string, point: boolean, label: string | null
636
1561
  } else if (same) {
637
1562
  ww = hh = Math.max(w ?? 0, h ?? 0);
638
1563
  }
639
- const exact = point || isTrue(get("fixedsize")) || (label === "" && !RECORD.has(shape));
640
- if (!exact) {
641
- noteAt(at("width"), STYLE_ISSUE.SIZE_MINIMUM, "grows to fit its label");
642
- }
643
1564
  put("width", ww === null ? null : canonNumber(ww, at("width"), DOT_POINTS_PER_INCH));
644
1565
  put("height", hh === null ? null : canonNumber(hh, at("height"), DOT_POINTS_PER_INCH));
645
1566
  }
646
1567
 
1568
+ /**
1569
+ * Reads the graph's `quantum` for a node that grows to hold its label: Graphviz rounds the padded label box up to a
1570
+ * multiple of it before fitting the outline round it (`poly_init`), so the node is at least one quantum across, an
1571
+ * ellipse's box times the square root of 2.
1572
+ * @param n - the node
1573
+ * @param label - its `label` attribute, or null
1574
+ */
1575
+ function readQuantum(n: NodeRead, label: string | null): void {
1576
+ const { get, put, shape } = n;
1577
+ let scale: number | null = BOX_LIKE.has(shape) ? 1 : null;
1578
+ if (ELLIPSE_LIKE.has(shape)) {
1579
+ scale = Math.SQRT2;
1580
+ }
1581
+ const w = Number(get("width") ?? Number.NaN);
1582
+ const h = Number(get("height") ?? Number.NaN);
1583
+ // ponytail: one quantum is the least size; a label box over one quantum is not rounded up to the next, and only
1584
+ // boxes and ellipses with their size given are read (other polygons fit their box by factors of their own)
1585
+ if (n.cx.quantum === 0 || scale === null || label === "" || isTrue(get("fixedsize")) || !(w >= 0 && h >= 0)) {
1586
+ return;
1587
+ }
1588
+ const least = n.cx.quantum * scale;
1589
+ put("width", num6(Math.max(w * DOT_POINTS_PER_INCH, least)));
1590
+ put("height", num6(Math.max(h * DOT_POINTS_PER_INCH, least)));
1591
+ }
1592
+
1593
+ /** Graphviz's padding around a node's label when it has no `margin`: 8 points left and right, 4 above and below. */
1594
+ const LABEL_PAD = { x: 8, y: 4 } as const;
1595
+
1596
+ /** Graphviz's line height, as a multiple of the font size (`LINESPACING`, lib/common/const.h). */
1597
+ const LINE_SPACING = 1.2;
1598
+
1599
+ /**
1600
+ * Reads how a node that is not drawn at a fixed size grows to hold its label (`poly_init`, lib/common/shapes.c):
1601
+ * its padded label box fits inside its outline, the `margin` (else 8 points by 4) on each side, and a circle, a
1602
+ * square or a `regular` node stays as wide as it is tall. The neutral label box is one font size per line, so the
1603
+ * rest of Graphviz's line height (1.2 font sizes) goes into the vertical padding.
1604
+ * @param get - the node's attributes
1605
+ * @param shape - its shape, lowercase
1606
+ * @param label - its `label` attribute, or null
1607
+ * @param shown - the label it shows
1608
+ * @param put - receives the values
1609
+ */
1610
+ function readLabelFit(get: Attr, shape: string, label: string | null, shown: string, put: Put): void {
1611
+ if (isTrue(get("fixedsize")) || (label === "" && !RECORD.has(shape))) {
1612
+ return;
1613
+ }
1614
+ const lines = Math.max(1, shown.replace(/\n$/, "").split("\n").length);
1615
+ const size = Number(get("fontsize") ?? 14);
1616
+ const fontSize = Number.isFinite(size) && size > 0 ? size : 14;
1617
+ const margin = (get("margin") ?? "")
1618
+ .split(",")
1619
+ .filter((text) => text.trim() !== "")
1620
+ .map(Number)
1621
+ .filter(isLength);
1622
+ // plain is plaintext with a margin of 0
1623
+ const pad = shape === "plain" ? { x: 0, y: 0 } : LABEL_PAD;
1624
+ const padX = margin.length > 0 ? margin[0] * DOT_POINTS_PER_INCH : pad.x;
1625
+ const padY = margin.length > 0 ? (margin[1] ?? margin[0]) * DOT_POINTS_PER_INCH : pad.y;
1626
+ // a decorated box holds its label as a box does, exactly
1627
+ put("labelFit", GRAPHVIZ_DECORATED_BOXES.has(shape) ? "bounds" : "inside");
1628
+ put("labelPaddingX", num6(padX));
1629
+ put("labelPaddingY", num6(padY + ((LINE_SPACING - 1) * fontSize * lines) / 2));
1630
+ if (CIRCLE_LIKE.has(shape) || isTrue(get("regular"))) {
1631
+ put("labelFitSquare", true);
1632
+ }
1633
+ }
1634
+
1635
+ /**
1636
+ * Whether a `margin` part is a usable length: a number at least 0.
1637
+ * @param value - the part
1638
+ * @returns whether it is
1639
+ */
1640
+ function isLength(value: number): boolean {
1641
+ return Number.isFinite(value) && value >= 0;
1642
+ }
1643
+
647
1644
  /**
648
1645
  * Reads one edge the way Graphviz draws it.
649
1646
  * @param get - its attributes
@@ -656,6 +1653,7 @@ function readEdge(get: Attr, cx: EdgeContext, put: Put, at: At): void {
656
1653
  const scheme = get("colorscheme");
657
1654
  readCommon(get, tokens, scheme, cx.names, put, at);
658
1655
  readColor(nonEmpty(get("color")), scheme, "edge", "color", "opacity", put, at);
1656
+ readEdgeSegments(nonEmpty(get("color")), scheme, put, at);
659
1657
  readEdgeLine(get, tokens, put, at);
660
1658
  readEdgeArrows(get, cx, put, at);
661
1659
  for (const [attribute, property] of [
@@ -663,14 +1661,263 @@ function readEdge(get: Attr, cx: EdgeContext, put: Put, at: At): void {
663
1661
  ["headlabel", "targetLabel"],
664
1662
  ["taillabel", "sourceLabel"],
665
1663
  ] as const) {
666
- const raw = get(attribute);
1664
+ // an edge's xlabel is drawn beside its middle, as its label is: it is the label when there is no other
1665
+ const raw = attribute === LABEL_ATTRIBUTE ? (get(attribute) ?? nonEmpty(get("xlabel"))) : get(attribute);
667
1666
  if (raw !== null) {
668
1667
  put(property, labelText(raw, cx.names, cx.html, false, at(property)));
1668
+ if (property === "label") {
1669
+ readLabelAlign(raw, cx.names, cx.html, false, put);
1670
+ readHtmlRuns(raw, cx.html, scheme, put, at);
1671
+ }
669
1672
  }
670
1673
  }
671
- if (nonEmpty(get("xlabel")) !== null) {
1674
+ if (get(LABEL_ATTRIBUTE) !== null && nonEmpty(get("xlabel")) !== null) {
672
1675
  noteAt(at("label"), STYLE_ISSUE.NOT_CARRIED, "xlabel");
673
1676
  }
1677
+ readEndLabelFace(get, scheme, put, at);
1678
+ }
1679
+
1680
+ /**
1681
+ * Reads the face of an edge's head and tail labels where it is not the label's: `labelfontsize`, `labelfontcolor` and
1682
+ * `labelfontname` (https://graphviz.org/docs/attrs/labelfontsize/), each the edge's `fontsize`, `fontcolor` and
1683
+ * `fontname` when unset, which the end labels then take from the label.
1684
+ * @param get - the edge's attributes
1685
+ * @param scheme - its color scheme
1686
+ * @param put - receives the values
1687
+ * @param at - the sites
1688
+ */
1689
+ function readEndLabelFace(get: Attr, scheme: string | null, put: Put, at: At): void {
1690
+ const ends = (["target", "source"] as const).filter(
1691
+ (end) => get(end === "target" ? "headlabel" : "taillabel") !== null,
1692
+ );
1693
+ const size = get("labelfontsize");
1694
+ const font = get("labelfontname");
1695
+ for (const end of ends) {
1696
+ readColor(nonEmpty(get("labelfontcolor")), scheme, "text", `${end}LabelColor`, null, put, at);
1697
+ if (size !== null) {
1698
+ put(`${end}LabelSize`, canonNumber(size, at(`${end}LabelSize`)));
1699
+ }
1700
+ // ponytail: the family only; a bold or italic end-label face is drawn in the label's weight and slant
1701
+ if (font !== null && font.length <= MAX_STYLE_VALUE_LENGTH) {
1702
+ put(`${end}LabelFont`, graphvizFont(font).family);
1703
+ }
1704
+ }
1705
+ }
1706
+
1707
+ /**
1708
+ * Reads an edge's colored segments: a color list with a `;fraction` in it (`red;0.3:blue`) draws the line in one piece
1709
+ * per color along its length, which is a linear gradient with a hard switch at each band's end. A list with no
1710
+ * fraction draws parallel lines, which graphty does not draw (the line is in the first color).
1711
+ * @param list - the edge's `color`
1712
+ * @param scheme - its color scheme
1713
+ * @param put - receives the values
1714
+ * @param at - the sites
1715
+ */
1716
+ function readEdgeSegments(list: string | null, scheme: string | null, put: Put, at: At): void {
1717
+ if (list === null || !list.includes(":") || !list.includes(";")) {
1718
+ return;
1719
+ }
1720
+ const bands = colorBands(list, scheme, "edge", at("gradientColors"));
1721
+ if (bands.length < 2) {
1722
+ return;
1723
+ }
1724
+ const colors: string[] = [];
1725
+ const stops: number[] = [];
1726
+ let start = 0;
1727
+ for (const band of bands) {
1728
+ colors.push(band.color, band.color);
1729
+ stops.push(num6(start), num6(Math.min(1, start + band.size)));
1730
+ start += band.size;
1731
+ }
1732
+ put("gradientKind", "linear");
1733
+ put("gradientColors", colors.join(" "));
1734
+ put("gradientStops", stops.join(" "));
1735
+ }
1736
+
1737
+ /**
1738
+ * What an edge's label places are read against: the nodes' centers and sizes.
1739
+ * @param snapshot - the graph
1740
+ * @returns the context
1741
+ */
1742
+ function labelPlaceContext(snapshot: GraphSnapshot): LabelPlaceContext {
1743
+ const column = snapshot.nodes.byRole("position");
1744
+ return {
1745
+ center: (i) => {
1746
+ const v = column?.isSet(i) === true ? (column.value(i) as ArrayLike<number>) : null;
1747
+ return v === null ? null : [v[0], v[1]];
1748
+ },
1749
+ node: (i, name) => nonEmpty(cellText(snapshot.nodes, name, i)),
1750
+ external: (cellText(snapshot.graph, "layout", 0)?.trim().toLowerCase() ?? "dot") !== "dot",
1751
+ };
1752
+ }
1753
+
1754
+ /**
1755
+ * The route Graphviz laid an edge along, from its `pos`: `[e,x,y] [s,x,y]` arrow tips, then the B-spline's `3n + 1`
1756
+ * control points (y up), flattened into bends between the two nodes' centers. Null for an edge Graphviz drew
1757
+ * straight, a `pos` that is not a spline, or nodes without positions.
1758
+ * @param snapshot - the graph
1759
+ * @param e - the edge
1760
+ * @param pos - its `pos` text
1761
+ * @returns the `bends` value, or null
1762
+ */
1763
+ function splineBends(snapshot: GraphSnapshot, e: number, pos: string | null): string | null {
1764
+ if (pos === null) {
1765
+ return null;
1766
+ }
1767
+ const column = snapshot.nodes.byRole("position");
1768
+ const center = (i: number): BendPoint | null => {
1769
+ const v = column?.isSet(i) === true ? (column.value(i) as ArrayLike<number>) : null;
1770
+ return v === null ? null : [v[0], v[1]];
1771
+ };
1772
+ const [source, target] = [center(snapshot.edgeSource(e)), center(snapshot.edgeTarget(e))];
1773
+ if (source === null || target === null) {
1774
+ return null;
1775
+ }
1776
+ return graphvizPosBends(source, target, pos);
1777
+ }
1778
+
1779
+ /**
1780
+ * The bends of an edge Graphviz laid, from its `pos` text (`[e,x,y] [s,x,y]` arrow tips, then each spline's
1781
+ * `3n + 1` control points, y up, splines separated by `;`), between the two nodes' centers in the same coordinates.
1782
+ * @param source - the source node's center
1783
+ * @param target - the target node's center
1784
+ * @param pos - the edge's `pos` text
1785
+ * @returns the `bends` value, or null for a straight route or a `pos` that is not a spline
1786
+ * @category Styles
1787
+ */
1788
+ export function graphvizPosBends(source: BendPoint, target: BendPoint, pos: string): string | null {
1789
+ // the arrow tips of the first spline's start and the last spline's end
1790
+ const tip = (spline: string | undefined, flag: "s" | "e"): BendPoint | null => {
1791
+ const token = (spline ?? "")
1792
+ .trim()
1793
+ .split(/\s+/)
1794
+ .find((t) => t.startsWith(`${flag},`));
1795
+ const [x, y] = (token ?? "").slice(2).split(",").map(Number);
1796
+ return token !== undefined && Number.isFinite(x) && Number.isFinite(y) ? [x, y] : null;
1797
+ };
1798
+ const parts = pos.split(";");
1799
+ const tips = { start: tip(parts[0], "s"), end: tip(parts.at(-1), "e") };
1800
+ // ponytail: the splines of a concentrated edge (";"-separated) are joined end to end
1801
+ const splines = parts.map((spline) =>
1802
+ spline
1803
+ .trim()
1804
+ .split(/\s+/)
1805
+ .filter((t) => !/^[es],/.test(t))
1806
+ .map((t): BendPoint => {
1807
+ const [x, y] = t.split(",").map(Number);
1808
+ return [x, y];
1809
+ }),
1810
+ );
1811
+ return graphvizRouteBends(source, target, splines, tips);
1812
+ }
1813
+
1814
+ /** How far off the line between its nodes' centers, in points, a Graphviz route may pass and still be straight. */
1815
+ const GRAPHVIZ_STRAIGHT_POINTS = 1;
1816
+
1817
+ /** How far apart Graphviz's neato lays the loops on one node, across (x) and along (y) its side, in points. */
1818
+ const GRAPHVIZ_LOOP_STEP = { x: 18, y: 9 } as const;
1819
+
1820
+ /**
1821
+ * The self-loops Graphviz lays itself, with no `pos` of their own and no ports: neato's `selfRight` (splines.c), on
1822
+ * the node's right side, the k-th of a node's n loops two cubic curves from its center out to `rw + 18k` points right
1823
+ * of it and back, `9k / n` points above and then below its center line (measured on Graphviz 14.1.2's and 2.43's
1824
+ * drawings of nodes from 0.33 to 1 inch).
1825
+ * @param snapshot - the graph
1826
+ * @returns the bends of an edge that is such a loop, or null
1827
+ */
1828
+ function graphvizLoops(snapshot: GraphSnapshot): { bendsOf: (e: number, get: Attr) => string | null } {
1829
+ const portless = (get: Attr): boolean =>
1830
+ nonEmpty(get("tailport")) === null &&
1831
+ nonEmpty(get("headport")) === null &&
1832
+ nonEmpty(get(POS_ATTRIBUTE)) === null;
1833
+ const onNode = new Map<number, number[]>();
1834
+ // ponytail: ortho routes a loop round the node's corners and none draws no edge; both keep the element's loop.
1835
+ const splines = (nonEmpty(cellText(snapshot.graph, "splines", 0)) ?? "").toLowerCase();
1836
+ // a file graphty wrote keeps its loops' look in its own block
1837
+ const laid =
1838
+ splines !== "ortho" && splines !== "none" && nonEmpty(cellText(snapshot.graph, STYLE_BLOCK_NAME, 0)) === null;
1839
+ for (let e = 0; e < snapshot.edgeCount && laid; e++) {
1840
+ const n = snapshot.edgeSource(e);
1841
+ if (n === snapshot.edgeTarget(e) && portless((name) => cellText(snapshot.edges, name, e))) {
1842
+ onNode.set(n, [...(onNode.get(n) ?? []), e]);
1843
+ }
1844
+ }
1845
+ const inches = (n: number, name: string, fallback: number): number => {
1846
+ const v = Number(nonEmpty(cellText(snapshot.nodes, name, n)) ?? Number.NaN);
1847
+ return (Number.isFinite(v) && v > 0 ? v : fallback) * DOT_POINTS_PER_INCH;
1848
+ };
1849
+ return {
1850
+ bendsOf: (e, get) => {
1851
+ const n = snapshot.edgeSource(e);
1852
+ const list = onNode.get(n);
1853
+ if (n !== snapshot.edgeTarget(e) || list === undefined || !portless(get)) {
1854
+ return null;
1855
+ }
1856
+ // ponytail: a node Graphviz grows to fit its label is taken at its stated width and height
1857
+ const [w, h] = [inches(n, "width", 0.75), inches(n, "height", 0.5)];
1858
+ const k = list.indexOf(e) + 1;
1859
+ const dx = w / 2 + GRAPHVIZ_LOOP_STEP.x * k;
1860
+ const dy = (GRAPHVIZ_LOOP_STEP.y * k) / list.length;
1861
+ const curve = flattenCubic([
1862
+ [0, 0],
1863
+ [dx / 3, dy],
1864
+ [dx, dy],
1865
+ [dx, 0],
1866
+ [dx, -dy],
1867
+ [dx / 3, -dy],
1868
+ [0, 0],
1869
+ ]);
1870
+ return curve === null ? null : canonNodeOffsets([0, 0], [w, h], curve.slice(1, -1), false);
1871
+ },
1872
+ };
1873
+ }
1874
+
1875
+ /**
1876
+ * The bends of a route Graphviz laid: B-splines of `3n + 1` control points each (y up), flattened into bends between
1877
+ * the two nodes' centers. Null for a route on the line between the centers, which is the straight edge it looks like,
1878
+ * or for control points that are not a spline. Also reads the `Line` of a GML file Graphviz's gv2gml wrote.
1879
+ * @param source - the source node's center
1880
+ * @param target - the target node's center
1881
+ * @param splines - the control points of each spline, joined end to end
1882
+ * @param tips - the arrow tips `pos` stores (`s,x,y` and `e,x,y`): the line runs on to each, along its arrow
1883
+ * @param tips.start - the tail arrow's tip, or null
1884
+ * @param tips.end - the head arrow's tip, or null
1885
+ * @returns the `bends` value, or null
1886
+ */
1887
+ export function graphvizRouteBends(
1888
+ source: BendPoint,
1889
+ target: BendPoint,
1890
+ splines: readonly (readonly BendPoint[])[],
1891
+ tips?: { readonly start: BendPoint | null; readonly end: BendPoint | null },
1892
+ ): string | null {
1893
+ const points: BendPoint[] = [];
1894
+ for (const controls of splines) {
1895
+ const flat = flattenCubic(controls);
1896
+ if (flat === null || flat.some(([x, y]) => !Number.isFinite(x) || !Number.isFinite(y))) {
1897
+ return null;
1898
+ }
1899
+ points.push(...flat);
1900
+ }
1901
+ // a tip where the spline already ends adds no point
1902
+ const apart = (a: BendPoint, b: BendPoint | undefined): boolean =>
1903
+ b === undefined || Math.hypot(a[0] - b[0], a[1] - b[1]) > 1e-6;
1904
+ const start = tips?.start ?? null;
1905
+ const end = tips?.end ?? null;
1906
+ if (start !== null && apart(start, points[0])) {
1907
+ points.unshift(start);
1908
+ }
1909
+ if (end !== null && apart(end, points.at(-1))) {
1910
+ points.push(end);
1911
+ }
1912
+ const [dx, dy] = [target[0] - source[0], target[1] - source[1]];
1913
+ const length = Math.hypot(dx, dy);
1914
+ const off =
1915
+ length > 0
1916
+ ? Math.max(...points.map(([x, y]) => Math.abs((x - source[0]) * dy - (y - source[1]) * dx))) / length
1917
+ : 0;
1918
+ // a route on the line between the centers is the straight edge it looks like: Graphviz draws its straight lines
1919
+ // (`splines=false`) up to half a point off that line
1920
+ return off > Math.max(1e-3 * length, GRAPHVIZ_STRAIGHT_POINTS) ? canonBends(source, target, points, false) : null;
674
1921
  }
675
1922
 
676
1923
  /**
@@ -712,15 +1959,97 @@ function readEdgeArrows(get: Attr, cx: EdgeContext, put: Put, at: At): void {
712
1959
  // with none of these the tool-default rule draws the arrow of the graph's kind
713
1960
  if (dirRaw !== null || head !== null || tail !== null) {
714
1961
  const dir = (dirRaw ?? (cx.directed ? "forward" : "none")).trim().toLowerCase();
715
- const forward = dir === "forward" || dir === "both";
716
- const back = dir === "back" || dir === "both";
717
- put("targetArrow", forward ? canonEnum("arrow", head ?? "normal", "graphviz", at("targetArrow")) : "none");
718
- put("sourceArrow", back ? canonEnum("arrow", tail ?? "normal", "graphviz", at("sourceArrow")) : "none");
1962
+ readEdgeArrow("target", head, dir === "forward" || dir === "both", put, at);
1963
+ readEdgeArrow("source", tail, dir === "back" || dir === "both", put, at);
719
1964
  }
720
1965
  const size = get("arrowsize");
721
1966
  if (size !== null) {
722
1967
  put("arrowSize", canonNumber(size, at("arrowSize")));
723
1968
  }
1969
+ // an edge's `fillcolor` fills its arrowheads (their outline stays the edge's color)
1970
+ const fill = nonEmpty(get("fillcolor"));
1971
+ for (const property of ["targetArrowColor", "sourceArrowColor"]) {
1972
+ readColor(fill, get("colorscheme"), "fill", property, null, put, at);
1973
+ }
1974
+ }
1975
+
1976
+ /**
1977
+ * Reads one end's arrow: its shape, or none when the edge's direction draws no arrow there, and the side it keeps.
1978
+ * @param end - which end
1979
+ * @param raw - its `arrowhead` or `arrowtail`, or null
1980
+ * @param on - whether the edge's direction draws an arrow there
1981
+ * @param put - receives the values
1982
+ * @param at - the sites
1983
+ */
1984
+ function readEdgeArrow(end: "target" | "source", raw: string | null, on: boolean, put: Put, at: At): void {
1985
+ const arrow = on ? graphvizArrowValue(raw ?? "normal", at(`${end}Arrow`)) : null;
1986
+ put(`${end}Arrow`, arrow === null ? "none" : arrow.value);
1987
+ if (arrow?.side !== null && arrow?.side !== undefined) {
1988
+ put(`${end}ArrowSide`, arrow.side);
1989
+ }
1990
+ }
1991
+
1992
+ /** The sides an `l` or `r` arrow modifier keeps, by their first letters. */
1993
+ const ARROW_SIDES = ["left", "right"] as const;
1994
+
1995
+ /** The open forms of Graphviz arrows that a neutral shape draws; the others are drawn filled. */
1996
+ const GRAPHVIZ_OPEN_ARROWS: ReadonlySet<string> = new Set(["normal", "diamond", "dot", "box", "inv"]);
1997
+
1998
+ /** The open Graphviz arrows the neutral vocabulary has no name for, by the value graphty draws them as. */
1999
+ const GRAPHVIZ_OTHER_ARROWS: ReadonlyMap<string, string> = new Map([
2000
+ ["obox", "other:open-square"],
2001
+ ["oinv", "other:open-inverted"],
2002
+ ]);
2003
+
2004
+ /**
2005
+ * The neutral arrow value Graphviz draws for an arrow name (`onormal`, `lbox`, `invempty`), with an open shape the
2006
+ * neutral vocabulary has no name for (`obox`, `oinv`) as the value graphty draws it as, and the side an `l` or `r`
2007
+ * keeps. Read so by the DOT reader and by the GML reader for gv2gml's `targetArrow` / `sourceArrow`.
2008
+ * @param raw - the arrow name
2009
+ * @param site - where it was read
2010
+ * @returns the value and its side (null for the whole shape)
2011
+ */
2012
+ export function graphvizArrowValue(
2013
+ raw: string,
2014
+ site: CanonSite,
2015
+ ): { readonly value: string | null; readonly side: "left" | "right" | null } {
2016
+ const first = graphvizArrow(raw);
2017
+ const other = first === null ? undefined : GRAPHVIZ_OTHER_ARROWS.get(first.name);
2018
+ return {
2019
+ value: other ?? canonEnum("arrow", first?.name ?? raw, "graphviz", site),
2020
+ side: first?.side ?? null,
2021
+ };
2022
+ }
2023
+
2024
+ /**
2025
+ * The arrow Graphviz draws at an edge's very end, from an arrow name of up to four shapes (`dotodot`,
2026
+ * `lteeoldiamond`): the first shape that is not `none`, with its `o` (open) kept where a neutral shape draws it, and
2027
+ * the side an `l` or `r` keeps alone ("clip the shape, leaving only the part to the left of the edge", looking along
2028
+ * the edge toward the arrow's tip).
2029
+ * @param raw - the arrow name
2030
+ * @returns the shape's own name and its side (null for the whole shape), or null for a name outside the grammar (a
2031
+ * synonym such as `empty`)
2032
+ */
2033
+ function graphvizArrow(raw: string): { readonly name: string; readonly side: "left" | "right" | null } | null {
2034
+ let rest = raw.trim().toLowerCase();
2035
+ // the one synonym the grammar would misread (as `inv`): arrows.c's `invempty` is an open `inv`
2036
+ if (rest === "invempty") {
2037
+ rest = "oinv";
2038
+ }
2039
+ while (rest.length > 0) {
2040
+ const m = /^([olr]{0,3})(normal|crow|tee|box|diamond|dot|none|inv|vee|icurve|curve)/.exec(rest);
2041
+ if (m === null) {
2042
+ return null;
2043
+ }
2044
+ rest = rest.slice(m[0].length);
2045
+ const [, mods, shape] = m;
2046
+ if (shape !== "none") {
2047
+ const side = ARROW_SIDES.find((s) => mods.includes(s[0])) ?? null;
2048
+ const open = mods.includes("o") && GRAPHVIZ_OPEN_ARROWS.has(shape) ? "o" : "";
2049
+ return { name: open + shape, side };
2050
+ }
2051
+ }
2052
+ return null;
724
2053
  }
725
2054
 
726
2055
  /**
@@ -807,12 +2136,16 @@ function contexts(
807
2136
  } {
808
2137
  const html = htmlStrings(snapshot);
809
2138
  const graph = snapshot.meta.name ?? "";
2139
+ const rankdir = (cellText(snapshot.graph, "rankdir", 0) ?? "").trim().toUpperCase();
2140
+ const flip = rankdir === "LR" || rankdir === "RL";
2141
+ const q = Number(cellText(snapshot.graph, "quantum", 0) ?? Number.NaN);
2142
+ const quantum = Number.isFinite(q) && q > 0 ? q * DOT_POINTS_PER_INCH : 0;
810
2143
  const own = directedEdges(snapshot);
811
2144
  // a graph mixing both directions is written with one, every edge then drawn as that one
812
2145
  const directed = written === null || own.every((d) => d === snapshot.directed) ? own : own.map(() => written);
813
2146
  const name = (i: number): string => String(snapshot.ids.idOf(i));
814
2147
  return {
815
- node: (i) => ({ name: name(i), graph, cluster: isCluster(snapshot, i), html }),
2148
+ node: (i) => ({ name: name(i), graph, cluster: isCluster(snapshot, i), html, flip, quantum }),
816
2149
  edge: (e) => {
817
2150
  const tail = name(snapshot.edgeSource(e));
818
2151
  const head = name(snapshot.edgeTarget(e));
@@ -854,6 +2187,34 @@ function styleColumns(table: GraphSnapshot["nodes"], attributes: readonly string
854
2187
  return [...table.names()].filter((c) => own.has(c) || c.startsWith("graphty.style."));
855
2188
  }
856
2189
 
2190
+ /**
2191
+ * The graph's own styles: its `bgcolor` as its background.
2192
+ * @param snapshot - the graph
2193
+ * @param notes - where a bad color is noted
2194
+ * @returns the graph's values
2195
+ */
2196
+ function graphBackground(snapshot: GraphSnapshot, notes: StyleNotes): Record<string, StyleValue> {
2197
+ const graph: Record<string, StyleValue> = {};
2198
+ const bg = nonEmpty(cellText(snapshot.graph, "bgcolor", 0));
2199
+ if (!snapshot.graph.has("bgcolor") || bg === null) {
2200
+ return graph;
2201
+ }
2202
+ readColor(
2203
+ bg,
2204
+ cellText(snapshot.graph, "colorscheme", 0),
2205
+ "fill",
2206
+ "background",
2207
+ null,
2208
+ (p, v) => {
2209
+ if (v !== null) {
2210
+ graph[p] = v;
2211
+ }
2212
+ },
2213
+ (property) => ({ notes, target: "graph", property }),
2214
+ );
2215
+ return graph;
2216
+ }
2217
+
857
2218
  /**
858
2219
  * The styles of a DOT file, read the way Graphviz draws it, with Graphviz's defaults beneath: an unfilled black
859
2220
  * ellipse 54 x 36 labeled with its name, black edges with a target arrow in a directed graph. A file graphty wrote
@@ -894,6 +2255,11 @@ export function dotStyles(snapshot: GraphSnapshot, context: StyleReadContext): I
894
2255
  }
895
2256
  }
896
2257
  const edge = valueColumns(snapshot.edgeCount);
2258
+ const loops = graphvizLoops(snapshot);
2259
+ // `splines=none` and `splines=""` draw no edges at all (https://graphviz.org/docs/attrs/splines/)
2260
+ const splines = cellText(snapshot.graph, "splines", 0)?.trim().toLowerCase();
2261
+ const noEdges = splines === "" || splines === "none";
2262
+ const places = labelPlaceContext(snapshot);
897
2263
  for (let e = 0; e < snapshot.edgeCount; e++) {
898
2264
  context.budget.spend("edge", EDGE_ATTRIBUTES.length);
899
2265
  const get: Attr = (name) => cellText(snapshot.edges, name, e);
@@ -906,24 +2272,18 @@ export function dotStyles(snapshot: GraphSnapshot, context: StyleReadContext): I
906
2272
  },
907
2273
  at,
908
2274
  );
2275
+ if (noEdges) {
2276
+ edge.put(e, "visible", false);
2277
+ }
2278
+ const bends = splineBends(snapshot, e, get(POS_ATTRIBUTE)) ?? loops.bendsOf(e, get);
2279
+ if (bends !== null) {
2280
+ edge.put(e, "bends", bends);
2281
+ }
2282
+ readLabelPlaces(snapshot, places, e, get, (p, v) => {
2283
+ edge.put(e, p, v);
2284
+ });
909
2285
  }
910
- const graph: Record<string, StyleValue> = {};
911
- const bg = nonEmpty(cellText(snapshot.graph, "bgcolor", 0));
912
- if (snapshot.graph.has("bgcolor") && bg !== null) {
913
- readColor(
914
- bg,
915
- cellText(snapshot.graph, "colorscheme", 0),
916
- "fill",
917
- "background",
918
- null,
919
- (p, v) => {
920
- if (v !== null) {
921
- graph[p] = v;
922
- }
923
- },
924
- (property) => ({ notes, target: "graph", property }),
925
- );
926
- }
2286
+ const graph = graphBackground(snapshot, notes);
927
2287
  const blockText = cellText(snapshot.graph, STYLE_BLOCK_NAME, 0);
928
2288
  if (blockText !== null) {
929
2289
  exactFromBlock(snapshot, blockText, { node: node.values, edge: edge.values }, cx);
@@ -1017,8 +2377,9 @@ const ARROWS: ReadonlyMap<string, string> = new Map([
1017
2377
  ["circle-open", "odot"],
1018
2378
  ["square", "box"],
1019
2379
  ["crow", "crow"],
1020
- ["half", "lnormal"],
1021
2380
  ["inverted", "inv"],
2381
+ ["other:open-square", "obox"],
2382
+ ["other:open-inverted", "oinv"],
1022
2383
  ]);
1023
2384
 
1024
2385
  /** Line and border styles Graphviz draws exactly; the others are written as the nearest. */
@@ -1046,7 +2407,10 @@ function graphvizDefaults(target: StyleTarget): ReadonlyMap<string, Written> {
1046
2407
  };
1047
2408
  add("fontcolor", "labelColor");
1048
2409
  add("fontsize", "labelSize");
1049
- add("fontname", "labelFont");
2410
+ const font = t.labelFont?.value;
2411
+ if (typeof font === "string") {
2412
+ out.set("fontname", graphvizFontName(font, false, false));
2413
+ }
1050
2414
  if (target === "node") {
1051
2415
  add("color", "borderColor");
1052
2416
  add("pencolor", "borderColor");
@@ -1100,12 +2464,16 @@ function gvColor(color: string, opacity: StyleValue | null): string {
1100
2464
  }
1101
2465
 
1102
2466
  /**
1103
- * A label's text as a DOT label: backslashes and line breaks escaped, so Graphviz's escape passes give the text back.
2467
+ * A label's text as a DOT label: backslashes, line breaks and `&` escaped, so Graphviz's escape and entity passes
2468
+ * give the text back.
1104
2469
  * @param text - the text
1105
2470
  * @returns the label
1106
2471
  */
1107
2472
  function escapeLabel(text: string): string {
1108
- return text.replaceAll("\\", String.raw`\\`).replaceAll("\n", String.raw`\n`);
2473
+ return text
2474
+ .replaceAll("&", "&amp;")
2475
+ .replaceAll("\\", String.raw`\\`)
2476
+ .replaceAll("\n", String.raw`\n`);
1109
2477
  }
1110
2478
 
1111
2479
  /**
@@ -1138,10 +2506,11 @@ function spelled(
1138
2506
  /**
1139
2507
  * The font, tooltip and visibility attributes every element shares.
1140
2508
  * @param get - the element's values
2509
+ * @param approximate - records approximations
1141
2510
  * @param out - the attributes, added to
1142
2511
  * @param tokens - the style tokens, added to
1143
2512
  */
1144
- function writeCommon(get: Get, out: Map<string, Written>, tokens: string[]): void {
2513
+ function writeCommon(get: Get, approximate: Approximate, out: Map<string, Written>, tokens: string[]): void {
1145
2514
  const color = get("labelColor");
1146
2515
  if (typeof color === "string") {
1147
2516
  out.set("fontcolor", color);
@@ -1151,8 +2520,20 @@ function writeCommon(get: Get, out: Map<string, Written>, tokens: string[]): voi
1151
2520
  out.set("fontsize", size);
1152
2521
  }
1153
2522
  const font = get("labelFont");
2523
+ const bold = get("labelWeight") === "bold";
2524
+ const italic = get("labelItalic") === true;
1154
2525
  if (typeof font === "string") {
1155
- out.set("fontname", font);
2526
+ const name = graphvizFontName(font, bold, italic);
2527
+ out.set("fontname", name);
2528
+ const drawn = graphvizFont(name);
2529
+ if (drawn.bold !== bold) {
2530
+ approximate("labelWeight", get("labelWeight") ?? "normal");
2531
+ }
2532
+ if (drawn.italic !== italic) {
2533
+ approximate("labelItalic", italic);
2534
+ }
2535
+ } else if (bold || italic) {
2536
+ approximate(bold ? "labelWeight" : "labelItalic", bold ? "bold" : true);
1156
2537
  }
1157
2538
  const tip = get("tooltip");
1158
2539
  if (typeof tip === "string") {
@@ -1167,14 +2548,21 @@ function writeCommon(get: Get, out: Map<string, Written>, tokens: string[]): voi
1167
2548
  * A node's or cluster's Graphviz attributes from its values.
1168
2549
  * @param get - the node's values
1169
2550
  * @param cx - the node's name and kind
2551
+ * @param raw - its attributes as data
1170
2552
  * @param approximate - records approximations
1171
2553
  * @param notes - where an HTML name is noted
1172
2554
  * @returns the attributes
1173
2555
  */
1174
- function nodeAttributes(get: Get, cx: NodeContext, approximate: Approximate, notes: StyleNotes): Map<string, Written> {
2556
+ function nodeAttributes(
2557
+ get: Get,
2558
+ cx: NodeContext,
2559
+ raw: Attr,
2560
+ approximate: Approximate,
2561
+ notes: StyleNotes,
2562
+ ): Map<string, Written> {
1175
2563
  const out = new Map<string, Written>();
1176
2564
  const tokens: string[] = [];
1177
- writeCommon(get, out, tokens);
2565
+ writeCommon(get, approximate, out, tokens);
1178
2566
  writeNodeOutline(get, cx, approximate, out, tokens);
1179
2567
  const gvShape = writeNodeShape(get, cx, approximate, out, tokens);
1180
2568
  if (!cx.cluster) {
@@ -1188,12 +2576,30 @@ function nodeAttributes(get: Get, cx: NodeContext, approximate: Approximate, not
1188
2576
  } else if (label === null && shown !== null) {
1189
2577
  out.set(LABEL_ATTRIBUTE, "");
1190
2578
  }
2579
+ noteLabelPosition(get("labelPosition"), cx, raw, approximate);
1191
2580
  if (tokens.length > 0) {
1192
2581
  out.set("style", tokens.join(","));
1193
2582
  }
1194
2583
  return withoutDefaults(out, "node");
1195
2584
  }
1196
2585
 
2586
+ /**
2587
+ * Notes a label position Graphviz does not draw: a node's label sits where its own `labelloc` (kept as data) puts
2588
+ * it, and a cluster's where its `labelloc` and `labeljust` do.
2589
+ * @param position - the label's position
2590
+ * @param cx - the node's name and kind
2591
+ * @param raw - the node's attributes as data
2592
+ * @param approximate - records approximations
2593
+ */
2594
+ function noteLabelPosition(position: StyleValue | null, cx: NodeContext, raw: Attr, approximate: Approximate): void {
2595
+ if (
2596
+ typeof position === "string" &&
2597
+ position !== (cx.cluster ? clusterLabelPosition(raw) : nodeLabelPosition(raw))
2598
+ ) {
2599
+ approximate("labelPosition", position);
2600
+ }
2601
+ }
2602
+
1197
2603
  /**
1198
2604
  * A node's or cluster's fill and outline attributes.
1199
2605
  * @param get - the node's values
@@ -1259,20 +2665,95 @@ function writeNodeShape(
1259
2665
  }
1260
2666
  return "";
1261
2667
  }
2668
+ const size = (name: string): number | null => {
2669
+ const v = get(name);
2670
+ return typeof v === "number" ? v : null;
2671
+ };
2672
+ const glyph = shape === "polygon" ? glyphOf(get, size("width"), size("height")) : undefined;
2673
+ if (glyph !== undefined) {
2674
+ return writeGlyph(get, glyph, out);
2675
+ }
2676
+ approximateShapeLines(get, approximate);
2677
+ const polygon = shape === "polygon" ? writePolygon(get("shapePoints"), approximate, out) : null;
2678
+ if (polygon !== null) {
2679
+ // Graphviz draws its polygon's corners where it lays them, which read back unstretched
2680
+ if (get("shapePointsStretch") !== false) {
2681
+ approximate("shapePointsStretch", true);
2682
+ }
2683
+ return polygon;
2684
+ }
1262
2685
  const gvShape = spelled(SHAPES, shape, shape === "vee" ? "invtriangle" : "box", "shape", approximate);
1263
2686
  out.set("shape", gvShape);
1264
2687
  return gvShape;
1265
2688
  }
1266
2689
 
1267
2690
  /**
1268
- * A node's width and height, fixed so Graphviz does not grow them to fit the label.
2691
+ * A glyph's shape attribute, and its line's width as its pen's when it draws no outline.
2692
+ * @param get - the node's values
2693
+ * @param glyph - the glyph
2694
+ * @param out - the attributes, added to
2695
+ * @returns the Graphviz shape written
2696
+ */
2697
+ function writeGlyph(get: Get, glyph: NonNullable<ReturnType<typeof glyphOf>>, out: Map<string, Written>): string {
2698
+ out.set("shape", glyph.name);
2699
+ // an underline's line is drawn in its pen, which draws no outline round it
2700
+ const line = get("shapeLineWidth");
2701
+ if (glyph.unstroked && typeof line === "number") {
2702
+ out.set("penwidth", line);
2703
+ }
2704
+ return glyph.name;
2705
+ }
2706
+
2707
+ /**
2708
+ * Notes a node's pieces and lines of no Graphviz shape as approximated: Graphviz has no attribute for them.
2709
+ * @param get - the node's values
2710
+ * @param approximate - records approximations
2711
+ */
2712
+ function approximateShapeLines(get: Get, approximate: Approximate): void {
2713
+ for (const property of ["shapeParts", "shapeLines", "shapeLineWidth"]) {
2714
+ const v = get(property);
2715
+ if (v !== null && v !== undefined) {
2716
+ approximate(property, v);
2717
+ }
2718
+ }
2719
+ }
2720
+
2721
+ /**
2722
+ * A polygon's shape attribute: the Graphviz shape whose outline it is, else `shape=polygon` with its number of sides.
2723
+ * @param points - its `shapePoints`
2724
+ * @param approximate - records approximations
2725
+ * @param out - the attributes, added to
2726
+ * @returns the Graphviz shape written, or null for corners too few to be a polygon
2727
+ */
2728
+ function writePolygon(points: unknown, approximate: Approximate, out: Map<string, Written>): string | null {
2729
+ if (typeof points !== "string") {
2730
+ return null;
2731
+ }
2732
+ const named = OUTLINE_NAMES.get(points.trim());
2733
+ if (named !== undefined) {
2734
+ out.set("shape", named);
2735
+ return named;
2736
+ }
2737
+ // the corners themselves are not Graphviz attributes: the polygon keeps its number of sides
2738
+ const sides = points.trim().split(/\s+/).length;
2739
+ if (sides < 3) {
2740
+ return null;
2741
+ }
2742
+ out.set("shape", "polygon");
2743
+ out.set("sides", sides);
2744
+ approximate("shapePoints", points);
2745
+ return "polygon";
2746
+ }
2747
+
2748
+ /**
2749
+ * A node's width and height, fixed so Graphviz does not grow them to fit the label, unless the node grows to fit it.
1269
2750
  * @param get - the node's values
1270
2751
  * @param out - the attributes, added to
1271
2752
  */
1272
2753
  function writeNodeSize(get: Get, out: Map<string, Written>): void {
1273
2754
  const width = get("width");
1274
2755
  const height = get("height");
1275
- if (typeof width === "number" || typeof height === "number") {
2756
+ if ((typeof width === "number" || typeof height === "number") && typeof get("labelFit") !== "string") {
1276
2757
  out.set("fixedsize", "true");
1277
2758
  }
1278
2759
  if (typeof width === "number") {
@@ -1293,7 +2774,7 @@ function writeNodeSize(get: Get, out: Map<string, Written>): void {
1293
2774
  function edgeAttributes(get: Get, directed: boolean, approximate: Approximate): Map<string, Written> {
1294
2775
  const out = new Map<string, Written>();
1295
2776
  const tokens: string[] = [];
1296
- writeCommon(get, out, tokens);
2777
+ writeCommon(get, approximate, out, tokens);
1297
2778
  const color = get("color");
1298
2779
  if (typeof color === "string") {
1299
2780
  out.set("color", gvColor(color, get("opacity")));
@@ -1307,6 +2788,16 @@ function edgeAttributes(get: Get, directed: boolean, approximate: Approximate):
1307
2788
  tokens.push(spelled(STROKES, line, line.includes("dash") ? "dashed" : "solid", "lineStyle", approximate));
1308
2789
  }
1309
2790
  writeEdgeArrows(get, directed, approximate, out);
2791
+ // Graphviz fills both arrowheads with the one `fillcolor`
2792
+ const head = get("targetArrowColor");
2793
+ const tail = get("sourceArrowColor");
2794
+ const fill = head ?? tail;
2795
+ if (typeof fill === "string") {
2796
+ out.set("fillcolor", fill);
2797
+ if (head !== null && tail !== null && head !== tail) {
2798
+ approximate("sourceArrowColor", tail);
2799
+ }
2800
+ }
1310
2801
  const arrowSize = get("arrowSize");
1311
2802
  if (typeof arrowSize === "number") {
1312
2803
  out.set("arrowsize", arrowSize);
@@ -1339,7 +2830,7 @@ function writeEdgeArrows(get: Get, directed: boolean, approximate: Approximate,
1339
2830
  const target = get("targetArrow");
1340
2831
  const tail = typeof source === "string" && source !== "none";
1341
2832
  const head = typeof target === "string" && target !== "none";
1342
- const plain = directed ? head && !tail && target === "triangle" : !head && !tail;
2833
+ const plain = directed ? head && !tail && target === "triangle" && get("targetArrowSide") === null : !head && !tail;
1343
2834
  if (plain || (source === null && target === null)) {
1344
2835
  return;
1345
2836
  }
@@ -1352,29 +2843,23 @@ function writeEdgeArrows(get: Get, directed: boolean, approximate: Approximate,
1352
2843
  dir = "back";
1353
2844
  }
1354
2845
  out.set("dir", dir);
2846
+ // a one-sided arrow is its shape with an `l` or `r` before it; the neutral half arrow, one barb, is nearest to the
2847
+ // left half of Graphviz's triangle, which reads back as that
2848
+ const arrow = (value: string, side: StyleValue | null, property: string): string => {
2849
+ if (value === "half") {
2850
+ approximate(property, value);
2851
+ approximate(`${property}Side`, value);
2852
+ return "lnormal";
2853
+ }
2854
+ const name = spelled(ARROWS, value, "normal", property, approximate);
2855
+ return side === "left" || side === "right" ? `${side[0]}${name}` : name;
2856
+ };
1355
2857
  if (head) {
1356
- out.set("arrowhead", spelled(ARROWS, target, "normal", "targetArrow", approximate));
2858
+ out.set("arrowhead", arrow(target, get("targetArrowSide"), "targetArrow"));
1357
2859
  }
1358
2860
  if (tail) {
1359
- out.set("arrowtail", spelled(ARROWS, source, "normal", "sourceArrow", approximate));
1360
- }
1361
- }
1362
-
1363
- /**
1364
- * Each element's values of the properties the writer writes, as a getter per element.
1365
- * @param picture - each element's value of a property
1366
- * @param target - nodes or edges
1367
- * @returns the getter
1368
- */
1369
- function getter(
1370
- picture: (target: StyleTarget, property: string) => readonly (StyleValue | null)[],
1371
- target: StyleTarget,
1372
- ): (i: number) => Get {
1373
- const columns = new Map<string, readonly (StyleValue | null)[]>();
1374
- for (const p of DOT_STYLE_CAPABILITIES[target]) {
1375
- columns.set(p, picture(target, p));
2861
+ out.set("arrowtail", arrow(source, get("sourceArrowSide"), "sourceArrow"));
1376
2862
  }
1377
- return (i) => (property) => columns.get(property)?.[i] ?? null;
1378
2863
  }
1379
2864
 
1380
2865
  /**
@@ -1395,29 +2880,17 @@ function exactFromBlock(
1395
2880
  read: { readonly node: ValueColumns; readonly edge: ValueColumns },
1396
2881
  cx: ReturnType<typeof contexts>,
1397
2882
  ): void {
1398
- const parsed = parseStyleBlock(text, styleFingerprint(snapshot));
1399
- const ex = parsed.ok ? blockStyles(parsed.block, snapshot, "dot") : null;
1400
- if (ex === null) {
1401
- return;
1402
- }
1403
2883
  const notes = new StyleNotes();
1404
- for (const target of ["node", "edge"] as const) {
1405
- const n = target === "node" ? snapshot.nodeCount : snapshot.edgeCount;
1406
- const get = getter((t, p) => resolveStyles(ex, t, p as never, snapshot), target);
1407
- const values = read[target];
1408
- for (let i = 0; i < n; i++) {
1409
- const want = get(i);
1410
- const back = writtenAndReadBack(target, i, want, cx, notes);
1411
- for (const property of DOT_STYLE_CAPABILITIES[target]) {
1412
- const native = values[property]?.[i] ?? null;
1413
- const w = want(property);
1414
- if (native !== w && native === (back.get(property) ?? null)) {
1415
- values[property] ??= new Array<StyleValue | null>(n).fill(null);
1416
- values[property][i] = w;
1417
- }
1418
- }
1419
- }
1420
- }
2884
+ takeFromBlock(snapshot, text, "dot", (target) => ({
2885
+ values: read[target],
2886
+ properties: DOT_STYLE_CAPABILITIES[target],
2887
+ writes: DOT_STYLE_CAPABILITIES[target],
2888
+ readBack: (i, want) => {
2889
+ const raw: Attr = (name) => cellText(target === "node" ? snapshot.nodes : snapshot.edges, name, i);
2890
+ const back = writtenAndReadBack(target, i, want, { cx, raw }, notes);
2891
+ return (property) => back.get(property) ?? null;
2892
+ },
2893
+ }));
1421
2894
  }
1422
2895
 
1423
2896
  /**
@@ -1425,7 +2898,9 @@ function exactFromBlock(
1425
2898
  * @param target - nodes or edges
1426
2899
  * @param i - the element
1427
2900
  * @param want - its values
1428
- * @param cx - the elements' contexts
2901
+ * @param data - the elements' contexts, and the element's attributes as data (what the writer leaves as it is)
2902
+ * @param data.cx - the elements' contexts
2903
+ * @param data.raw - the element's attributes as data
1429
2904
  * @param notes - a scratch for the notes the round trip makes
1430
2905
  * @returns the values read back, by property
1431
2906
  */
@@ -1433,9 +2908,10 @@ function writtenAndReadBack(
1433
2908
  target: StyleTarget,
1434
2909
  i: number,
1435
2910
  want: Get,
1436
- cx: ReturnType<typeof contexts>,
2911
+ data: { readonly cx: ReturnType<typeof contexts>; readonly raw: Attr },
1437
2912
  notes: StyleNotes,
1438
2913
  ): Map<string, StyleValue> {
2914
+ const { cx, raw } = data;
1439
2915
  const ignore: Approximate = () => undefined;
1440
2916
  const back = new Map<string, StyleValue>();
1441
2917
  const put: Put = (property, value) => {
@@ -1446,8 +2922,10 @@ function writtenAndReadBack(
1446
2922
  const at: At = (property) => ({ notes, target, property });
1447
2923
  if (target === "node") {
1448
2924
  const ncx = cx.node(i);
1449
- const attrs = nodeAttributes(want, ncx, ignore, notes);
1450
- readNode((name) => textOf(attrs.get(name)), ncx, put, at);
2925
+ const attrs = nodeAttributes(want, ncx, raw, ignore, notes);
2926
+ // a cluster's label position stays in its data (`labelloc`, `labeljust`), which the writer leaves alone
2927
+ const kept = (name: string): string | null => (name === "labelloc" || name === "labeljust" ? raw(name) : null);
2928
+ readNode((name) => (attrs.has(name) ? textOf(attrs.get(name)) : kept(name)), ncx, put, at);
1451
2929
  } else {
1452
2930
  const ecx = cx.edge(i);
1453
2931
  const attrs = edgeAttributes(want, ecx.directed, ignore);
@@ -1600,30 +3078,105 @@ function attributeColumns(
1600
3078
  w: AttributeWrite,
1601
3079
  ): Map<string, (Written | null)[]> {
1602
3080
  const n = target === "node" ? snapshot.nodeCount : snapshot.edgeCount;
1603
- const get = getter((t, p) => input.picture(t, p as never), target);
3081
+ const get = elementValues((t, p) => input.picture(t, p as never), target, DOT_STYLE_CAPABILITIES[target]);
1604
3082
  const columns = new Map<string, (Written | null)[]>();
1605
3083
  for (let i = 0; i < n; i++) {
1606
3084
  const attrs =
1607
3085
  target === "node"
1608
- ? nodeAttributes(get(i), w.cx.node(i), w.approximate, w.notes)
3086
+ ? nodeAttributes(
3087
+ get(i),
3088
+ w.cx.node(i),
3089
+ (name) => cellText(snapshot.nodes, name, i),
3090
+ w.approximate,
3091
+ w.notes,
3092
+ )
1609
3093
  : edgeAttributes(get(i), w.cx.edge(i).directed, w.approximate);
3094
+ // a self-loop's route is not written: Graphviz lays the loop itself, which is what reads back
3095
+ if (target === "edge" && snapshot.edgeSource(i) === snapshot.edgeTarget(i)) {
3096
+ w.approximate("bends", "self-loop");
3097
+ }
1610
3098
  // no label is written label="" (Graphviz would show the name), which a rule reading the label column
1611
3099
  // reads back as the empty label
1612
3100
  if (w.read.has(LABEL_ATTRIBUTE) && attrs.get(LABEL_ATTRIBUTE) === "" && get(i)("label") === null) {
1613
3101
  w.approximate("label", "");
1614
3102
  }
1615
- for (const [name, value] of attrs) {
1616
- let col = columns.get(name);
1617
- if (col === undefined) {
1618
- col = new Array<Written | null>(n).fill(null);
1619
- columns.set(name, col);
1620
- }
1621
- col[i] = value;
1622
- }
3103
+ setColumnValues(columns, attrs, i, n);
3104
+ }
3105
+ if (target === "node") {
3106
+ stopInheritance(snapshot, w.cx, columns, input.rules.styles.graph.background);
1623
3107
  }
1624
3108
  return columns;
1625
3109
  }
1626
3110
 
3111
+ /**
3112
+ * Puts one element's attributes into the columns, adding a column for an attribute not seen before.
3113
+ * @param columns - the columns, by name
3114
+ * @param attrs - the element's attributes
3115
+ * @param i - the element
3116
+ * @param n - how many elements there are
3117
+ */
3118
+ function setColumnValues(
3119
+ columns: Map<string, (Written | null)[]>,
3120
+ attrs: ReadonlyMap<string, Written>,
3121
+ i: number,
3122
+ n: number,
3123
+ ): void {
3124
+ for (const [name, value] of attrs) {
3125
+ let col = columns.get(name);
3126
+ if (col === undefined) {
3127
+ col = new Array<Written | null>(n).fill(null);
3128
+ columns.set(name, col);
3129
+ }
3130
+ col[i] = value;
3131
+ }
3132
+ }
3133
+
3134
+ /** What a cluster draws when neither it nor a graph around it sets the attribute: Graphviz's defaults. */
3135
+ const CLUSTER_UNSET: ReadonlyMap<string, Written> = new Map<string, Written>([
3136
+ ["style", ""],
3137
+ ["penwidth", 1],
3138
+ ["color", ""],
3139
+ ["pencolor", ""],
3140
+ ["fillcolor", ""],
3141
+ ["bgcolor", ""],
3142
+ ["fontname", "Times-Roman"],
3143
+ ["fontsize", 14],
3144
+ ["fontcolor", "black"],
3145
+ [LABEL_ATTRIBUTE, ""],
3146
+ ]);
3147
+
3148
+ /**
3149
+ * Gives every cluster its own value of each attribute a graph around it sets, Graphviz's default where the cluster
3150
+ * has none, since Graphviz gives a cluster the attributes of the graphs around it (the root's are kept as data).
3151
+ * @param snapshot - the graph handed to the exporter
3152
+ * @param cx - the elements' contexts
3153
+ * @param columns - the node attributes written, by name, changed in place
3154
+ * @param background - the background the writer gives the root graph (its `bgcolor`), which a cluster would inherit
3155
+ */
3156
+ function stopInheritance(
3157
+ snapshot: GraphSnapshot,
3158
+ cx: ReturnType<typeof contexts>,
3159
+ columns: Map<string, (Written | null)[]>,
3160
+ background: unknown,
3161
+ ): void {
3162
+ const clusters = Array.from({ length: snapshot.nodeCount }, (_, i) => i).filter((i) => cx.node(i).cluster);
3163
+ for (const [name, unset] of CLUSTER_UNSET) {
3164
+ const col = columns.get(name);
3165
+ const enclosing =
3166
+ cellText(snapshot.graph, name, 0) !== null ||
3167
+ (name === "bgcolor" && typeof background === "string") ||
3168
+ clusters.some((i) => (col?.[i] ?? null) !== null);
3169
+ if (!enclosing) {
3170
+ continue;
3171
+ }
3172
+ const filled = col ?? new Array<Written | null>(snapshot.nodeCount).fill(null);
3173
+ for (const i of clusters) {
3174
+ filled[i] ??= unset;
3175
+ }
3176
+ columns.set(name, filled);
3177
+ }
3178
+ }
3179
+
1627
3180
  /**
1628
3181
  * Removes every column Graphviz reads as how to draw: the picture writes them, so a data column of the same name (a
1629
3182
  * `label` attribute graphty does not draw) would draw something graphty does not. One a rule reads, whose values the