8
0

conversion.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707
  1. /**
  2. * @license Copyright (c) 2003-2019, CKSource - Frederico Knabben. All rights reserved.
  3. * For licensing, see LICENSE.md.
  4. */
  5. import Conversion from '../../src/conversion/conversion';
  6. import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
  7. import UpcastDispatcher from '../../src/conversion/upcastdispatcher';
  8. import UpcastHelpers, { convertText, convertToModelFragment } from '../../src/conversion/upcasthelpers';
  9. import DowncastHelpers from '../../src/conversion/downcasthelpers';
  10. import EditingController from '../../src/controller/editingcontroller';
  11. import Model from '../../src/model/model';
  12. import { parse as viewParse, stringify as viewStringify } from '../../src/dev-utils/view';
  13. import { stringify as modelStringify } from '../../src/dev-utils/model';
  14. import ConversionHelpers from '../../src/conversion/conversionhelpers';
  15. describe( 'Conversion', () => {
  16. let conversion, downcastDispA, upcastDispaA, downcastDispB;
  17. beforeEach( () => {
  18. // Placeholders. Will be used only to see if their were given as attribute for a spy function.
  19. downcastDispA = Symbol( 'downA' );
  20. downcastDispB = Symbol( 'downB' );
  21. upcastDispaA = Symbol( 'upA' );
  22. conversion = new Conversion( [ downcastDispA, downcastDispB ], upcastDispaA );
  23. } );
  24. describe( 'addAlias()', () => {
  25. it( 'should throw when trying to use same group name twice', () => {
  26. expect( () => {
  27. conversion.addAlias( 'upcast', upcastDispaA );
  28. } ).to.throw( CKEditorError, /conversion-group-exists/ );
  29. } );
  30. it( 'should throw when trying to add not registered dispatcher', () => {
  31. expect( () => {
  32. conversion.addAlias( 'foo', {} );
  33. } ).to.throw( CKEditorError, /conversion-add-alias-dispatcher-not-registered/ );
  34. } );
  35. } );
  36. describe( 'for()', () => {
  37. it( 'should return ConversionHelpers', () => {
  38. expect( conversion.for( 'upcast' ) ).to.be.instanceof( ConversionHelpers );
  39. expect( conversion.for( 'downcast' ) ).to.be.instanceof( ConversionHelpers );
  40. } );
  41. it( 'should throw if non-existing group name has been used', () => {
  42. expect( () => {
  43. conversion.for( 'foo' );
  44. } ).to.throw( CKEditorError, /conversion-for-unknown-group/ );
  45. } );
  46. it( 'should return proper helpers for group', () => {
  47. expect( conversion.for( 'upcast' ) ).to.be.instanceof( UpcastHelpers );
  48. conversion.addAlias( 'foo', upcastDispaA );
  49. expect( conversion.for( 'foo' ) ).to.be.instanceof( UpcastHelpers );
  50. expect( conversion.for( 'downcast' ) ).to.be.instanceof( DowncastHelpers );
  51. conversion.addAlias( 'bar', downcastDispB );
  52. expect( conversion.for( 'bar' ) ).to.be.instanceof( DowncastHelpers );
  53. } );
  54. } );
  55. describe( 'add()', () => {
  56. let helperA, helperB;
  57. beforeEach( () => {
  58. helperA = sinon.stub();
  59. helperB = sinon.stub();
  60. } );
  61. it( 'should be chainable', () => {
  62. const helpers = conversion.for( 'upcast' );
  63. const addResult = helpers.add( () => {} );
  64. expect( addResult ).to.equal( helpers );
  65. } );
  66. it( 'should fire given helper for every dispatcher in given group', () => {
  67. conversion.for( 'downcast' ).add( helperA );
  68. expect( helperA.calledWithExactly( downcastDispA ) ).to.be.true;
  69. expect( helperA.calledWithExactly( downcastDispB ) ).to.be.true;
  70. expect( helperA.calledWithExactly( upcastDispaA ) ).to.be.false;
  71. conversion.for( 'upcast' ).add( helperB );
  72. expect( helperB.calledWithExactly( downcastDispA ) ).to.be.false;
  73. expect( helperB.calledWithExactly( downcastDispB ) ).to.be.false;
  74. expect( helperB.calledWithExactly( upcastDispaA ) ).to.be.true;
  75. } );
  76. } );
  77. describe( 'converters', () => {
  78. let viewDispatcher, model, schema, conversion, modelRoot, viewRoot;
  79. beforeEach( () => {
  80. model = new Model();
  81. const controller = new EditingController( model );
  82. const modelDoc = model.document;
  83. modelRoot = modelDoc.createRoot();
  84. viewRoot = controller.view.document.getRoot();
  85. // Set name of view root the same as dom root.
  86. // This is a mock of attaching view root to dom root.
  87. viewRoot._name = 'div';
  88. schema = model.schema;
  89. schema.extend( '$text', {
  90. allowIn: '$root',
  91. allowAttributes: [ 'bold' ]
  92. } );
  93. schema.register( 'paragraph', {
  94. inheritAllFrom: '$block'
  95. } );
  96. viewDispatcher = new UpcastDispatcher( model, { schema } );
  97. viewDispatcher.on( 'text', convertText() );
  98. viewDispatcher.on( 'element', convertToModelFragment(), { priority: 'lowest' } );
  99. viewDispatcher.on( 'documentFragment', convertToModelFragment(), { priority: 'lowest' } );
  100. conversion = new Conversion( controller.downcastDispatcher, viewDispatcher );
  101. } );
  102. describe( 'elementToElement', () => {
  103. it( 'config.view is a string', () => {
  104. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  105. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
  106. } );
  107. it( 'config.converterPriority is defined (override downcast)', () => {
  108. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  109. conversion.elementToElement( { model: 'paragraph', view: 'div', converterPriority: 'high' } );
  110. test( '<div>Foo</div>', '<paragraph>Foo</paragraph>' );
  111. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>', '<div>Foo</div>' );
  112. } );
  113. it( 'config.converterPriority is defined (override upcast)', () => {
  114. schema.register( 'foo', {
  115. inheritAllFrom: '$block'
  116. } );
  117. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  118. conversion.elementToElement( { model: 'foo', view: 'p', converterPriority: 'high' } );
  119. test( '<p>Foo</p>', '<foo>Foo</foo>', '<p>Foo</p>' );
  120. } );
  121. it( 'config.view is an object', () => {
  122. schema.register( 'fancyParagraph', {
  123. inheritAllFrom: 'paragraph'
  124. } );
  125. conversion.elementToElement( {
  126. model: 'fancyParagraph',
  127. view: {
  128. name: 'p',
  129. classes: 'fancy'
  130. }
  131. } );
  132. test( '<p class="fancy">Foo</p>', '<fancyParagraph>Foo</fancyParagraph>' );
  133. } );
  134. it( 'config.view is an object with upcastAlso defined', () => {
  135. conversion.elementToElement( {
  136. model: 'paragraph',
  137. view: 'p',
  138. upcastAlso: [
  139. 'div',
  140. {
  141. // Any element with `display: block` style.
  142. styles: {
  143. display: 'block'
  144. }
  145. }
  146. ]
  147. } );
  148. test( '<p>Foo</p>', '<paragraph>Foo</paragraph>' );
  149. test( '<div>Foo</div>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  150. test( '<span style="display:block">Foo</span>', '<paragraph>Foo</paragraph>', '<p>Foo</p>' );
  151. } );
  152. it( 'upcastAlso given as a function', () => {
  153. schema.register( 'heading', {
  154. inheritAllFrom: '$block'
  155. } );
  156. conversion.elementToElement( {
  157. model: 'heading',
  158. view: 'h2',
  159. upcastAlso: viewElement => {
  160. const fontSize = viewElement.getStyle( 'font-size' );
  161. if ( !fontSize ) {
  162. return null;
  163. }
  164. const match = fontSize.match( /(\d+)\s*px/ );
  165. if ( !match ) {
  166. return null;
  167. }
  168. const size = Number( match[ 1 ] );
  169. if ( size >= 26 ) {
  170. return { name: true, style: [ 'font-size' ] };
  171. }
  172. return null;
  173. }
  174. } );
  175. conversion.elementToElement( {
  176. model: 'paragraph',
  177. view: 'p'
  178. } );
  179. test( '<p></p>', '<paragraph></paragraph>' );
  180. test( '<p style="font-size:20px"></p>', '<paragraph></paragraph>', '<p></p>' );
  181. test( '<h2></h2>', '<heading></heading>' );
  182. test( '<p style="font-size:26px"></p>', '<heading></heading>', '<h2></h2>' );
  183. } );
  184. } );
  185. describe( 'attributeToElement', () => {
  186. beforeEach( () => {
  187. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  188. } );
  189. it( 'config.view is a string', () => {
  190. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  191. test( '<p><strong>Foo</strong> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  192. } );
  193. it( 'config.converterPriority is defined (override downcast)', () => {
  194. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  195. conversion.attributeToElement( { model: 'bold', view: 'b', converterPriority: 'high' } );
  196. test( '<p><b>Foo</b></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>' );
  197. test( '<p><strong>Foo</strong></p>', '<paragraph><$text bold="true">Foo</$text></paragraph>', '<p><b>Foo</b></p>' );
  198. } );
  199. it( 'config.converterPriority is defined (override upcast)', () => {
  200. schema.extend( '$text', {
  201. allowAttributes: [ 'foo' ]
  202. } );
  203. conversion.attributeToElement( { model: 'bold', view: 'strong' } );
  204. conversion.attributeToElement( { model: 'foo', view: 'strong', converterPriority: 'high' } );
  205. test(
  206. '<p><strong>Foo</strong></p>',
  207. '<paragraph><$text foo="true">Foo</$text></paragraph>',
  208. '<p><strong>Foo</strong></p>'
  209. );
  210. } );
  211. it( 'config.view is an object', () => {
  212. conversion.attributeToElement( {
  213. model: 'bold',
  214. view: {
  215. name: 'span',
  216. classes: 'bold'
  217. }
  218. } );
  219. test( '<p><span class="bold">Foo</span> bar</p>', '<paragraph><$text bold="true">Foo</$text> bar</paragraph>' );
  220. } );
  221. it( 'config.view is an object with upcastAlso defined', () => {
  222. conversion.attributeToElement( {
  223. model: 'bold',
  224. view: 'strong',
  225. upcastAlso: [
  226. 'b',
  227. {
  228. name: 'span',
  229. classes: 'bold'
  230. },
  231. {
  232. name: 'span',
  233. styles: {
  234. 'font-weight': 'bold'
  235. }
  236. },
  237. viewElement => {
  238. const fontWeight = viewElement.getStyle( 'font-weight' );
  239. if ( viewElement.is( 'span' ) && fontWeight && /\d+/.test( fontWeight ) && Number( fontWeight ) > 500 ) {
  240. return {
  241. name: true,
  242. styles: [ 'font-weight' ]
  243. };
  244. }
  245. }
  246. ]
  247. } );
  248. test(
  249. '<p><strong>Foo</strong></p>',
  250. '<paragraph><$text bold="true">Foo</$text></paragraph>'
  251. );
  252. test(
  253. '<p><b>Foo</b></p>',
  254. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  255. '<p><strong>Foo</strong></p>'
  256. );
  257. test(
  258. '<p><span class="bold">Foo</span></p>',
  259. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  260. '<p><strong>Foo</strong></p>'
  261. );
  262. test(
  263. '<p><span style="font-weight: bold;">Foo</span></p>',
  264. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  265. '<p><strong>Foo</strong></p>'
  266. );
  267. test(
  268. '<p><span style="font-weight: 500;">Foo</span></p>',
  269. '<paragraph>Foo</paragraph>',
  270. '<p>Foo</p>'
  271. );
  272. test(
  273. '<p><span style="font-weight: 600;">Foo</span></p>',
  274. '<paragraph><$text bold="true">Foo</$text></paragraph>',
  275. '<p><strong>Foo</strong></p>'
  276. );
  277. } );
  278. it( 'model attribute value is enumerable', () => {
  279. schema.extend( '$text', {
  280. allowAttributes: [ 'fontSize' ]
  281. } );
  282. conversion.attributeToElement( {
  283. model: {
  284. key: 'fontSize',
  285. values: [ 'big', 'small' ]
  286. },
  287. view: {
  288. big: {
  289. name: 'span',
  290. styles: {
  291. 'font-size': '1.2em'
  292. }
  293. },
  294. small: {
  295. name: 'span',
  296. styles: {
  297. 'font-size': '0.8em'
  298. }
  299. }
  300. },
  301. upcastAlso: {
  302. big: viewElement => {
  303. const fontSize = viewElement.getStyle( 'font-size' );
  304. if ( !fontSize ) {
  305. return null;
  306. }
  307. const match = fontSize.match( /(\d+)\s*px/ );
  308. if ( !match ) {
  309. return null;
  310. }
  311. const size = Number( match[ 1 ] );
  312. if ( viewElement.is( 'span' ) && size > 10 ) {
  313. return { name: true, style: [ 'font-size' ] };
  314. }
  315. return null;
  316. },
  317. small: viewElement => {
  318. const fontSize = viewElement.getStyle( 'font-size' );
  319. if ( !fontSize ) {
  320. return null;
  321. }
  322. const match = fontSize.match( /(\d+)\s*px/ );
  323. if ( !match ) {
  324. return null;
  325. }
  326. const size = Number( match[ 1 ] );
  327. if ( viewElement.is( 'span' ) && size < 10 ) {
  328. return { name: true, style: [ 'font-size' ] };
  329. }
  330. return null;
  331. }
  332. }
  333. } );
  334. test(
  335. '<p><span style="font-size:1.2em">Foo</span> bar</p>',
  336. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>'
  337. );
  338. test(
  339. '<p><span style="font-size:12px">Foo</span> bar</p>',
  340. '<paragraph><$text fontSize="big">Foo</$text> bar</paragraph>',
  341. '<p><span style="font-size:1.2em">Foo</span> bar</p>'
  342. );
  343. test(
  344. '<p><span style="font-size:0.8em">Foo</span> bar</p>',
  345. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>'
  346. );
  347. test(
  348. '<p><span style="font-size:8px">Foo</span> bar</p>',
  349. '<paragraph><$text fontSize="small">Foo</$text> bar</paragraph>',
  350. '<p><span style="font-size:0.8em">Foo</span> bar</p>'
  351. );
  352. test(
  353. '<p><span style="font-size:10px">Foo</span> bar</p>',
  354. '<paragraph>Foo bar</paragraph>',
  355. '<p>Foo bar</p>'
  356. );
  357. } );
  358. it( 'config.model.name is given', () => {
  359. schema.extend( '$text', {
  360. allowAttributes: [ 'textDecoration' ]
  361. } );
  362. conversion.attributeToElement( {
  363. model: {
  364. key: 'textDecoration',
  365. values: [ 'underline', 'lineThrough' ],
  366. name: '$text'
  367. },
  368. view: {
  369. underline: {
  370. name: 'span',
  371. styles: {
  372. 'text-decoration': 'underline'
  373. }
  374. },
  375. lineThrough: {
  376. name: 'span',
  377. styles: {
  378. 'text-decoration': 'line-through'
  379. }
  380. }
  381. }
  382. } );
  383. test(
  384. '<p><span style="text-decoration:underline">Foo</span></p>',
  385. '<paragraph><$text textDecoration="underline">Foo</$text></paragraph>'
  386. );
  387. test(
  388. '<p><span style="text-decoration:line-through">Foo</span></p>',
  389. '<paragraph><$text textDecoration="lineThrough">Foo</$text></paragraph>'
  390. );
  391. test(
  392. '<p><span style="text-decoration:underline">Foo</span></p>',
  393. '<paragraph><$text textDecoration="underline">Foo</$text></paragraph>'
  394. );
  395. } );
  396. } );
  397. describe( 'attributeToAttribute', () => {
  398. beforeEach( () => {
  399. conversion.elementToElement( { model: 'image', view: 'img' } );
  400. schema.register( 'image', {
  401. inheritAllFrom: '$block',
  402. } );
  403. } );
  404. it( 'config.view and config.model are strings', () => {
  405. schema.extend( 'image', {
  406. allowAttributes: [ 'source' ]
  407. } );
  408. conversion.attributeToAttribute( { model: 'source', view: 'src' } );
  409. test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
  410. } );
  411. it( 'config.view and config.model are objects', () => {
  412. schema.extend( 'image', {
  413. allowAttributes: [ 'aside' ]
  414. } );
  415. conversion.attributeToAttribute( {
  416. model: {
  417. name: 'image',
  418. key: 'aside',
  419. values: [ 'aside' ]
  420. },
  421. view: {
  422. aside: {
  423. name: 'img',
  424. key: 'class',
  425. value: [ 'aside', 'half-size' ]
  426. }
  427. }
  428. } );
  429. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  430. test( '<img class="aside half-size"></img>', '<image aside="aside"></image>' );
  431. test( '<p class="aside half-size"></p>', '<paragraph></paragraph>', '<p></p>' );
  432. } );
  433. it( 'config.view and config.model are objects - convert to style attribute', () => {
  434. schema.extend( 'image', {
  435. allowAttributes: [ 'aside' ]
  436. } );
  437. conversion.attributeToAttribute( {
  438. model: {
  439. name: 'image',
  440. key: 'aside',
  441. values: [ 'aside' ]
  442. },
  443. view: {
  444. aside: {
  445. name: 'img',
  446. key: 'style',
  447. value: {
  448. float: 'right',
  449. width: '50%',
  450. margin: '5px'
  451. }
  452. }
  453. }
  454. } );
  455. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  456. test( '<img style="float:right;margin:5px;width:50%"></img>', '<image aside="aside"></image>' );
  457. test( '<p style="float:right;margin:5px;width:50%"></p>', '<paragraph></paragraph>', '<p></p>' );
  458. } );
  459. it( 'config is an array with upcastAlso defined', () => {
  460. conversion.elementToElement( { model: 'paragraph', view: 'p' } );
  461. schema.extend( 'paragraph', {
  462. allowAttributes: [ 'align' ]
  463. } );
  464. conversion.attributeToAttribute( {
  465. model: {
  466. key: 'align',
  467. values: [ 'right', 'center' ]
  468. },
  469. view: {
  470. right: {
  471. key: 'class',
  472. value: 'align-right'
  473. },
  474. center: {
  475. key: 'class',
  476. value: 'align-center'
  477. }
  478. },
  479. upcastAlso: {
  480. right: {
  481. styles: {
  482. 'text-align': 'right'
  483. }
  484. },
  485. center: {
  486. styles: {
  487. 'text-align': 'center'
  488. }
  489. }
  490. }
  491. } );
  492. test(
  493. '<p class="align-right">Foo</p>',
  494. '<paragraph align="right">Foo</paragraph>'
  495. );
  496. test(
  497. '<p style="text-align:right">Foo</p>',
  498. '<paragraph align="right">Foo</paragraph>',
  499. '<p class="align-right">Foo</p>'
  500. );
  501. test(
  502. '<p class="align-center">Foo</p>',
  503. '<paragraph align="center">Foo</paragraph>'
  504. );
  505. test(
  506. '<p style="text-align:center">Foo</p>',
  507. '<paragraph align="center">Foo</paragraph>',
  508. '<p class="align-center">Foo</p>'
  509. );
  510. } );
  511. it( 'config.view and config.model have name and key set', () => {
  512. schema.extend( 'image', {
  513. allowAttributes: [ 'source' ]
  514. } );
  515. conversion.attributeToAttribute( {
  516. model: {
  517. name: 'image',
  518. key: 'source'
  519. },
  520. view: {
  521. name: 'img',
  522. key: 'src'
  523. }
  524. } );
  525. test( '<img src="foo.jpg"></img>', '<image source="foo.jpg"></image>' );
  526. } );
  527. // #1443.
  528. it( 'should not set attributes on the element\'s children', () => {
  529. schema.register( 'div', {
  530. inheritAllFrom: '$root',
  531. allowWhere: '$block',
  532. isLimit: true,
  533. allowAttributes: [ 'border', 'shade' ]
  534. } );
  535. conversion.elementToElement(
  536. { model: 'div', view: 'div' }
  537. );
  538. conversion.attributeToAttribute( { model: 'border', view: { key: 'class', value: 'border' } } );
  539. conversion.attributeToAttribute( { model: 'shade', view: { key: 'class', value: 'shade' } } );
  540. test(
  541. '<div class="border"><div class="shade"></div></div>',
  542. '<div border="border"><div shade="shade"></div></div>'
  543. );
  544. } );
  545. it( 'config.converterPriority is defined (override downcast)', () => {
  546. schema.extend( 'image', {
  547. allowAttributes: [ 'foo' ]
  548. } );
  549. conversion.attributeToAttribute( { model: 'foo', view: 'foo' } );
  550. conversion.attributeToAttribute( { model: 'foo', view: 'foofoo', converterPriority: 'high' } );
  551. test( '<img foo="foo"></img>', '<image foo="foo"></image>', '<img foofoo="foo"></img>' );
  552. } );
  553. } );
  554. function test( input, expectedModel, expectedView = null ) {
  555. loadData( input );
  556. expect( modelStringify( model.document.getRoot() ) ).to.equal( expectedModel );
  557. expect( viewStringify( viewRoot, null, { ignoreRoot: true } ) ).to.equal( expectedView || input );
  558. }
  559. function loadData( input ) {
  560. const parsedView = viewParse( input );
  561. let convertedModel;
  562. model.change( writer => {
  563. convertedModel = viewDispatcher.convert( parsedView, writer );
  564. } );
  565. model.change( writer => {
  566. writer.remove( writer.createRange(
  567. writer.createPositionAt( modelRoot, 0 ),
  568. writer.createPositionAt( modelRoot, modelRoot.maxOffset ) )
  569. );
  570. writer.insert( convertedModel, modelRoot, 0 );
  571. } );
  572. }
  573. } );
  574. } );