stringifier.js 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. 'use strict';
  2. /**
  3. * @typedef {import('./types').XastParent} XastParent
  4. * @typedef {import('./types').XastRoot} XastRoot
  5. * @typedef {import('./types').XastElement} XastElement
  6. * @typedef {import('./types').XastInstruction} XastInstruction
  7. * @typedef {import('./types').XastDoctype} XastDoctype
  8. * @typedef {import('./types').XastText} XastText
  9. * @typedef {import('./types').XastCdata} XastCdata
  10. * @typedef {import('./types').XastComment} XastComment
  11. * @typedef {import('./types').StringifyOptions} StringifyOptions
  12. */
  13. const { textElems } = require('../plugins/_collections.js');
  14. /**
  15. * @typedef {{
  16. * width: void | string,
  17. * height: void | string,
  18. * indent: string,
  19. * textContext: null | XastElement,
  20. * indentLevel: number,
  21. * }} State
  22. */
  23. /**
  24. * @typedef {Required<StringifyOptions>} Options
  25. */
  26. /**
  27. * @type {(char: string) => string}
  28. */
  29. const encodeEntity = (char) => {
  30. return entities[char];
  31. };
  32. /**
  33. * @type {Options}
  34. */
  35. const defaults = {
  36. doctypeStart: '<!DOCTYPE',
  37. doctypeEnd: '>',
  38. procInstStart: '<?',
  39. procInstEnd: '?>',
  40. tagOpenStart: '<',
  41. tagOpenEnd: '>',
  42. tagCloseStart: '</',
  43. tagCloseEnd: '>',
  44. tagShortStart: '<',
  45. tagShortEnd: '/>',
  46. attrStart: '="',
  47. attrEnd: '"',
  48. commentStart: '<!--',
  49. commentEnd: '-->',
  50. cdataStart: '<![CDATA[',
  51. cdataEnd: ']]>',
  52. textStart: '',
  53. textEnd: '',
  54. indent: 4,
  55. regEntities: /[&'"<>]/g,
  56. regValEntities: /[&"<>]/g,
  57. encodeEntity: encodeEntity,
  58. pretty: false,
  59. useShortTags: true,
  60. eol: 'lf',
  61. finalNewline: false,
  62. };
  63. /**
  64. * @type {Record<string, string>}
  65. */
  66. const entities = {
  67. '&': '&amp;',
  68. "'": '&apos;',
  69. '"': '&quot;',
  70. '>': '&gt;',
  71. '<': '&lt;',
  72. };
  73. /**
  74. * convert XAST to SVG string
  75. *
  76. * @type {(data: XastRoot, config: StringifyOptions) => {
  77. * data: string,
  78. * info: {
  79. * width: void | string,
  80. * height: void | string
  81. * }
  82. * }}
  83. */
  84. const stringifySvg = (data, userOptions = {}) => {
  85. /**
  86. * @type {Options}
  87. */
  88. const config = { ...defaults, ...userOptions };
  89. const indent = config.indent;
  90. let newIndent = ' ';
  91. if (typeof indent === 'number' && Number.isNaN(indent) === false) {
  92. newIndent = indent < 0 ? '\t' : ' '.repeat(indent);
  93. } else if (typeof indent === 'string') {
  94. newIndent = indent;
  95. }
  96. /**
  97. * @type {State}
  98. */
  99. const state = {
  100. // TODO remove width and height in v3
  101. width: undefined,
  102. height: undefined,
  103. indent: newIndent,
  104. textContext: null,
  105. indentLevel: 0,
  106. };
  107. const eol = config.eol === 'crlf' ? '\r\n' : '\n';
  108. if (config.pretty) {
  109. config.doctypeEnd += eol;
  110. config.procInstEnd += eol;
  111. config.commentEnd += eol;
  112. config.cdataEnd += eol;
  113. config.tagShortEnd += eol;
  114. config.tagOpenEnd += eol;
  115. config.tagCloseEnd += eol;
  116. config.textEnd += eol;
  117. }
  118. let svg = stringifyNode(data, config, state);
  119. if (config.finalNewline && svg.length > 0 && svg[svg.length - 1] !== '\n') {
  120. svg += eol;
  121. }
  122. return {
  123. data: svg,
  124. info: {
  125. width: state.width,
  126. height: state.height,
  127. },
  128. };
  129. };
  130. exports.stringifySvg = stringifySvg;
  131. /**
  132. * @type {(node: XastParent, config: Options, state: State) => string}
  133. */
  134. const stringifyNode = (data, config, state) => {
  135. let svg = '';
  136. state.indentLevel += 1;
  137. for (const item of data.children) {
  138. if (item.type === 'element') {
  139. svg += stringifyElement(item, config, state);
  140. }
  141. if (item.type === 'text') {
  142. svg += stringifyText(item, config, state);
  143. }
  144. if (item.type === 'doctype') {
  145. svg += stringifyDoctype(item, config);
  146. }
  147. if (item.type === 'instruction') {
  148. svg += stringifyInstruction(item, config);
  149. }
  150. if (item.type === 'comment') {
  151. svg += stringifyComment(item, config);
  152. }
  153. if (item.type === 'cdata') {
  154. svg += stringifyCdata(item, config, state);
  155. }
  156. }
  157. state.indentLevel -= 1;
  158. return svg;
  159. };
  160. /**
  161. * create indent string in accordance with the current node level.
  162. *
  163. * @type {(config: Options, state: State) => string}
  164. */
  165. const createIndent = (config, state) => {
  166. let indent = '';
  167. if (config.pretty && state.textContext == null) {
  168. indent = state.indent.repeat(state.indentLevel - 1);
  169. }
  170. return indent;
  171. };
  172. /**
  173. * @type {(node: XastDoctype, config: Options) => string}
  174. */
  175. const stringifyDoctype = (node, config) => {
  176. return config.doctypeStart + node.data.doctype + config.doctypeEnd;
  177. };
  178. /**
  179. * @type {(node: XastInstruction, config: Options) => string}
  180. */
  181. const stringifyInstruction = (node, config) => {
  182. return (
  183. config.procInstStart + node.name + ' ' + node.value + config.procInstEnd
  184. );
  185. };
  186. /**
  187. * @type {(node: XastComment, config: Options) => string}
  188. */
  189. const stringifyComment = (node, config) => {
  190. return config.commentStart + node.value + config.commentEnd;
  191. };
  192. /**
  193. * @type {(node: XastCdata, config: Options, state: State) => string}
  194. */
  195. const stringifyCdata = (node, config, state) => {
  196. return (
  197. createIndent(config, state) +
  198. config.cdataStart +
  199. node.value +
  200. config.cdataEnd
  201. );
  202. };
  203. /**
  204. * @type {(node: XastElement, config: Options, state: State) => string}
  205. */
  206. const stringifyElement = (node, config, state) => {
  207. // beautiful injection for obtaining SVG information :)
  208. if (
  209. node.name === 'svg' &&
  210. node.attributes.width != null &&
  211. node.attributes.height != null
  212. ) {
  213. state.width = node.attributes.width;
  214. state.height = node.attributes.height;
  215. }
  216. // empty element and short tag
  217. if (node.children.length === 0) {
  218. if (config.useShortTags) {
  219. return (
  220. createIndent(config, state) +
  221. config.tagShortStart +
  222. node.name +
  223. stringifyAttributes(node, config) +
  224. config.tagShortEnd
  225. );
  226. } else {
  227. return (
  228. createIndent(config, state) +
  229. config.tagShortStart +
  230. node.name +
  231. stringifyAttributes(node, config) +
  232. config.tagOpenEnd +
  233. config.tagCloseStart +
  234. node.name +
  235. config.tagCloseEnd
  236. );
  237. }
  238. // non-empty element
  239. } else {
  240. let tagOpenStart = config.tagOpenStart;
  241. let tagOpenEnd = config.tagOpenEnd;
  242. let tagCloseStart = config.tagCloseStart;
  243. let tagCloseEnd = config.tagCloseEnd;
  244. let openIndent = createIndent(config, state);
  245. let closeIndent = createIndent(config, state);
  246. if (state.textContext) {
  247. tagOpenStart = defaults.tagOpenStart;
  248. tagOpenEnd = defaults.tagOpenEnd;
  249. tagCloseStart = defaults.tagCloseStart;
  250. tagCloseEnd = defaults.tagCloseEnd;
  251. openIndent = '';
  252. } else if (textElems.includes(node.name)) {
  253. tagOpenEnd = defaults.tagOpenEnd;
  254. tagCloseStart = defaults.tagCloseStart;
  255. closeIndent = '';
  256. state.textContext = node;
  257. }
  258. const children = stringifyNode(node, config, state);
  259. if (state.textContext === node) {
  260. state.textContext = null;
  261. }
  262. return (
  263. openIndent +
  264. tagOpenStart +
  265. node.name +
  266. stringifyAttributes(node, config) +
  267. tagOpenEnd +
  268. children +
  269. closeIndent +
  270. tagCloseStart +
  271. node.name +
  272. tagCloseEnd
  273. );
  274. }
  275. };
  276. /**
  277. * @type {(node: XastElement, config: Options) => string}
  278. */
  279. const stringifyAttributes = (node, config) => {
  280. let attrs = '';
  281. for (const [name, value] of Object.entries(node.attributes)) {
  282. // TODO remove attributes without values support in v3
  283. if (value !== undefined) {
  284. const encodedValue = value
  285. .toString()
  286. .replace(config.regValEntities, config.encodeEntity);
  287. attrs += ' ' + name + config.attrStart + encodedValue + config.attrEnd;
  288. } else {
  289. attrs += ' ' + name;
  290. }
  291. }
  292. return attrs;
  293. };
  294. /**
  295. * @type {(node: XastText, config: Options, state: State) => string}
  296. */
  297. const stringifyText = (node, config, state) => {
  298. return (
  299. createIndent(config, state) +
  300. config.textStart +
  301. node.value.replace(config.regEntities, config.encodeEntity) +
  302. (state.textContext ? '' : config.textEnd)
  303. );
  304. };