svg.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. /*************************************************************
  2. *
  3. * Copyright (c) 2018-2022 The MathJax Consortium
  4. *
  5. * Licensed under the Apache License, Version 2.0 (the "License");
  6. * you may not use this file except in compliance with the License.
  7. * You may obtain a copy of the License at
  8. *
  9. * http://www.apache.org/licenses/LICENSE-2.0
  10. *
  11. * Unless required by applicable law or agreed to in writing, software
  12. * distributed under the License is distributed on an "AS IS" BASIS,
  13. * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  14. * See the License for the specific language governing permissions and
  15. * limitations under the License.
  16. */
  17. /**
  18. * @fileoverview Implements the SVG OutputJax object
  19. *
  20. * @author dpvc@mathjax.org (Davide Cervone)
  21. */
  22. import {CommonOutputJax, UnknownBBox} from './common/OutputJax.js';
  23. import {OptionList} from '../util/Options.js';
  24. import {MathDocument} from '../core/MathDocument.js';
  25. import {MathItem} from '../core/MathItem.js';
  26. import {MmlNode} from '../core/MmlTree/MmlNode.js';
  27. import {SVGWrapper} from './svg/Wrapper.js';
  28. import {SVGWrapperFactory} from './svg/WrapperFactory.js';
  29. import {SVGFontData} from './svg/FontData.js';
  30. import {TeXFont} from './svg/fonts/tex.js';
  31. import {StyleList as CssStyleList} from '../util/StyleList.js';
  32. import {FontCache} from './svg/FontCache.js';
  33. import {unicodeChars} from '../util/string.js';
  34. import {percent} from '../util/lengths.js';
  35. export const SVGNS = 'http://www.w3.org/2000/svg';
  36. export const XLINKNS = 'http://www.w3.org/1999/xlink';
  37. /*****************************************************************/
  38. /**
  39. * Implements the CHTML class (extends AbstractOutputJax)
  40. *
  41. * @template N The HTMLElement node class
  42. * @template T The Text node class
  43. * @template D The Document class
  44. */
  45. export class SVG<N, T, D> extends
  46. CommonOutputJax<N, T, D, SVGWrapper<N, T, D>, SVGWrapperFactory<N, T, D>, SVGFontData, typeof SVGFontData> {
  47. /**
  48. * The name of the output jax
  49. */
  50. public static NAME: string = 'SVG';
  51. /**
  52. * @override
  53. */
  54. public static OPTIONS: OptionList = {
  55. ...CommonOutputJax.OPTIONS,
  56. internalSpeechTitles: true, // insert <title> tags with speech content
  57. titleID: 0, // initial id number to use for aria-labeledby titles
  58. fontCache: 'local', // or 'global' or 'none'
  59. localID: null, // ID to use for local font cache (for single equation processing)
  60. };
  61. /**
  62. * The default styles for SVG
  63. */
  64. public static commonStyles: CssStyleList = {
  65. 'mjx-container[jax="SVG"]': {
  66. direction: 'ltr'
  67. },
  68. 'mjx-container[jax="SVG"] > svg': {
  69. overflow: 'visible',
  70. 'min-height': '1px',
  71. 'min-width': '1px'
  72. },
  73. 'mjx-container[jax="SVG"] > svg a': {
  74. fill: 'blue', stroke: 'blue'
  75. }
  76. };
  77. /**
  78. * The ID for the SVG element that stores the cached font paths
  79. */
  80. public static FONTCACHEID = 'MJX-SVG-global-cache';
  81. /**
  82. * The ID for the stylesheet element for the styles for the SVG output
  83. */
  84. public static STYLESHEETID = 'MJX-SVG-styles';
  85. /**
  86. * Stores the CHTMLWrapper factory
  87. */
  88. public factory: SVGWrapperFactory<N, T, D>;
  89. /**
  90. * Stores the information about the cached character glyphs
  91. */
  92. public fontCache: FontCache<N, T, D>;
  93. /**
  94. * Minimum width for tables with labels,
  95. */
  96. public minwidth: number = 0;
  97. /**
  98. * The shift for the main equation
  99. */
  100. public shift: number = 0;
  101. /**
  102. * The container element for the math
  103. */
  104. public container: N = null;
  105. /**
  106. * The SVG stylesheet, once it is constructed
  107. */
  108. public svgStyles: N = null;
  109. /**
  110. * @override
  111. * @constructor
  112. */
  113. constructor(options: OptionList = null) {
  114. super(options, SVGWrapperFactory as any, TeXFont);
  115. this.fontCache = new FontCache(this);
  116. }
  117. /**
  118. * @override
  119. */
  120. public initialize() {
  121. if (this.options.fontCache === 'global') {
  122. this.fontCache.clearCache();
  123. }
  124. }
  125. /**
  126. * Clear the font cache (use for resetting the global font cache)
  127. */
  128. public clearFontCache() {
  129. this.fontCache.clearCache();
  130. }
  131. /**
  132. * @override
  133. */
  134. public reset() {
  135. this.clearFontCache();
  136. }
  137. /**
  138. * @override
  139. */
  140. protected setScale(node: N) {
  141. if (this.options.scale !== 1) {
  142. this.adaptor.setStyle(node, 'fontSize', percent(this.options.scale));
  143. }
  144. }
  145. /**
  146. * @override
  147. */
  148. public escaped(math: MathItem<N, T, D>, html: MathDocument<N, T, D>) {
  149. this.setDocument(html);
  150. return this.html('span', {}, [this.text(math.math)]);
  151. }
  152. /**
  153. * @override
  154. */
  155. public styleSheet(html: MathDocument<N, T, D>) {
  156. if (this.svgStyles) {
  157. return this.svgStyles; // stylesheet is already added to the document
  158. }
  159. const sheet = this.svgStyles = super.styleSheet(html);
  160. this.adaptor.setAttribute(sheet, 'id', SVG.STYLESHEETID);
  161. return sheet;
  162. }
  163. /**
  164. * @override
  165. */
  166. public pageElements(html: MathDocument<N, T, D>) {
  167. if (this.options.fontCache === 'global' && !this.findCache(html)) {
  168. return this.svg('svg', {id: SVG.FONTCACHEID, style: {display: 'none'}}, [this.fontCache.getCache()]);
  169. }
  170. return null as N;
  171. }
  172. /**
  173. * Checks if there is already a font-cache element in the page
  174. *
  175. * @param {MathDocument} html The document to search
  176. * @return {boolean} True if a font cache already exists in the page
  177. */
  178. protected findCache(html: MathDocument<N, T, D>): boolean {
  179. const adaptor = this.adaptor;
  180. const svgs = adaptor.tags(adaptor.body(html.document), 'svg');
  181. for (let i = svgs.length - 1; i >= 0; i--) {
  182. if (this.adaptor.getAttribute(svgs[i], 'id') === SVG.FONTCACHEID) {
  183. return true;
  184. }
  185. }
  186. return false;
  187. }
  188. /**
  189. * @param {MmlNode} math The MML node whose SVG is to be produced
  190. * @param {N} parent The HTML node to contain the SVG
  191. */
  192. protected processMath(math: MmlNode, parent: N) {
  193. //
  194. // Cache the container (tooltips process into separate containers)
  195. //
  196. const container = this.container;
  197. this.container = parent;
  198. //
  199. // Get the wrapped math element and the SVG container
  200. // Then typeset the math into the SVG
  201. //
  202. const wrapper = this.factory.wrap(math);
  203. const [svg, g] = this.createRoot(wrapper);
  204. this.typesetSVG(wrapper, svg, g);
  205. //
  206. // Put back the original container
  207. //
  208. this.container = container;
  209. }
  210. /**
  211. * @param {SVGWrapper} wrapper The wrapped math to process
  212. * @return {[N, N]} The svg and g nodes for the math
  213. */
  214. protected createRoot(wrapper: SVGWrapper<N, T, D>): [N, N] {
  215. const {w, h, d, pwidth} = wrapper.getOuterBBox();
  216. const px = wrapper.metrics.em / 1000;
  217. const W = Math.max(w, px); // make sure we are at least one px wide (needed for e.g. \llap)
  218. const H = Math.max(h + d, px); // make sure we are at least one px tall (needed for e.g., \smash)
  219. //
  220. // The container that flips the y-axis and sets the colors to inherit from the surroundings
  221. //
  222. const g = this.svg('g', {
  223. stroke: 'currentColor', fill: 'currentColor',
  224. 'stroke-width': 0, transform: 'scale(1,-1)'
  225. }) as N;
  226. //
  227. // The svg element with its viewBox, size and alignment
  228. //
  229. const adaptor = this.adaptor;
  230. const svg = adaptor.append(this.container, this.svg('svg', {
  231. xmlns: SVGNS,
  232. width: this.ex(W), height: this.ex(H),
  233. role: 'img', focusable: false,
  234. style: {'vertical-align': this.ex(-d)},
  235. viewBox: [0, this.fixed(-h * 1000, 1), this.fixed(W * 1000, 1), this.fixed(H * 1000, 1)].join(' ')
  236. }, [g])) as N;
  237. if (W === .001) {
  238. adaptor.setAttribute(svg, 'preserveAspectRatio', 'xMidYMid slice');
  239. if (w < 0) {
  240. adaptor.setStyle(this.container, 'margin-right', this.ex(w));
  241. }
  242. }
  243. if (pwidth) {
  244. //
  245. // Use width 100% with no viewbox, and instead scale and translate to achieve the same result
  246. //
  247. adaptor.setStyle(svg, 'min-width', this.ex(W));
  248. adaptor.setAttribute(svg, 'width', pwidth);
  249. adaptor.removeAttribute(svg, 'viewBox');
  250. const scale = this.fixed(wrapper.metrics.ex / (this.font.params.x_height * 1000), 6);
  251. adaptor.setAttribute(g, 'transform', `scale(${scale},-${scale}) translate(0, ${this.fixed(-h * 1000, 1)})`);
  252. }
  253. if (this.options.fontCache !== 'none') {
  254. adaptor.setAttribute(svg, 'xmlns:xlink', XLINKNS);
  255. }
  256. return [svg, g];
  257. }
  258. /**
  259. * Typeset the math and add minwidth (from mtables), or set the alignment and indentation
  260. * of the finalized expression.
  261. *
  262. * @param {SVGWrapper} wrapper The wrapped math to typeset
  263. * @param {N} svg The main svg element for the typeet math
  264. * @param {N} g The group in which the math is typeset
  265. */
  266. protected typesetSVG(wrapper: SVGWrapper<N, T, D>, svg: N, g: N) {
  267. const adaptor = this.adaptor;
  268. //
  269. // Typeset the math and add minWidth (from mtables), or set the alignment and indentation
  270. // of the finalized expression
  271. //
  272. this.minwidth = this.shift = 0;
  273. if (this.options.fontCache === 'local') {
  274. this.fontCache.clearCache();
  275. this.fontCache.useLocalID(this.options.localID);
  276. adaptor.insert(this.fontCache.getCache(), g);
  277. }
  278. wrapper.toSVG(g);
  279. this.fontCache.clearLocalID();
  280. if (this.minwidth) {
  281. adaptor.setStyle(svg, 'minWidth', this.ex(this.minwidth));
  282. adaptor.setStyle(this.container, 'minWidth', this.ex(this.minwidth));
  283. } else if (this.shift) {
  284. const align = adaptor.getAttribute(this.container, 'justify') || 'center';
  285. this.setIndent(svg, align, this.shift);
  286. }
  287. }
  288. /**
  289. * @param {N} svg The svg node whose indentation is to be adjusted
  290. * @param {string} align The alignment for the node
  291. * @param {number} shift The indent (positive or negative) for the node
  292. */
  293. protected setIndent(svg: N, align: string, shift: number) {
  294. if (align === 'center' || align === 'left') {
  295. this.adaptor.setStyle(svg, 'margin-left', this.ex(shift));
  296. }
  297. if (align === 'center' || align === 'right') {
  298. this.adaptor.setStyle(svg, 'margin-right', this.ex(-shift));
  299. }
  300. }
  301. /**
  302. * @param {number} m A number to be shown in ex
  303. * @return {string} The number with units of ex
  304. */
  305. public ex(m: number): string {
  306. m /= this.font.params.x_height;
  307. return (Math.abs(m) < .001 ? '0' : m.toFixed(3).replace(/\.?0+$/, '') + 'ex');
  308. }
  309. /**
  310. * @param {string} kind The kind of node to create
  311. * @param {OptionList} properties The properties to set for the element
  312. * @param {(N|T)[]} children The child nodes for this node
  313. * @return {N} The newly created node in the SVG namespace
  314. */
  315. public svg(kind: string, properties: OptionList = {}, children: (N | T)[] = []): N {
  316. return this.html(kind, properties, children, SVGNS);
  317. }
  318. /**
  319. * @param {string} text The text to be displayed
  320. * @param {string} variant The name of the variant for the text
  321. * @return {N} The text element containing the text
  322. */
  323. public unknownText(text: string, variant: string): N {
  324. const metrics = this.math.metrics;
  325. const scale = this.font.params.x_height / metrics.ex * metrics.em * 1000;
  326. const svg = this.svg('text', {
  327. 'data-variant': variant,
  328. transform: 'scale(1,-1)', 'font-size': this.fixed(scale, 1) + 'px'
  329. }, [this.text(text)]);
  330. const adaptor = this.adaptor;
  331. if (variant !== '-explicitFont') {
  332. const c = unicodeChars(text);
  333. if (c.length !== 1 || c[0] < 0x1D400 || c[0] > 0x1D7FF) {
  334. const [family, italic, bold] = this.font.getCssFont(variant);
  335. adaptor.setAttribute(svg, 'font-family', family);
  336. if (italic) {
  337. adaptor.setAttribute(svg, 'font-style', 'italic');
  338. }
  339. if (bold) {
  340. adaptor.setAttribute(svg, 'font-weight', 'bold');
  341. }
  342. }
  343. }
  344. return svg;
  345. }
  346. /**
  347. * Measure the width of a text element by placing it in the page
  348. * and looking up its size (fake the height and depth, since we can't measure that)
  349. *
  350. * @param {N} text The text element to measure
  351. * @return {Object} The width, height and depth for the text
  352. */
  353. public measureTextNode(text: N): UnknownBBox {
  354. const adaptor = this.adaptor;
  355. text = adaptor.clone(text);
  356. adaptor.removeAttribute(text, 'transform');
  357. const ex = this.fixed(this.font.params.x_height * 1000, 1);
  358. const svg = this.svg('svg', {
  359. position: 'absolute', visibility: 'hidden',
  360. width: '1ex', height: '1ex',
  361. viewBox: [0, 0, ex, ex].join(' ')
  362. }, [text]);
  363. adaptor.append(adaptor.body(adaptor.document), svg);
  364. let w = adaptor.nodeSize(text, 1000, true)[0];
  365. adaptor.remove(svg);
  366. return {w: w, h: .75, d: .2};
  367. }
  368. }