Styles.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  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 a lite CssStyleDeclaration replacement
  19. * (very limited in scope)
  20. *
  21. * @author dpvc@mathjax.org (Davide Cervone)
  22. */
  23. /**
  24. * An object contining name: value pairs
  25. */
  26. export type StyleList = {[name: string]: string};
  27. /**
  28. * Data for how to map a combined style (like border) to its children
  29. */
  30. export type connection = {
  31. children: string[], // suffix names to add to the base name
  32. split: (name: string) => void, // function to split the value for the children
  33. combine: (name: string) => void // function to combine the child values when one changes
  34. };
  35. /**
  36. * A collection of connections
  37. */
  38. export type connections = {[name: string]: connection};
  39. /*********************************************************/
  40. /**
  41. * Some common children arrays
  42. */
  43. const TRBL = ['top', 'right', 'bottom', 'left'];
  44. const WSC = ['width', 'style', 'color'];
  45. /**
  46. * Split a style at spaces (taking quotation marks and commas into account)
  47. *
  48. * @param {string} text The combined styles to be split at spaces
  49. * @return {string[]} Array of parts of the style (separated by spaces)
  50. */
  51. function splitSpaces(text: string): string[] {
  52. const parts = text.split(/((?:'[^']*'|"[^"]*"|,[\s\n]|[^\s\n])*)/g);
  53. const split = [] as string[];
  54. while (parts.length > 1) {
  55. parts.shift();
  56. split.push(parts.shift());
  57. }
  58. return split;
  59. }
  60. /*********************************************************/
  61. /**
  62. * Split a top-right-bottom-left group into its parts
  63. * Format:
  64. * x all are the same value
  65. * x y same as x y x y
  66. * x y z same as x y z y
  67. * x y z w each specified
  68. *
  69. * @param {string} name The style to be processed
  70. */
  71. function splitTRBL(name: string) {
  72. const parts = splitSpaces(this.styles[name]);
  73. if (parts.length === 0) {
  74. parts.push('');
  75. }
  76. if (parts.length === 1) {
  77. parts.push(parts[0]);
  78. }
  79. if (parts.length === 2) {
  80. parts.push(parts[0]);
  81. }
  82. if (parts.length === 3) {
  83. parts.push(parts[1]);
  84. }
  85. for (const child of Styles.connect[name].children) {
  86. this.setStyle(this.childName(name, child), parts.shift());
  87. }
  88. }
  89. /**
  90. * Combine top-right-bottom-left into one entry
  91. * (removing unneeded values)
  92. *
  93. * @param {string} name The style to be processed
  94. */
  95. function combineTRBL(name: string) {
  96. const children = Styles.connect[name].children;
  97. const parts = [] as string[];
  98. for (const child of children) {
  99. const part = this.styles[name + '-' + child];
  100. if (!part) {
  101. delete this.styles[name];
  102. return;
  103. }
  104. parts.push(part);
  105. }
  106. if (parts[3] === parts[1]) {
  107. parts.pop();
  108. if (parts[2] === parts[0]) {
  109. parts.pop();
  110. if (parts[1] === parts[0]) {
  111. parts.pop();
  112. }
  113. }
  114. }
  115. this.styles[name] = parts.join(' ');
  116. }
  117. /*********************************************************/
  118. /**
  119. * Use the same value for all children
  120. *
  121. * @param {string} name The style to be processed
  122. */
  123. function splitSame(name: string) {
  124. for (const child of Styles.connect[name].children) {
  125. this.setStyle(this.childName(name, child), this.styles[name]);
  126. }
  127. }
  128. /**
  129. * Check that all children have the same values and
  130. * if so, set the parent to that value
  131. *
  132. * @param {string} name The style to be processed
  133. */
  134. function combineSame(name: string) {
  135. const children = [...Styles.connect[name].children];
  136. const value = this.styles[this.childName(name, children.shift())];
  137. for (const child of children) {
  138. if (this.styles[this.childName(name, child)] !== value) {
  139. delete this.styles[name];
  140. return;
  141. }
  142. }
  143. this.styles[name] = value;
  144. }
  145. /*********************************************************/
  146. /**
  147. * Patterns for the parts of a boarder
  148. */
  149. const BORDER: {[name: string]: RegExp} = {
  150. width: /^(?:[\d.]+(?:[a-z]+)|thin|medium|thick|inherit|initial|unset)$/,
  151. style: /^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset|inherit|initial|unset)$/
  152. };
  153. /**
  154. * Split a width-style-color border definition
  155. *
  156. * @param {string} name The style to be processed
  157. */
  158. function splitWSC(name: string) {
  159. let parts = {width: '', style: '', color: ''} as StyleList;
  160. for (const part of splitSpaces(this.styles[name])) {
  161. if (part.match(BORDER.width) && parts.width === '') {
  162. parts.width = part;
  163. } else if (part.match(BORDER.style) && parts.style === '') {
  164. parts.style = part;
  165. } else {
  166. parts.color = part;
  167. }
  168. }
  169. for (const child of Styles.connect[name].children) {
  170. this.setStyle(this.childName(name, child), parts[child]);
  171. }
  172. }
  173. /**
  174. * Combine with-style-color border definition from children
  175. *
  176. * @param {string} name The style to be processed
  177. */
  178. function combineWSC(name: string) {
  179. const parts = [] as string[];
  180. for (const child of Styles.connect[name].children) {
  181. const value = this.styles[this.childName(name, child)];
  182. if (value) {
  183. parts.push(value);
  184. }
  185. }
  186. if (parts.length) {
  187. this.styles[name] = parts.join(' ');
  188. } else {
  189. delete this.styles[name];
  190. }
  191. }
  192. /*********************************************************/
  193. /**
  194. * Patterns for the parts of a font declaration
  195. */
  196. const FONT: {[name: string]: RegExp} = {
  197. style: /^(?:normal|italic|oblique|inherit|initial|unset)$/,
  198. variant: new RegExp('^(?:' +
  199. ['normal|none',
  200. 'inherit|initial|unset',
  201. 'common-ligatures|no-common-ligatures',
  202. 'discretionary-ligatures|no-discretionary-ligatures',
  203. 'historical-ligatures|no-historical-ligatures',
  204. 'contextual|no-contextual',
  205. '(?:stylistic|character-variant|swash|ornaments|annotation)\\([^)]*\\)',
  206. 'small-caps|all-small-caps|petite-caps|all-petite-caps|unicase|titling-caps',
  207. 'lining-nums|oldstyle-nums|proportional-nums|tabular-nums',
  208. 'diagonal-fractions|stacked-fractions',
  209. 'ordinal|slashed-zero',
  210. 'jis78|jis83|jis90|jis04|simplified|traditional',
  211. 'full-width|proportional-width',
  212. 'ruby'].join('|') + ')$'),
  213. weight: /^(?:normal|bold|bolder|lighter|[1-9]00|inherit|initial|unset)$/,
  214. stretch: new RegExp('^(?:' +
  215. ['normal',
  216. '(?:(?:ultra|extra|semi)-)?condensed',
  217. '(?:(?:semi|extra|ulta)-)?expanded',
  218. 'inherit|initial|unset']. join('|') + ')$'),
  219. size: new RegExp('^(?:' +
  220. ['xx-small|x-small|small|medium|large|x-large|xx-large|larger|smaller',
  221. '[\d.]+%|[\d.]+[a-z]+',
  222. 'inherit|initial|unset'].join('|') + ')' +
  223. '(?:\/(?:normal|[\d.\+](?:%|[a-z]+)?))?$')
  224. };
  225. /**
  226. * Split a font declaration into is parts (not perfect but good enough for now)
  227. *
  228. * @param {string} name The style to be processed
  229. */
  230. function splitFont(name: string) {
  231. const parts = splitSpaces(this.styles[name]);
  232. //
  233. // The parts found (array means can be more than one word)
  234. //
  235. const value = {
  236. style: '', variant: [], weight: '', stretch: '',
  237. size: '', family: '', 'line-height': ''
  238. } as {[name: string]: string | string[]};
  239. for (const part of parts) {
  240. value.family = part; // assume it is family unless otherwise (family must be present)
  241. for (const name of Object.keys(FONT)) {
  242. if ((Array.isArray(value[name]) || value[name] === '') && part.match(FONT[name])) {
  243. if (name === 'size') {
  244. //
  245. // Handle size/line-height
  246. //
  247. const [size, height] = part.split(/\//);
  248. value[name] = size;
  249. if (height) {
  250. value['line-height'] = height;
  251. }
  252. } else if (value.size === '') {
  253. //
  254. // style, weight, variant, stretch must appear before size
  255. //
  256. if (Array.isArray(value[name])) {
  257. (value[name] as string[]).push(part);
  258. } else {
  259. value[name] = part;
  260. }
  261. }
  262. }
  263. }
  264. }
  265. saveFontParts(name, value);
  266. delete this.styles[name]; // only use the parts, not the font declaration itself
  267. }
  268. /**
  269. * @param {string} name The style to be processed
  270. * @param {{[name: string]: string | string[]}} value The list of parts detected above
  271. */
  272. function saveFontParts(name: string, value: {[name: string]: string | string[]}) {
  273. for (const child of Styles.connect[name].children) {
  274. const cname = this.childName(name, child);
  275. if (Array.isArray(value[child])) {
  276. const values = value[child] as string[];
  277. if (values.length) {
  278. this.styles[cname] = values.join(' ');
  279. }
  280. } else if (value[child] !== '') {
  281. this.styles[cname] = value[child];
  282. }
  283. }
  284. }
  285. /**
  286. * Combine font parts into one (we don't actually do that)
  287. */
  288. function combineFont(_name: string) {}
  289. /*********************************************************/
  290. /**
  291. * Implements the Styles object (lite version of CssStyleDeclaration)
  292. */
  293. export class Styles {
  294. /**
  295. * Patterns for style values and comments
  296. */
  297. public static pattern: {[name: string]: RegExp} = {
  298. style: /([-a-z]+)[\s\n]*:[\s\n]*((?:'[^']*'|"[^"]*"|\n|.)*?)[\s\n]*(?:;|$)/g,
  299. comment: /\/\*[^]*?\*\//g
  300. };
  301. /**
  302. * The mapping of parents to children, and how to split and combine them
  303. */
  304. public static connect: connections = {
  305. padding: {
  306. children: TRBL,
  307. split: splitTRBL,
  308. combine: combineTRBL
  309. },
  310. border: {
  311. children: TRBL,
  312. split: splitSame,
  313. combine: combineSame
  314. },
  315. 'border-top': {
  316. children: WSC,
  317. split: splitWSC,
  318. combine: combineWSC
  319. },
  320. 'border-right': {
  321. children: WSC,
  322. split: splitWSC,
  323. combine: combineWSC
  324. },
  325. 'border-bottom': {
  326. children: WSC,
  327. split: splitWSC,
  328. combine: combineWSC
  329. },
  330. 'border-left': {
  331. children: WSC,
  332. split: splitWSC,
  333. combine: combineWSC
  334. },
  335. 'border-width': {
  336. children: TRBL,
  337. split: splitTRBL,
  338. combine: null // means its children combine to a different parent
  339. },
  340. 'border-style': {
  341. children: TRBL,
  342. split: splitTRBL,
  343. combine: null // means its children combine to a different parent
  344. },
  345. 'border-color': {
  346. children: TRBL,
  347. split: splitTRBL,
  348. combine: null // means its children combine to a different parent
  349. },
  350. font: {
  351. children: ['style', 'variant', 'weight', 'stretch', 'line-height', 'size', 'family'],
  352. split: splitFont,
  353. combine: combineFont
  354. }
  355. };
  356. /**
  357. * The list of styles defined for this declaration
  358. */
  359. protected styles: StyleList;
  360. /**
  361. * @param {string} cssText The initial definition for the style
  362. * @constructor
  363. */
  364. constructor(cssText: string = '') {
  365. this.parse(cssText);
  366. }
  367. /**
  368. * @return {string} The CSS string for the styles currently defined
  369. */
  370. public get cssText(): string {
  371. const styles = [] as string[];
  372. for (const name of Object.keys(this.styles)) {
  373. const parent = this.parentName(name);
  374. if (!this.styles[parent]) {
  375. styles.push(name + ': ' + this.styles[name] + ';');
  376. }
  377. }
  378. return styles.join(' ');
  379. }
  380. /**
  381. * @param {string} name The name of the style to set
  382. * @param {string|number|boolean} value The value to set it to
  383. */
  384. public set(name: string, value: string | number | boolean) {
  385. name = this.normalizeName(name);
  386. this.setStyle(name, value as string);
  387. //
  388. // If there is no combine function ,the children combine to
  389. // a separate parent (e.g., border-width sets border-top-width, etc.
  390. // and combines to border-top)
  391. //
  392. if (Styles.connect[name] && !Styles.connect[name].combine) {
  393. this.combineChildren(name);
  394. delete this.styles[name];
  395. }
  396. //
  397. // If we just changed a child, we need to try to combine
  398. // it with its parent's other children
  399. //
  400. while (name.match(/-/)) {
  401. name = this.parentName(name);
  402. if (!Styles.connect[name]) break;
  403. Styles.connect[name].combine.call(this, name);
  404. }
  405. }
  406. /**
  407. * @param {string} name The name of the style to get
  408. * @return {string} The value of the style (or empty string if not defined)
  409. */
  410. public get(name: string): string {
  411. name = this.normalizeName(name);
  412. return (this.styles.hasOwnProperty(name) ? this.styles[name] : '');
  413. }
  414. /**
  415. * @param {string} name The name of the style to set (without causing parent updates)
  416. * @param {string} value The value to set it to
  417. */
  418. protected setStyle(name: string, value: string) {
  419. this.styles[name] = value;
  420. if (Styles.connect[name] && Styles.connect[name].children) {
  421. Styles.connect[name].split.call(this, name);
  422. }
  423. if (value === '') {
  424. delete this.styles[name];
  425. }
  426. }
  427. /**
  428. * @param {string} name The name of the style whose parent is to be combined
  429. */
  430. protected combineChildren(name: string) {
  431. const parent = this.parentName(name);
  432. for (const child of Styles.connect[name].children) {
  433. const cname = this.childName(parent, child);
  434. Styles.connect[cname].combine.call(this, cname);
  435. }
  436. }
  437. /**
  438. * @param {string} name The name of the style whose parent style is to be found
  439. * @return {string} The name of the parent, or '' if none
  440. */
  441. protected parentName(name: string): string {
  442. const parent = name.replace(/-[^-]*$/, '');
  443. return (name === parent ? '' : parent);
  444. }
  445. /**
  446. * @param {string} name The name of the parent style
  447. * @param {string} child The suffix to be added to the parent
  448. * @preturn {string} The combined name
  449. */
  450. protected childName(name: string, child: string) {
  451. //
  452. // If the child contains a dash, it is already the fill name
  453. //
  454. if (child.match(/-/)) {
  455. return child;
  456. }
  457. //
  458. // For non-combining styles, like border-width, insert
  459. // the child name before the find word, e.g., border-top-width
  460. //
  461. if (Styles.connect[name] && !Styles.connect[name].combine) {
  462. child += name.replace(/.*-/, '-');
  463. name = this.parentName(name);
  464. }
  465. return name + '-' + child;
  466. }
  467. /**
  468. * @param {string} name The name of a style to normalize
  469. * @return {string} The name converted from CamelCase to lowercase with dashes
  470. */
  471. protected normalizeName(name: string): string {
  472. return name.replace(/[A-Z]/g, c => '-' + c.toLowerCase());
  473. }
  474. /**
  475. * @param {string} cssText A style text string to be parsed into separate styles
  476. * (by using this.set(), we get all the sub-styles created
  477. * as well as the merged style shorthands)
  478. */
  479. protected parse(cssText: string = '') {
  480. let PATTERN = (this.constructor as typeof Styles).pattern;
  481. this.styles = {};
  482. const parts = cssText.replace(PATTERN.comment, '').split(PATTERN.style);
  483. while (parts.length > 1) {
  484. let [space, name, value] = parts.splice(0, 3);
  485. if (space.match(/[^\s\n]/)) return;
  486. this.set(name, value);
  487. }
  488. }
  489. }