浏览代码

Merge pull request #224 from ckeditor/i/3287

Feature: Introduced the table and table cell properties plugins. Closes ckeditor/ckeditor5#6048.
Aleksander Nowodzinski 5 年之前
父节点
当前提交
f0a96db598
共有 67 个文件被更改,包括 6620 次插入370 次删除
  1. 3 1
      packages/ckeditor5-table/package.json
  2. 30 0
      packages/ckeditor5-table/src/commands/utils.js
  3. 116 0
      packages/ckeditor5-table/src/converters/tableproperties.js
  4. 37 0
      packages/ckeditor5-table/src/tablecellproperties.js
  5. 35 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbackgroundcolorcommand.js
  6. 47 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbordercolorcommand.js
  7. 47 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderstylecommand.js
  8. 47 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderwidthcommand.js
  9. 35 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellheightcommand.js
  10. 35 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellhorizontalalignmentcommand.js
  11. 47 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpaddingcommand.js
  12. 89 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpropertycommand.js
  13. 42 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellverticalalignmentcommand.js
  14. 35 0
      packages/ckeditor5-table/src/tablecellproperties/commands/tablecellwidthcommand.js
  15. 172 0
      packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesediting.js
  16. 37 0
      packages/ckeditor5-table/src/tableproperties.js
  17. 35 0
      packages/ckeditor5-table/src/tableproperties/commands/tablealignmentcommand.js
  18. 35 0
      packages/ckeditor5-table/src/tableproperties/commands/tablebackgroundcolorcommand.js
  19. 47 0
      packages/ckeditor5-table/src/tableproperties/commands/tablebordercolorcommand.js
  20. 47 0
      packages/ckeditor5-table/src/tableproperties/commands/tableborderstylecommand.js
  21. 47 0
      packages/ckeditor5-table/src/tableproperties/commands/tableborderwidthcommand.js
  22. 35 0
      packages/ckeditor5-table/src/tableproperties/commands/tableheightcommand.js
  23. 88 0
      packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js
  24. 35 0
      packages/ckeditor5-table/src/tableproperties/commands/tablewidthcommand.js
  25. 194 0
      packages/ckeditor5-table/src/tableproperties/tablepropertiesediting.js
  26. 2 2
      packages/ckeditor5-table/src/tableui.js
  27. 148 44
      packages/ckeditor5-table/tests/_utils/utils.js
  28. 16 15
      packages/ckeditor5-table/tests/commands/insertcolumncommand.js
  29. 15 14
      packages/ckeditor5-table/tests/commands/insertrowcommand.js
  30. 11 10
      packages/ckeditor5-table/tests/commands/inserttablecommand.js
  31. 27 26
      packages/ckeditor5-table/tests/commands/mergecellcommand.js
  32. 7 6
      packages/ckeditor5-table/tests/commands/removecolumncommand.js
  33. 7 6
      packages/ckeditor5-table/tests/commands/removerowcommand.js
  34. 8 7
      packages/ckeditor5-table/tests/commands/setheadercolumncommand.js
  35. 13 12
      packages/ckeditor5-table/tests/commands/setheaderrowcommand.js
  36. 8 7
      packages/ckeditor5-table/tests/commands/splitcellcommand.js
  37. 77 77
      packages/ckeditor5-table/tests/converters/downcast.js
  38. 15 15
      packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js
  39. 16 15
      packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js
  40. 30 29
      packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js
  41. 10 12
      packages/ckeditor5-table/tests/converters/upcasttable.js
  42. 412 0
      packages/ckeditor5-table/tests/manual/tableproperties.html
  43. 39 0
      packages/ckeditor5-table/tests/manual/tableproperties.js
  44. 12 0
      packages/ckeditor5-table/tests/manual/tableproperties.md
  45. 7 6
      packages/ckeditor5-table/tests/table-integration.js
  46. 44 0
      packages/ckeditor5-table/tests/tablecellproperties.js
  47. 153 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellbackgroundcolorcommand.js
  48. 178 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellbordercolorcommand.js
  49. 178 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellborderstylecommand.js
  50. 178 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellborderwidthcommand.js
  51. 153 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellheightcommand.js
  52. 153 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellhorizontalalignmentcommand.js
  53. 178 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellpaddingcommand.js
  54. 153 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellverticalalignmentcommand.js
  55. 153 0
      packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellwidthcommand.js
  56. 828 0
      packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesediting.js
  57. 29 28
      packages/ckeditor5-table/tests/tableediting.js
  58. 44 0
      packages/ckeditor5-table/tests/tableproperties.js
  59. 153 0
      packages/ckeditor5-table/tests/tableproperties/commands/tablealignmentcommand.js
  60. 153 0
      packages/ckeditor5-table/tests/tableproperties/commands/tablebackgroundcolorcommand.js
  61. 178 0
      packages/ckeditor5-table/tests/tableproperties/commands/tablebordercolorcommand.js
  62. 178 0
      packages/ckeditor5-table/tests/tableproperties/commands/tableborderstylecommand.js
  63. 178 0
      packages/ckeditor5-table/tests/tableproperties/commands/tableborderwidthcommand.js
  64. 153 0
      packages/ckeditor5-table/tests/tableproperties/commands/tableheightcommand.js
  65. 153 0
      packages/ckeditor5-table/tests/tableproperties/commands/tablewidthcommand.js
  66. 716 0
      packages/ckeditor5-table/tests/tableproperties/tablepropertiesediting.js
  67. 39 38
      packages/ckeditor5-table/tests/tableutils.js

+ 3 - 1
packages/ckeditor5-table/package.json

@@ -12,7 +12,8 @@
   "dependencies": {
     "@ckeditor/ckeditor5-core": "^16.0.0",
     "@ckeditor/ckeditor5-ui": "^16.0.0",
-    "@ckeditor/ckeditor5-widget": "^16.0.0"
+    "@ckeditor/ckeditor5-widget": "^16.0.0",
+    "lodash-es": "^4.17.10"
   },
   "devDependencies": {
     "@ckeditor/ckeditor5-alignment": "^16.0.0",
@@ -21,6 +22,7 @@
     "@ckeditor/ckeditor5-editor-classic": "^16.0.0",
     "@ckeditor/ckeditor5-engine": "^16.0.0",
     "@ckeditor/ckeditor5-image": "^16.0.0",
+    "@ckeditor/ckeditor5-indent": "^16.0.0",
     "@ckeditor/ckeditor5-list": "^16.0.0",
     "@ckeditor/ckeditor5-media-embed": "^16.0.0",
     "@ckeditor/ckeditor5-paragraph": "^16.0.0",

+ 30 - 0
packages/ckeditor5-table/src/commands/utils.js

@@ -7,6 +7,8 @@
  * @module table/commands/utils
  */
 
+import { isObject } from 'lodash-es';
+
 /**
  * Returns the parent element of given name. Returns undefined if position is not inside desired parent.
  *
@@ -55,3 +57,31 @@ export function createEmptyTableCell( writer, insertPosition, attributes = {} )
 	writer.insertElement( 'paragraph', tableCell );
 	writer.insert( tableCell, insertPosition );
 }
+
+/**
+ * Returns a string if all four values of box sides are equal.
+ *
+ * If a string is passed, it is treated as a single value (pass-through).
+ *
+ *		// returns 'foo':
+ *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'foo', left: 'foo' } );
+ *		getSingleValue( 'foo' );
+ *
+ *		// Returns undefined:
+ *		getSingleValue( { top: 'foo', right: 'foo', bottom: 'bar', left: 'foo' } );
+ *		getSingleValue( { top: 'foo', right: 'foo' } );
+ *
+ * @param objectOrString
+ * @returns {module:engine/view/stylesmap~BoxSides|String}
+ */
+export function getSingleValue( objectOrString ) {
+	if ( !objectOrString || !isObject( objectOrString ) ) {
+		return objectOrString;
+	}
+
+	const { top, right, bottom, left } = objectOrString;
+
+	if ( top == right && right == bottom && bottom == left ) {
+		return top;
+	}
+}

+ 116 - 0
packages/ckeditor5-table/src/converters/tableproperties.js

@@ -0,0 +1,116 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/converters/tableproperites
+ */
+
+/**
+ * Conversion helper for upcasting attribute using normalized styles.
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion
+ * @param {String} modelElement
+ * @param {String} modelAttribute
+ * @param {String} styleName
+ */
+export function upcastStyleToAttribute( conversion, modelElement, modelAttribute, styleName ) {
+	conversion.for( 'upcast' ).attributeToAttribute( {
+		view: {
+			styles: {
+				[ styleName ]: /[\s\S]+/
+			}
+		},
+		model: {
+			name: modelElement,
+			key: modelAttribute,
+			value: viewElement => viewElement.getNormalizedStyle( styleName )
+		}
+	} );
+}
+
+/**
+ * Conversion helper for upcasting border styles for view element.
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion
+ * @param {String} viewElementName
+ */
+export function upcastBorderStyles( conversion, viewElementName ) {
+	conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:' + viewElementName, ( evt, data, conversionApi ) => {
+		// TODO: this is counter-intuitive: ie.: if only `border-top` is defined then `hasStyle( 'border' )` also returns true.
+		// TODO: this might needs to be fixed in styles normalizer.
+		const stylesToConsume = [
+			'border-top',
+			'border-right',
+			'border-bottom',
+			'border-left'
+		].filter( styleName => data.viewItem.hasStyle( styleName ) );
+
+		if ( !stylesToConsume.length ) {
+			return;
+		}
+
+		const matcherPattern = {
+			styles: stylesToConsume
+		};
+
+		// Try to consume appropriate values from consumable values list.
+		if ( !conversionApi.consumable.test( data.viewItem, matcherPattern ) ) {
+			return;
+		}
+
+		const modelElement = [ ...data.modelRange.getItems( { shallow: true } ) ].pop();
+
+		conversionApi.consumable.consume( data.viewItem, matcherPattern );
+
+		conversionApi.writer.setAttribute( 'borderStyle', data.viewItem.getNormalizedStyle( 'border-style' ), modelElement );
+		conversionApi.writer.setAttribute( 'borderColor', data.viewItem.getNormalizedStyle( 'border-color' ), modelElement );
+		conversionApi.writer.setAttribute( 'borderWidth', data.viewItem.getNormalizedStyle( 'border-width' ), modelElement );
+	} ) );
+}
+
+/**
+ * Conversion helper for downcasting attribute to a style.
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion
+ * @param {String} modelElement
+ * @param {String} modelAttribute
+ * @param {String} styleName
+ */
+export function downcastAttributeToStyle( conversion, modelElement, modelAttribute, styleName ) {
+	conversion.for( 'downcast' ).attributeToAttribute( {
+		model: {
+			name: modelElement,
+			key: modelAttribute
+		},
+		view: modelAttributeValue => ( {
+			key: 'style',
+			value: {
+				[ styleName ]: modelAttributeValue
+			}
+		} )
+	} );
+}
+
+/**
+ * Conversion helper for downcasting attributes from model's table to a view table (not to figure).
+ *
+ * @param {module:engine/conversion/conversion~Conversion} conversion
+ * @param {String} modelAttribute
+ * @param {String} styleName
+ */
+export function downcastTableAttribute( conversion, modelAttribute, styleName ) {
+	conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( `attribute:${ modelAttribute }:table`, ( evt, data, conversionApi ) => {
+		const { item, attributeNewValue } = data;
+		const { mapper, writer } = conversionApi;
+
+		const table = [ ...mapper.toViewElement( item ).getChildren() ].find( child => child.is( 'table' ) );
+
+		if ( attributeNewValue ) {
+			writer.setStyle( styleName, attributeNewValue, table );
+		} else {
+			writer.removeAttribute( styleName, table );
+		}
+	} ) );
+}

+ 37 - 0
packages/ckeditor5-table/src/tablecellproperties.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TableCellPropertiesEditing from './tablecellproperties/tablecellpropertiesediting';
+
+/**
+ * The table cell properties feature.
+ *
+ * This is a "glue" plugin which loads the
+ * {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing table properties editing feature} and
+ * table cell properties UI feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCellProperties extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableCellProperties';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableCellPropertiesEditing ];
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbackgroundcolorcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellbackgroundcolorcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+
+/**
+ * The table cell background color command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellBackgroundColor'` editor command.
+ *
+ * To change the background color of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellBackgroundColor', {
+ *			value: '#f00'
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableCellBackgroundColorCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellBackgroundColorCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'backgroundColor' );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellbordercolorcommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellbordercolorcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table cell border color command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellBorderColor'` editor command.
+ *
+ * To change the border color of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellBorderColor', {
+ *			value: '#f00'
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableCellBorderColorCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellBorderWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderColor' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getAttribute( tableCell ) {
+		if ( !tableCell ) {
+			return;
+		}
+
+		return getSingleValue( tableCell.getAttribute( this.attributeName ) );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderstylecommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellborderstylecommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table cell border style command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellBorderStyle'` editor command.
+ *
+ * To change the border style of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellBorderStyle', {
+ *			value: 'dashed'
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableCellBorderStyleCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellBorderWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderStyle' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getAttribute( tableCell ) {
+		if ( !tableCell ) {
+			return;
+		}
+
+		return getSingleValue( tableCell.getAttribute( this.attributeName ) );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellborderwidthcommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellborderwidthcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table cell border width command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellBorderWidth'` editor command.
+ *
+ * To change the border width of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellBorderWidth', {
+ *			value: '5px'
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableCellBorderWidthCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellBorderWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderWidth' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getAttribute( tableCell ) {
+		if ( !tableCell ) {
+			return;
+		}
+
+		return getSingleValue( tableCell.getAttribute( this.attributeName ) );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellheightcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellheightcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+
+/**
+ * The table cell height command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellHeight'` editor command.
+ *
+ * To change the height of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellHeight', {
+ *			value: '50px'
+ *		} );
+ *
+ * @extends module:table/tablecellproperties/commands/tablecellpropertycommand
+ */
+export default class TableCellHeightCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellHeightCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'height' );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellhorizontalalignmentcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellhorizontalalignmentcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+
+/**
+ * The table cell horizontal alignment command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellHorizontalAlignment'` editor command.
+ *
+ * To change the horizontal text alignment of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellHorizontalAlignment', {
+ *			value: 'right'
+ *		} );
+ *
+ * @extends module:table/tablecellproperties/commands/tablecellpropertycommand
+ */
+export default class TableCellHorizontalAlignmentCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellHorizontalAlignmentCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'horizontalAlignment' );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpaddingcommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellpaddingcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table cell padding command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellPadding'` editor command.
+ *
+ * To change the padding of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellPadding', {
+ *			value: '5px'
+ *		} );
+ *
+ * @extends module:table/tablecellproperties/commands/tablecellpropertycommand
+ */
+export default class TableCellPaddingCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellPaddingCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'padding' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getAttribute( tableCell ) {
+		if ( !tableCell ) {
+			return;
+		}
+
+		return getSingleValue( tableCell.getAttribute( this.attributeName ) );
+	}
+}

+ 89 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellpropertycommand.js

@@ -0,0 +1,89 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellpropertycommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import { findAncestor } from '../../commands/utils';
+
+/**
+ * The table cell attribute command.
+ *
+ * The command is a base command for other table cell property commands.
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableCellPropertyCommand extends Command {
+	/**
+	 * Creates a new `TableCellPropertyCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 * @param {String} attributeName Table cell attribute name.
+	 */
+	constructor( editor, attributeName ) {
+		super( editor );
+
+		this.attributeName = attributeName;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		const tableCell = findAncestor( 'tableCell', selection.getFirstPosition() );
+
+		this.isEnabled = !!tableCell;
+		this.value = this._getAttribute( tableCell );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 * @param {Object} [options]
+	 * @param {*} [options.value] If set, the command will set the attribute on selected table cells.
+	 * If it is not set, the command will remove the attribute from selected table cells.
+	 * @param {module:engine/model/batch~Batch} [options.batch] Pass the model batch instance to the command to aggregate changes,
+	 * e.g. allow a single undo step for multiple executions.
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const { value, batch } = options;
+
+		const tableCells = Array.from( selection.getSelectedBlocks() )
+			.map( element => findAncestor( 'tableCell', model.createPositionAt( element, 0 ) ) );
+
+		model.enqueueChange( batch || 'default', writer => {
+			if ( value ) {
+				tableCells.forEach( tableCell => writer.setAttribute( this.attributeName, value, tableCell ) );
+			} else {
+				tableCells.forEach( tableCell => writer.removeAttribute( this.attributeName, tableCell ) );
+			}
+		} );
+	}
+
+	/**
+	 * Returns the attribute value for a table cell.
+	 *
+	 * @param {module:engine/model/element~Element} tableCell
+	 * @returns {String|undefined}
+	 * @private
+	 */
+	_getAttribute( tableCell ) {
+		if ( !tableCell ) {
+			return;
+		}
+
+		return tableCell.getAttribute( this.attributeName );
+	}
+}

+ 42 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellverticalalignmentcommand.js

@@ -0,0 +1,42 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellverticalalignmentcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+
+/**
+ * The table cell vertical alignment command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellVerticalAlignment'` editor command.
+ *
+ * To change the vertical text alignment of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellVerticalAlignment', {
+ *			value: 'top'
+ *		} );
+ *
+ * The following values are allowed corresponding to
+ * [the `vertical-align` CSS attribute](https://developer.mozilla.org/en-US/docs/Web/CSS/vertical-align):
+ *
+ * * `'top'`
+ * * `'bottom'`
+ * * `'middle'`
+ *
+ * @extends module:table/tablecellproperties/commands/tablecellpropertycommand
+ */
+export default class TableCellVerticalAlignmentCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellVerticalAlignmentCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'verticalAlignment' );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tablecellproperties/commands/tablecellwidthcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/commands/tablecellwidthcommand
+ */
+
+import TableCellPropertyCommand from './tablecellpropertycommand';
+
+/**
+ * The table cell width command.
+ *
+ * The command is registered by the {@link module:table/tablecellproperties/tablecellpropertiesediting~TableCellPropertiesEditing} as
+ * `'tableCellWidth'` editor command.
+ *
+ * To change the width of selected cells, execute the command:
+ *
+ *		editor.execute( 'tableCellWidth', {
+ *			value: '50px'
+ *		} );
+ *
+ * @extends module:table/tablecellproperties/commands/tablecellpropertycommand
+ */
+export default class TableCellWidthCommand extends TableCellPropertyCommand {
+	/**
+	 * Creates a new `TableCellWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'width' );
+	}
+}

+ 172 - 0
packages/ckeditor5-table/src/tablecellproperties/tablecellpropertiesediting.js

@@ -0,0 +1,172 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tablecellproperties/tablecellpropertiesediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { addBorderRules } from '@ckeditor/ckeditor5-engine/src/view/styles/border';
+import { addPaddingRules } from '@ckeditor/ckeditor5-engine/src/view/styles/padding';
+import { addBackgroundRules } from '@ckeditor/ckeditor5-engine/src/view/styles/background';
+
+import { downcastAttributeToStyle, upcastStyleToAttribute, upcastBorderStyles } from './../converters/tableproperties';
+import TableEditing from './../tableediting';
+import TableCellPaddingCommand from './commands/tablecellpaddingcommand';
+import TableCellWidthCommand from './commands/tablecellwidthcommand';
+import TableCellHeightCommand from './commands/tablecellheightcommand';
+import TableCellBackgroundColorCommand from './commands/tablecellbackgroundcolorcommand';
+import TableCellVerticalAlignmentCommand from './commands/tablecellverticalalignmentcommand';
+import TableCellHorizontalAlignmentCommand from './commands/tablecellhorizontalalignmentcommand';
+import TableCellBorderStyleCommand from './commands/tablecellborderstylecommand';
+import TableCellBorderColorCommand from './commands/tablecellbordercolorcommand';
+import TableCellBorderWidthCommand from './commands/tablecellborderwidthcommand';
+
+/**
+ * The table cell properties editing feature.
+ *
+ * Introduces table cells's model attributes and their conversion:
+ *
+ * - border: `borderStyle`, `borderColor` and `borderWidth`
+ * - background color: `backgroundColor`
+ * - cell padding: `padding`
+ * - horizontal and vertical alignment: `horizontalAlignment`, `verticalAlignment`
+ * - cell width & height: `width` & `height`
+ *
+ * It also registers commands used to manipulate the above attributes:
+ *
+ * - border: `'tableCellBorderStyle'`, `'tableCellBorderColor'` and `'tableCellBorderWidth'` commands
+ * - background color: `'tableCellBackgroundColor'`
+ * - cell padding: `'tableCellPadding'`
+ * - horizontal and vertical alignment: `'tableCellHorizontalAlignment'`, `'tableCellVerticalAlignment'`
+ * - width & height: `'tableCellWidth'` & `'tableCellHeight'`
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableCellPropertiesEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableCellPropertiesEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const conversion = editor.conversion;
+		const viewDoc = editor.editing.view.document;
+
+		viewDoc.addStyleProcessorRules( addBorderRules );
+		enableBorderProperties( schema, conversion );
+		editor.commands.add( 'tableCellBorderStyle', new TableCellBorderStyleCommand( editor ) );
+		editor.commands.add( 'tableCellBorderColor', new TableCellBorderColorCommand( editor ) );
+		editor.commands.add( 'tableCellBorderWidth', new TableCellBorderWidthCommand( editor ) );
+
+		enableHorizontalAlignmentProperty( schema, conversion );
+		editor.commands.add( 'tableCellHorizontalAlignment', new TableCellHorizontalAlignmentCommand( editor ) );
+
+		enableProperty( schema, conversion, 'width', 'width' );
+		editor.commands.add( 'tableCellWidth', new TableCellWidthCommand( editor ) );
+
+		enableProperty( schema, conversion, 'height', 'height' );
+		editor.commands.add( 'tableCellHeight', new TableCellHeightCommand( editor ) );
+
+		viewDoc.addStyleProcessorRules( addPaddingRules );
+		enableProperty( schema, conversion, 'padding', 'padding' );
+		editor.commands.add( 'tableCellPadding', new TableCellPaddingCommand( editor ) );
+
+		viewDoc.addStyleProcessorRules( addBackgroundRules );
+		enableProperty( schema, conversion, 'backgroundColor', 'background-color' );
+		editor.commands.add( 'tableCellBackgroundColor', new TableCellBackgroundColorCommand( editor ) );
+
+		enableProperty( schema, conversion, 'verticalAlignment', 'vertical-align' );
+		editor.commands.add( 'tableCellVerticalAlignment', new TableCellVerticalAlignmentCommand( editor ) );
+	}
+}
+
+// Enables `'borderStyle'`, `'borderColor'` and `'borderWidth'` attributes for table cells.
+//
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableBorderProperties( schema, conversion ) {
+	schema.extend( 'tableCell', {
+		allowAttributes: [ 'borderWidth', 'borderColor', 'borderStyle' ]
+	} );
+	upcastBorderStyles( conversion, 'td' );
+	upcastBorderStyles( conversion, 'th' );
+	downcastAttributeToStyle( conversion, 'tableCell', 'borderStyle', 'border-style' );
+	downcastAttributeToStyle( conversion, 'tableCell', 'borderColor', 'border-color' );
+	downcastAttributeToStyle( conversion, 'tableCell', 'borderWidth', 'border-width' );
+}
+
+// Enables the `'horizontalAlignment'` attribute for table cells.
+//
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableHorizontalAlignmentProperty( schema, conversion ) {
+	schema.extend( 'tableCell', {
+		allowAttributes: [ 'horizontalAlignment' ]
+	} );
+
+	conversion.attributeToAttribute( {
+		model: {
+			name: 'tableCell',
+			key: 'horizontalAlignment',
+			values: [ 'left', 'right', 'center', 'justify' ]
+		},
+		view: {
+			// TODO: controversial one but I don't know if we want "default".
+			left: {
+				key: 'style',
+				value: {
+					'text-align': 'left'
+				}
+			},
+			right: {
+				key: 'style',
+				value: {
+					'text-align': 'right'
+				}
+			},
+			center: {
+				key: 'style',
+				value: {
+					'text-align': 'center'
+				}
+			},
+			justify: {
+				key: 'style',
+				value: {
+					'text-align': 'justify'
+				}
+			}
+		}
+	} );
+}
+
+// Enables conversion for an attribute for simple view-model mappings.
+//
+// @param {String} modelAttribute
+// @param {String} styleName
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableProperty( schema, conversion, modelAttribute, styleName ) {
+	schema.extend( 'tableCell', {
+		allowAttributes: [ modelAttribute ]
+	} );
+	upcastStyleToAttribute( conversion, 'tableCell', modelAttribute, styleName );
+	downcastAttributeToStyle( conversion, 'tableCell', modelAttribute, styleName );
+}

+ 37 - 0
packages/ckeditor5-table/src/tableproperties.js

@@ -0,0 +1,37 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+
+import TablePropertiesEditing from './tableproperties/tablepropertiesediting';
+
+/**
+ * The table properties feature.
+ *
+ * This is a "glue" plugin which loads the
+ * {@link module:table/tableproperties/tablepropertiesediting~TableCellPropertiesEditing table editing feature} and
+ * table UI feature.
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TableProperties extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TableProperties';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TablePropertiesEditing ];
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tableproperties/commands/tablealignmentcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tablealignmentcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+
+/**
+ * The table alignment command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableAlignment'` editor command.
+ *
+ * To change the alignment of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableAlignment', {
+ *			value: 'right'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableAlignmentCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableAlignmentCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'alignment' );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tableproperties/commands/tablebackgroundcolorcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tablebackgroundcolorcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+
+/**
+ * The table background color command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableBackgroundColor'` editor command.
+ *
+ * To change the background color of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableBackgroundColor', {
+ *			value: '#f00'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableBackgroundColorCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableBackgroundColorCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'backgroundColor' );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tableproperties/commands/tablebordercolorcommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tablebordercolorcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table border color command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableBorderColor'` editor command.
+ *
+ * To change the border color of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableBorderColor', {
+ *			value: '#f00'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableBorderColorCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableBorderColorCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderColor' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getValue( table ) {
+		if ( !table ) {
+			return;
+		}
+
+		return getSingleValue( table.getAttribute( this.attributeName ) );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tableproperties/commands/tableborderstylecommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tableborderstylecommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table style border command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableBorderStyle'` editor command.
+ *
+ * To change the border style of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableBorderStyle', {
+ *			value: 'dashed'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableBorderStyleCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableBorderStyleCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderStyle' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getValue( table ) {
+		if ( !table ) {
+			return;
+		}
+
+		return getSingleValue( table.getAttribute( this.attributeName ) );
+	}
+}

+ 47 - 0
packages/ckeditor5-table/src/tableproperties/commands/tableborderwidthcommand.js

@@ -0,0 +1,47 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tableborderwidthcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+import { getSingleValue } from '../../commands/utils';
+
+/**
+ * The table width border command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableBorderWidth'` editor command.
+ *
+ * To change the border width of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableBorderWidth', {
+ *			value: '5px'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableBorderWidthCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableBorderWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'borderWidth' );
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	_getValue( table ) {
+		if ( !table ) {
+			return;
+		}
+
+		return getSingleValue( table.getAttribute( this.attributeName ) );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tableproperties/commands/tableheightcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tableheightcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+
+/**
+ * The table height command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableHeight'` editor command.
+ *
+ * To change height of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableHeight', {
+ *			value: '500px'
+ *		} );
+ *
+ * @extends module:core/command~Command
+ */
+export default class TableHeightCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableHeightCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'height' );
+	}
+}

+ 88 - 0
packages/ckeditor5-table/src/tableproperties/commands/tablepropertycommand.js

@@ -0,0 +1,88 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tablepropertycommand
+ */
+
+import Command from '@ckeditor/ckeditor5-core/src/command';
+
+import { findAncestor } from '../../commands/utils';
+
+/**
+ * The table cell attribute command.
+ *
+ * The command is a base command for other table property commands.
+ *
+ * @extends module:core/command~Command
+ */
+export default class TablePropertyCommand extends Command {
+	/**
+	 * Creates a new `TablePropertyCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 * @param {String} attributeName Table cell attribute name.
+	 */
+	constructor( editor, attributeName ) {
+		super( editor );
+
+		this.attributeName = attributeName;
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	refresh() {
+		const editor = this.editor;
+		const selection = editor.model.document.selection;
+
+		const table = findAncestor( 'table', selection.getFirstPosition() );
+
+		this.isEnabled = !!table;
+		this.value = this._getValue( table );
+	}
+
+	/**
+	 * Executes the command.
+	 *
+	 * @fires execute
+	 * @param {Object} [options]
+	 * @param {*} [options.value] If set, the command will set the attribute on the selected table.
+	 * If not set, the command will remove the attribute from the selected table.
+	 * @param {module:engine/model/batch~Batch} [options.batch] Pass the model batch instance to the command to aggregate changes,
+	 * e.g. allow a single undo step for multiple executions.
+	 */
+	execute( options = {} ) {
+		const model = this.editor.model;
+		const selection = model.document.selection;
+
+		const { value, batch } = options;
+
+		const table = findAncestor( 'table', selection.getFirstPosition() );
+
+		model.enqueueChange( batch || 'default', writer => {
+			if ( value ) {
+				writer.setAttribute( this.attributeName, value, table );
+			} else {
+				writer.removeAttribute( this.attributeName, table );
+			}
+		} );
+	}
+
+	/**
+	 * Returns the attribute value for a table.
+	 *
+	 * @param {module:engine/model/element~Element} table
+	 * @returns {String|undefined}
+	 * @private
+	 */
+	_getValue( table ) {
+		if ( !table ) {
+			return;
+		}
+
+		return table.getAttribute( this.attributeName );
+	}
+}

+ 35 - 0
packages/ckeditor5-table/src/tableproperties/commands/tablewidthcommand.js

@@ -0,0 +1,35 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/commands/tablewidthcommand
+ */
+
+import TablePropertyCommand from './tablepropertycommand';
+
+/**
+ * The table width command.
+ *
+ * The command is registered by the {@link module:table/tableproperties/tablepropertiesediting~TablePropertiesEditing} as
+ * `'tableWidth'` editor command.
+ *
+ * To change width of the selected table, execute the command:
+ *
+ *		editor.execute( 'tableWidth', {
+ *			value: '400px'
+ *		} );
+ *
+ * @extends module:table/tableproperties/commands/tablepropertycommand
+ */
+export default class TableWidthCommand extends TablePropertyCommand {
+	/**
+	 * Creates a new `TableWidthCommand` instance.
+	 *
+	 * @param {module:core/editor/editor~Editor} editor An editor in which this command will be used.
+	 */
+	constructor( editor ) {
+		super( editor, 'width' );
+	}
+}

+ 194 - 0
packages/ckeditor5-table/src/tableproperties/tablepropertiesediting.js

@@ -0,0 +1,194 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/**
+ * @module table/tableproperties/tablepropertiesediting
+ */
+
+import Plugin from '@ckeditor/ckeditor5-core/src/plugin';
+import { addBorderRules } from '@ckeditor/ckeditor5-engine/src/view/styles/border';
+import { addBackgroundRules } from '@ckeditor/ckeditor5-engine/src/view/styles/background';
+
+import TableEditing from './../tableediting';
+import { downcastTableAttribute, upcastStyleToAttribute, upcastBorderStyles } from './../converters/tableproperties';
+import TableBackgroundColorCommand from './commands/tablebackgroundcolorcommand';
+import TableBorderColorCommand from './commands/tablebordercolorcommand';
+import TableBorderStyleCommand from './commands/tableborderstylecommand';
+import TableBorderWidthCommand from './commands/tableborderwidthcommand';
+import TableWidthCommand from './commands/tablewidthcommand';
+import TableHeightCommand from './commands/tableheightcommand';
+import TableAlignmentCommand from './commands/tablealignmentcommand';
+
+// RegExp used for matching margin style in converters.
+const MARGIN_REG_EXP = /^(auto|0(%|[a-z]{2,4})?)$/;
+const ALIGN_VALUES_REG_EXP = /^(left|right|center)$/;
+
+/**
+ * The table properties editing feature.
+ *
+ * Introduces table's model attributes and their conversion:
+ *
+ * - border: `borderStyle`, `borderColor` and `borderWidth`
+ * - background color: `backgroundColor`
+ * - horizontal alignment: `alignment`
+ * - width & height: `width` & `height`
+ *
+ * It also registers commands used to manipulate the above attributes:
+ *
+ * - border: `'tableBorderStyle'`, `'tableBorderColor'` and `'tableBorderWidth'` commands
+ * - background color: `'tableBackgroundColor'`
+ * - horizontal alignment: `'tableAlignment'`
+ * - width & height: `'tableWidth'` & `'tableHeight'`
+ *
+ * @extends module:core/plugin~Plugin
+ */
+export default class TablePropertiesEditing extends Plugin {
+	/**
+	 * @inheritDoc
+	 */
+	static get pluginName() {
+		return 'TablePropertiesEditing';
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	static get requires() {
+		return [ TableEditing ];
+	}
+
+	/**
+	 * @inheritDoc
+	 */
+	init() {
+		const editor = this.editor;
+		const schema = editor.model.schema;
+		const conversion = editor.conversion;
+		const viewDoc = editor.editing.view.document;
+
+		viewDoc.addStyleProcessorRules( addBorderRules );
+		enableBorderProperties( schema, conversion );
+		editor.commands.add( 'tableBorderColor', new TableBorderColorCommand( editor ) );
+		editor.commands.add( 'tableBorderStyle', new TableBorderStyleCommand( editor ) );
+		editor.commands.add( 'tableBorderWidth', new TableBorderWidthCommand( editor ) );
+
+		enableAlignmentProperty( schema, conversion );
+		editor.commands.add( 'tableAlignment', new TableAlignmentCommand( editor ) );
+
+		enableProperty( schema, conversion, 'width', 'width' );
+		editor.commands.add( 'tableWidth', new TableWidthCommand( editor ) );
+
+		enableProperty( schema, conversion, 'height', 'height' );
+		editor.commands.add( 'tableHeight', new TableHeightCommand( editor ) );
+
+		viewDoc.addStyleProcessorRules( addBackgroundRules );
+		enableProperty( schema, conversion, 'backgroundColor', 'background-color' );
+		editor.commands.add( 'tableBackgroundColor', new TableBackgroundColorCommand( editor ) );
+	}
+}
+
+// Enables `'borderStyle'`, `'borderColor'` and `'borderWidth'` attributes for table.
+//
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableBorderProperties( schema, conversion ) {
+	schema.extend( 'table', {
+		allowAttributes: [ 'borderWidth', 'borderColor', 'borderStyle' ]
+	} );
+	upcastBorderStyles( conversion, 'table' );
+	downcastTableAttribute( conversion, 'borderColor', 'border-color' );
+	downcastTableAttribute( conversion, 'borderStyle', 'border-style' );
+	downcastTableAttribute( conversion, 'borderWidth', 'border-width' );
+}
+
+// Enables the `'alignment'` attribute for table.
+//
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableAlignmentProperty( schema, conversion ) {
+	schema.extend( 'table', {
+		allowAttributes: [ 'alignment' ]
+	} );
+	conversion.for( 'upcast' )
+		.attributeToAttribute( {
+			view: {
+				styles: {
+					'margin-right': MARGIN_REG_EXP,
+					'margin-left': MARGIN_REG_EXP
+				}
+			},
+			model: {
+				name: 'table',
+				key: 'alignment',
+				value: viewElement => {
+					// At this point we only have auto or 0 value (with a unit).
+					if ( viewElement.getStyle( 'margin-right' ) != 'auto' ) {
+						return 'right';
+					}
+
+					if ( viewElement.getStyle( 'margin-left' ) != 'auto' ) {
+						return 'left';
+					}
+
+					return 'center';
+				}
+			}
+		} )
+		// Support for backwards compatibility and pasting from other sources.
+		.attributeToAttribute( {
+			view: {
+				attributes: {
+					align: ALIGN_VALUES_REG_EXP
+				}
+			},
+			model: {
+				name: 'table',
+				key: 'alignment',
+				value: viewElement => viewElement.getAttribute( 'align' )
+			}
+		} );
+	conversion.for( 'downcast' ).add( dispatcher => dispatcher.on( 'attribute:alignment:table', ( evt, data, conversionApi ) => {
+		const { item, attributeNewValue } = data;
+		const { mapper, writer } = conversionApi;
+
+		const table = [ ...mapper.toViewElement( item ).getChildren() ].find( child => child.is( 'table' ) );
+
+		if ( !attributeNewValue ) {
+			writer.removeStyle( 'margin-left', table );
+			writer.removeStyle( 'margin-right', table );
+
+			return;
+		}
+
+		const styles = {
+			'margin-right': 'auto',
+			'margin-left': 'auto'
+		};
+
+		if ( attributeNewValue == 'left' ) {
+			styles[ 'margin-left' ] = '0';
+		}
+
+		if ( attributeNewValue == 'right' ) {
+			styles[ 'margin-right' ] = '0';
+		}
+
+		writer.setStyle( styles, table );
+	} ) );
+}
+
+// Enables conversion for an attribute for simple view-model mappings.
+//
+// @param {String} modelAttribute
+// @param {String} styleName
+// @param {module:engine/model/schema~Schema} schema
+// @param {module:engine/conversion/conversion~Conversion} conversion
+function enableProperty( schema, conversion, modelAttribute, styleName ) {
+	schema.extend( 'table', {
+		allowAttributes: [ modelAttribute ]
+	} );
+	upcastStyleToAttribute( conversion, 'table', modelAttribute, styleName );
+	downcastTableAttribute( conversion, modelAttribute, styleName );
+}

+ 2 - 2
packages/ckeditor5-table/src/tableui.js

@@ -222,7 +222,7 @@ export default class TableUI extends Plugin {
 			addListOption( option, editor, commands, itemDefinitions );
 		}
 
-		addListToDropdown( dropdownView, itemDefinitions );
+		addListToDropdown( dropdownView, itemDefinitions, editor.ui.componentFactory );
 
 		// Decorate dropdown's button.
 		dropdownView.buttonView.set( {
@@ -256,7 +256,7 @@ function addListOption( option, editor, commands, itemDefinitions ) {
 	const model = option.model = new Model( option.model );
 	const { commandName, bindIsOn } = option.model;
 
-	if ( option.type !== 'separator' ) {
+	if ( option.type === 'button' || option.type === 'switchbutton' ) {
 		const command = editor.commands.get( commandName );
 
 		commands.push( command );

+ 148 - 44
packages/ckeditor5-table/tests/_utils/utils.js

@@ -12,8 +12,11 @@ import {
 	downcastTableHeadingRowsChange
 } from '../../src/converters/downcast';
 import upcastTable, { upcastTableCell } from '../../src/converters/upcasttable';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 const WIDGET_TABLE_CELL_CLASS = 'ck-editor__editable ck-editor__nested-editable';
+const BORDER_REG_EXP = /[\s\S]+/;
 
 /**
  * Returns a model representation of a table shorthand notation:
@@ -55,6 +58,69 @@ export function modelTable( tableData, attributes ) {
 }
 
 /**
+ * A helper method for creating a test table with a single table cell of which attributes are defined as objects.
+ *
+ *		setTableCellWithObjectAttributes(
+ *			model,
+ *			{
+ *				margin: { top: '1px', left: '2px' },
+ *				borderColor: { top: '#f00', left: '#ba2' }
+ *				backgroundColor: '#f00'
+ *			},
+ *			'fo[o]'
+ *		);
+ *
+ * This will create a model table with one table cell with a "foo" text.
+ * The selection will be set on the last "o" and a table cell will have three attributes.
+ *
+ * @param {module:engine/model/model~Model} model
+ * @param {Object} attributes
+ * @param {String} cellContent
+ */
+export function setTableCellWithObjectAttributes( model, attributes, cellContent ) {
+	setData( model, modelTable( [ [ { contents: cellContent } ] ] ) );
+
+	const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+	model.change( writer => {
+		for ( const [ key, value ] of Object.entries( attributes ) ) {
+			writer.setAttribute( key, value, tableCell );
+		}
+	} );
+}
+
+/**
+ * A helper method for creating a test table, with a single table cell. Table attributes are defined as objects.
+ *
+ *		setTableWithObjectAttributes(
+ *			model,
+ *			{
+ *				borderColor: { top: '#f00', left: '#ba2' }
+ *				backgroundColor: '#f00'
+ *			},
+ *			'fo[o]'
+ *		);
+ *
+ * This will create a model table with one table cell with a "foo" text.
+ * The selection will be set on last "o" and a table will have three attributes.
+ *
+ * @param {module:engine/model/model~Model} model
+ * @param {Object} attributes
+ * @param {String} cellContent
+ */
+export function setTableWithObjectAttributes( model, attributes, cellContent ) {
+	setData( model, modelTable( [ [ { contents: cellContent } ] ] ) );
+
+	const table = model.document.getRoot().getChild( 0 );
+
+	model.change( writer => {
+		for ( const [ key, value ] of Object.entries( attributes ) ) {
+			writer.setAttribute( key, value, table );
+		}
+	} );
+}
+
+/**
  * Returns a view representation of a table shorthand notation:
  *
  *		viewTable( [
@@ -113,50 +179,6 @@ export function viewTable( tableData, attributes = {} ) {
 	return `<figure ${ figureAttributes }>${ asWidget ? widgetHandler : '' }<table>${ thead }${ tbody }</table></figure>`;
 }
 
-/**
- * Formats model or view table - useful for chai assertions debugging.
- *
- * @param {String} tableString
- * @returns {String}
- */
-export function formatTable( tableString ) {
-	return tableString
-		.replace( /<tableRow>/g, '\n<tableRow>\n    ' )
-		.replace( /<thead>/g, '\n<thead>\n    ' )
-		.replace( /<tbody>/g, '\n<tbody>\n    ' )
-		.replace( /<tr>/g, '\n<tr>\n    ' )
-		.replace( /<\/tableRow>/g, '\n</tableRow>' )
-		.replace( /<\/thead>/g, '\n</thead>' )
-		.replace( /<\/tbody>/g, '\n</tbody>' )
-		.replace( /<\/tr>/g, '\n</tr>' )
-		.replace( /<\/table>/g, '\n</table>' )
-		.replace( /<\/figure>/g, '\n</figure>' );
-}
-
-/**
- * Returns formatted model table string.
- *
- * @param {Array.<String>} tableData
- * @param {Object} [attributes]
- * @returns {String}
- */
-export function formattedModelTable( tableData, attributes ) {
-	const tableString = modelTable( tableData, attributes );
-
-	return formatTable( tableString );
-}
-
-/**
- * Returns formatted view table string.
- *
- * @param {Array.<String>} tableData
- * @param {Object} [attributes]
- * @returns {String}
- */
-export function formattedViewTable( tableData, attributes ) {
-	return formatTable( viewTable( tableData, attributes ) );
-}
-
 export function defaultSchema( schema, registerParagraph = true ) {
 	schema.register( 'table', {
 		allowWhere: '$block',
@@ -190,6 +212,11 @@ export function defaultSchema( schema, registerParagraph = true ) {
 	if ( registerParagraph ) {
 		schema.register( 'paragraph', { inheritAllFrom: '$block' } );
 	}
+
+	// Styles
+	schema.extend( 'tableCell', {
+		allowAttributes: [ 'border' ]
+	} );
 }
 
 export function defaultConversion( conversion, asWidget = false ) {
@@ -215,6 +242,83 @@ export function defaultConversion( conversion, asWidget = false ) {
 
 	conversion.for( 'downcast' ).add( downcastTableHeadingColumnsChange( { asWidget } ) );
 	conversion.for( 'downcast' ).add( downcastTableHeadingRowsChange( { asWidget } ) );
+
+	// Styles
+	conversion.for( 'upcast' ).attributeToAttribute( {
+		view: {
+			name: 'td',
+			styles: {
+				border: BORDER_REG_EXP
+			}
+		},
+		model: {
+			key: 'border',
+			value: viewElement => viewElement.getStyle( 'border' )
+		}
+	} );
+}
+
+/**
+ * An assertion helper for top-right-bottom-left attribute object.
+ *
+ * @param {module:engine/model/node~Node} element
+ * @param {String} key Attribute key
+ * @param {String} top Top value. Pass `null` to omit the value in the attributes object.
+ * @param {String} [right=top] Right value - defaults to top if not provided.
+ * Pass `null` to omit the value in the attributes object.
+ * @param {String} [bottom=top] Bottom value - defaults to top (right value must be defined).
+ * Pass `null` to omit the value in the attributes object.
+ * @param {String} [left=right] Left value - defaults to right (bottom and right values must be defined).
+ * Pass `null` to omit the value in the attributes object.
+ */
+export function assertTRBLAttribute( element, key, top, right = top, bottom = top, left = right ) {
+	const styleObject = {};
+
+	if ( top ) {
+		styleObject.top = top;
+	}
+
+	if ( right ) {
+		styleObject.right = right;
+	}
+
+	if ( bottom ) {
+		styleObject.bottom = bottom;
+	}
+
+	if ( left ) {
+		styleObject.left = left;
+	}
+
+	expect( element.getAttribute( key ) ).to.deep.equal( styleObject );
+}
+
+/**
+ * An assertion helper for testing the `<table>` style attribute.
+ *
+ * @param {module:core/editor/editor~Editor} editor
+ * @param {String} tableStyle A style to assert on table.
+ */
+export function assertTableStyle( editor, tableStyle ) {
+	const styleEntry = tableStyle ? ` style="${ tableStyle }"` : '';
+
+	assertEqualMarkup( editor.getData(),
+		`<figure class="table"><table${ styleEntry }><tbody><tr><td>foo</td></tr></tbody></table></figure>`
+	);
+}
+
+/**
+ * An assertion helper for testing the `<td>` style attribute.
+ *
+ * @param {module:core/editor/editor~Editor} editor
+ * @param {String} tableCellStyle A style to assert on td.
+ */
+export function assertTableCellStyle( editor, tableCellStyle ) {
+	assertEqualMarkup( editor.getData(),
+		'<figure class="table"><table><tbody><tr>' +
+			`<td${ tableCellStyle ? ` style="${ tableCellStyle }"` : '' }>foo</td>` +
+		'</tr></tbody></table></figure>'
+	);
 }
 
 // Formats table cell attributes

+ 16 - 15
packages/ckeditor5-table/tests/commands/insertcolumncommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import InsertColumnCommand from '../../src/commands/insertcolumncommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'InsertColumnCommand', () => {
 	let editor, model, command;
@@ -57,7 +58,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11[]', '', '12' ],
 					[ '21', '', '22' ]
 				] ) );
@@ -70,7 +71,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '<paragraph>12[]</paragraph>', '', '13' ]
 				] ) );
 			} );
@@ -83,7 +84,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '12', '' ],
 					[ '21', '22[]', '' ]
 				] ) );
@@ -98,7 +99,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11[]', '', '12' ],
 					[ '21', '', '22' ],
 					[ '31', '', '32' ]
@@ -114,7 +115,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '12[]', '', '13' ],
 					[ '21', '22', '', '23' ],
 					[ '31', '32', '', '33' ]
@@ -130,7 +131,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11[]', '', '12' ],
 					[ { colspan: 3, contents: '21' } ],
 					[ '31', '', '32' ]
@@ -146,7 +147,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '12[]', '', '13', '14', '15' ],
 					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
 					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]
@@ -181,7 +182,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '', '12[]' ],
 					[ '21', '', '22' ]
 				] ) );
@@ -194,7 +195,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '', '<paragraph>12[]</paragraph>', '13' ]
 				] ) );
 			} );
@@ -207,7 +208,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '', '11', '12' ],
 					[ '', '[]21', '22' ]
 				] ) );
@@ -222,7 +223,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '', '12[]' ],
 					[ '21', '', '22' ],
 					[ '31', '', '32' ]
@@ -238,7 +239,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '12', '', '13[]' ],
 					[ '21', '22', '', '23' ],
 					[ '31', '32', '', '33' ]
@@ -254,7 +255,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '', '12[]' ],
 					[ { colspan: 3, contents: '21' } ],
 					[ '31', '', '32' ]
@@ -270,7 +271,7 @@ describe( 'InsertColumnCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '11', '12', '', '13[]', '14', '15' ],
 					[ '21', '22', '', { colspan: 2, contents: '23' }, '25' ],
 					[ { colspan: 5, contents: '31' }, { colspan: 2, contents: '34' } ]

+ 15 - 14
packages/ckeditor5-table/tests/commands/insertrowcommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import InsertRowCommand from '../../src/commands/insertrowcommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'InsertRowCommand', () => {
 	let editor, model, command;
@@ -57,7 +58,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00[]', '01' ],
 					[ '', '' ],
 					[ '10', '11' ]
@@ -73,7 +74,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00' ],
 					[ '<paragraph>[]10</paragraph>' ],
 					[ '' ],
@@ -90,7 +91,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00[]', '01' ],
 					[ '', '' ],
 					[ '10', '11' ],
@@ -107,7 +108,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01' ],
 					[ '10[]', '11' ],
 					[ '', '' ],
@@ -124,7 +125,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '00' }, '02', '03' ],
 					[ { colspan: 2, rowspan: 5, contents: '10[]' }, '12', '13' ],
 					[ '', '' ],
@@ -141,7 +142,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '00' }, '01', '02' ],
 					[ '11[]', '12' ],
 					[ '', '', '' ],
@@ -158,7 +159,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '00' }, '01', '02' ],
 					[ '11[]', '12' ],
 					[ '', '', '' ],
@@ -174,7 +175,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01' ],
 					[ '10[]', '11' ],
 					[ '', '' ]
@@ -210,7 +211,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00' ],
 					[ '' ],
 					[ '<paragraph>[]10</paragraph>' ],
@@ -226,7 +227,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '', '' ],
 					[ '00[]', '01' ],
 					[ '10', '11' ]
@@ -242,7 +243,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01' ],
 					[ '10', '11' ],
 					[ '', '' ],
@@ -259,7 +260,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '', '' ],
 					[ '00[]', '01' ],
 					[ '10', '11' ],
@@ -276,7 +277,7 @@ describe( 'InsertRowCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01' ],
 					[ '10', '11' ],
 					[ '', '' ],

+ 11 - 10
packages/ckeditor5-table/tests/commands/inserttablecommand.js

@@ -9,7 +9,8 @@ import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model
 import InsertTableCommand from '../../src/commands/inserttablecommand';
 import TableUtils from '../../src/tableutils';
 
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'InsertTableCommand', () => {
 	let editor, model, command;
@@ -66,7 +67,7 @@ describe( 'InsertTableCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '[]', '' ],
 					[ '', '' ]
 				] ) );
@@ -77,9 +78,9 @@ describe( 'InsertTableCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal(
+				assertEqualMarkup( getData( model ),
 					'<paragraph>foo</paragraph>' +
-					formattedModelTable( [
+					modelTable( [
 						[ '[]', '' ],
 						[ '', '' ]
 					] )
@@ -91,9 +92,9 @@ describe( 'InsertTableCommand', () => {
 
 				command.execute( { rows: 3, columns: 4 } );
 
-				expect( formatTable( getData( model ) ) ).to.equal(
+				assertEqualMarkup( getData( model ),
 					'<paragraph>foo</paragraph>' +
-					formattedModelTable( [
+					modelTable( [
 						[ '[]', '', '', '' ],
 						[ '', '', '', '' ],
 						[ '', '', '', '' ]
@@ -106,8 +107,8 @@ describe( 'InsertTableCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal(
-					formattedModelTable( [
+				assertEqualMarkup( getData( model ),
+					modelTable( [
 						[ '[]', '' ],
 						[ '', '' ]
 					] ) +
@@ -120,8 +121,8 @@ describe( 'InsertTableCommand', () => {
 
 				command.execute( { rows: 3, columns: 4 } );
 
-				expect( formatTable( getData( model ) ) ).to.equal(
-					formattedModelTable( [
+				assertEqualMarkup( getData( model ),
+					modelTable( [
 						[ '[]', '', '', '' ],
 						[ '', '', '', '' ],
 						[ '', '', '', '' ]

+ 27 - 26
packages/ckeditor5-table/tests/commands/mergecellcommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import MergeCellCommand from '../../src/commands/mergecellcommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'MergeCellCommand', () => {
 	let editor, model, command, root;
@@ -191,7 +192,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
 				] ) );
 			} );
@@ -203,7 +204,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
 				] ) );
 			} );
@@ -215,7 +216,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
 				] ) );
 			} );
@@ -227,7 +228,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
 				] ) );
 			} );
@@ -245,7 +246,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
 				] ) );
 			} );
@@ -387,7 +388,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[00</paragraph><paragraph>01]</paragraph>' } ]
 				] ) );
 			} );
@@ -399,7 +400,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[]</paragraph>' } ]
 				] ) );
 			} );
@@ -411,7 +412,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
 				] ) );
 			} );
@@ -423,7 +424,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<paragraph>[foo]</paragraph>' } ]
 				] ) );
 			} );
@@ -441,7 +442,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { colspan: 2, contents: '<block>[</block><block>]</block>' } ]
 				] ) );
 			} );
@@ -579,7 +580,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', { rowspan: 2, contents: '<paragraph>[01</paragraph><paragraph>11]</paragraph>' } ],
 					[ '10' ]
 				] ) );
@@ -593,7 +594,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[]' }, '' ],
 					[ '' ]
 				] ) );
@@ -607,7 +608,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[foo]' }, '' ],
 					[ '' ]
 				] ) );
@@ -621,7 +622,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[foo]' }, '' ],
 					[ '' ]
 				] ) );
@@ -641,7 +642,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '<block>[</block><block>]</block>' }, '' ],
 					[ '' ]
 				] ) );
@@ -656,7 +657,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>', { rowspan: 2, contents: '02' } ],
 					[ '20', '21' ]
 				] ) );
@@ -673,7 +674,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '00' }, '01', '02' ],
 					[ '11', '12' ],
 					[ '20', '<paragraph>[21</paragraph><paragraph>31]</paragraph>', { rowspan: 2, contents: '22' } ],
@@ -815,7 +816,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', { rowspan: 2, contents: '<paragraph>[01</paragraph><paragraph>11]</paragraph>' } ],
 					[ '10' ]
 				] ) );
@@ -829,7 +830,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[]' }, '' ],
 					[ '' ]
 				] ) );
@@ -843,7 +844,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[foo]' }, '' ],
 					[ '' ]
 				] ) );
@@ -857,7 +858,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '[foo]' }, '' ],
 					[ '' ]
 				] ) );
@@ -877,7 +878,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '<block>[</block><block>]</block>' }, '' ],
 					[ '' ]
 				] ) );
@@ -893,7 +894,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 3, contents: '00' }, '11', '12', '13' ],
 					[
 						{ rowspan: 2, contents: '21' },
@@ -914,7 +915,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '<paragraph>[01</paragraph><paragraph>11]</paragraph>', { rowspan: 2, contents: '02' } ],
 					[ '20', '21' ]
 				] ) );
@@ -931,7 +932,7 @@ describe( 'MergeCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ { rowspan: 2, contents: '00' }, '01', '02' ],
 					[ '11', '12' ],
 					[ '20', '<paragraph>[21</paragraph><paragraph>31]</paragraph>', { rowspan: 2, contents: '22' } ],

+ 7 - 6
packages/ckeditor5-table/tests/commands/removecolumncommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import RemoveColumnCommand from '../../src/commands/removecolumncommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'RemoveColumnCommand', () => {
 	let editor, model, command;
@@ -69,7 +70,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '02' ],
 				[ '<paragraph>10[]</paragraph>', '12' ],
 				[ '20', '22' ]
@@ -85,7 +86,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '<paragraph>[]01</paragraph>' ],
 				[ '11' ],
 				[ '21' ]
@@ -101,7 +102,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '01' ],
 				[ '<paragraph>[]11</paragraph>' ],
 				[ '21' ]
@@ -119,7 +120,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 3, contents: '00' }, '03' ],
 				[ { colspan: 2, contents: '10' }, '13' ],
 				[ { colspan: 2, contents: '<paragraph>20[]</paragraph>' }, '23' ],
@@ -138,7 +139,7 @@ describe( 'RemoveColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 2, contents: '[]00' }, '03' ],
 				[ '10', '13' ],
 				[ '21', '22', '23' ]

+ 7 - 6
packages/ckeditor5-table/tests/commands/removerowcommand.js

@@ -7,7 +7,8 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import RemoveRowCommand from '../../src/commands/removerowcommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'RemoveRowCommand', () => {
 	let editor, model, command;
@@ -63,7 +64,7 @@ describe( 'RemoveRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '<paragraph>01[]</paragraph>' ],
 				[ '20', '21' ]
 			] ) );
@@ -78,7 +79,7 @@ describe( 'RemoveRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '<paragraph>[]10</paragraph>', '11' ],
 				[ '20', '21' ]
 			] ) );
@@ -93,7 +94,7 @@ describe( 'RemoveRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '<paragraph>01[]</paragraph>' ],
 				[ '20', '21' ]
 			], { headingRows: 1 } ) );
@@ -109,7 +110,7 @@ describe( 'RemoveRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 3, contents: '00' }, { rowspan: 2, contents: '01' }, { rowspan: 2, contents: '02' }, '03', '04' ],
 				[ '13', '<paragraph>14[]</paragraph>' ],
 				[ '30', '31', '32', '33', '34' ]
@@ -126,7 +127,7 @@ describe( 'RemoveRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 2, contents: '[]00' }, '01', '12' ],
 				[ '22' ],
 				[ '30', '31', '32' ]

+ 8 - 7
packages/ckeditor5-table/tests/commands/setheadercolumncommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import SetHeaderColumnCommand from '../../src/commands/setheadercolumncommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'SetHeaderColumnCommand', () => {
 	let editor, model, command;
@@ -81,7 +82,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 2 } ) );
 		} );
@@ -93,7 +94,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 		} );
@@ -105,13 +106,13 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 2 } ) );
 		} );
@@ -123,7 +124,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			command.execute( { forceValue: true } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 3 } ) );
 		} );
@@ -135,7 +136,7 @@ describe( 'SetHeaderColumnCommand', () => {
 
 			command.execute( { forceValue: false } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]', '02', '03' ]
 			], { headingColumns: 1 } ) );
 		} );

+ 13 - 12
packages/ckeditor5-table/tests/commands/setheaderrowcommand.js

@@ -6,8 +6,9 @@
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 import SetHeaderRowCommand from '../../src/commands/setheaderrowcommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'SetHeaderRowCommand', () => {
 	let editor, model, command;
@@ -92,7 +93,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00' ],
 				[ '[]10' ],
 				[ '20' ],
@@ -110,7 +111,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00' ],
 				[ '[]10' ],
 				[ '20' ],
@@ -137,7 +138,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00' ],
 				[ '[]10' ],
 				[ '20' ],
@@ -155,7 +156,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '[]00' ],
 				[ '10' ],
 				[ '20' ],
@@ -173,7 +174,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute( { forceValue: true } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00' ],
 				[ '[]10' ],
 				[ '20' ],
@@ -191,7 +192,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute( { forceValue: false } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00' ],
 				[ '[]10' ],
 				[ '20' ],
@@ -208,7 +209,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { colspan: 2, contents: '10[]' }, '12' ],
 				[ { colspan: 2, contents: '' }, '22' ]
@@ -227,7 +228,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { colspan: 2, rowspan: 2, contents: '10' }, '12' ],
 				[ '22[]' ],
@@ -245,7 +246,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01' ],
 				[ '', '[]11' ]
 			], { headingRows: 1 } ) );
@@ -259,7 +260,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01' ],
 				[ '[]10', '' ]
 			], { headingRows: 1 } ) );
@@ -274,7 +275,7 @@ describe( 'SetHeaderRowCommand', () => {
 
 			command.execute();
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '[]01', '02' ],
 				[ { colspan: 2, rowspan: 2, contents: '10' }, '12' ],
 				[ '22' ]

+ 8 - 7
packages/ckeditor5-table/tests/commands/splitcellcommand.js

@@ -7,8 +7,9 @@ import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltestedit
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import SplitCellCommand from '../../src/commands/splitcellcommand';
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from '../_utils/utils';
 import TableUtils from '../../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'SplitCellCommand', () => {
 	let editor, model, command;
@@ -64,7 +65,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', { colspan: 2, contents: '01' }, '02' ],
 					[ '10', '[]11', '', '12' ],
 					[ '20', { colspan: 3, contents: '21' } ],
@@ -82,7 +83,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01', '02' ],
 					[ '10', '11', '12' ],
 					[ '20', '21[]', '' ],
@@ -98,7 +99,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01', '02' ],
 					[ { colspan: 2, contents: '10[]' }, '' ]
 				] ) );
@@ -112,7 +113,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01', '02', '03' ],
 					[ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ]
 				] ) );
@@ -127,7 +128,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01', '02', '03', '04', '05' ],
 					[ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ],
 					[ '25' ]
@@ -167,7 +168,7 @@ describe( 'SplitCellCommand', () => {
 
 				command.execute();
 
-				expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getData( model ), modelTable( [
 					[ '00', '01', '02' ],
 					[ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ],
 					[ '' ],

+ 77 - 77
packages/ckeditor5-table/tests/converters/downcast.js

@@ -7,9 +7,10 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import { defaultConversion, defaultSchema, formatTable, formattedViewTable, modelTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, modelTable, viewTable } from '../_utils/utils';
 import env from '@ckeditor/ckeditor5-utils/src/env';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 function paragraphInTableCell() {
 	return dispatcher => dispatcher.on( 'insert:paragraph', ( evt, data, conversionApi ) => {
@@ -59,7 +60,7 @@ describe( 'downcast converters', () => {
 		it( 'should create table with tbody', () => {
 			setModelData( model, modelTable( [ [ '' ] ] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<tbody>' +
@@ -67,7 +68,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should create table with tbody and thead', () => {
@@ -76,7 +77,7 @@ describe( 'downcast converters', () => {
 				[ '10' ]
 			], { headingRows: 1 } ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<thead>' +
@@ -87,7 +88,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should create table with thead', () => {
@@ -96,7 +97,7 @@ describe( 'downcast converters', () => {
 				[ '10' ]
 			], { headingRows: 2 } ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<thead>' +
@@ -105,7 +106,7 @@ describe( 'downcast converters', () => {
 						'</thead>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should create table with heading columns and rows', () => {
@@ -114,7 +115,7 @@ describe( 'downcast converters', () => {
 				[ '10', '11', '12', '13' ]
 			], { headingColumns: 3, headingRows: 1 } ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<thead>' +
@@ -125,7 +126,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should create table with block content', () => {
@@ -133,7 +134,7 @@ describe( 'downcast converters', () => {
 				[ '<paragraph>00</paragraph><paragraph>foo</paragraph>', '01' ]
 			] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<tbody>' +
@@ -144,7 +145,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should create table with block content (attribute on paragraph)', () => {
@@ -163,7 +164,7 @@ describe( 'downcast converters', () => {
 				[ '<paragraph alignment="right">00</paragraph>' ]
 			] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<tbody>' +
@@ -173,7 +174,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should be possible to overwrite', () => {
@@ -193,11 +194,11 @@ describe( 'downcast converters', () => {
 
 			setModelData( model, modelTable( [ [ '' ] ] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<table foo="bar">' +
 					'<tr><td><p></p></td></tr>' +
 				'</table>'
-			) );
+			);
 		} );
 
 		it( 'should re-create table on reinsert', () => {
@@ -209,7 +210,7 @@ describe( 'downcast converters', () => {
 
 			setModelData( model, modelTable( [ [ '[]' ] ] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table">' +
 					'<table>' +
 						'<tbody>' +
@@ -217,7 +218,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 
 			model.change( writer => {
 				const table = model.document.getRoot().getChild( 0 );
@@ -227,7 +228,7 @@ describe( 'downcast converters', () => {
 				writer.wrap( range, wrapper );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<div>' +
 					'<figure class="table">' +
 						'<table>' +
@@ -237,7 +238,7 @@ describe( 'downcast converters', () => {
 						'</table>' +
 					'</figure>' +
 				'</div>'
-			) );
+			);
 		} );
 
 		describe( 'headingColumns attribute', () => {
@@ -247,7 +248,7 @@ describe( 'downcast converters', () => {
 					[ '10', '11', '12' ]
 				], { headingColumns: 2 } ) );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="table">' +
 						'<table>' +
 							'<tbody>' +
@@ -256,7 +257,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 
 			it( 'should mark heading columns table cells when one has colspan attribute', () => {
@@ -265,7 +266,7 @@ describe( 'downcast converters', () => {
 					[ { colspan: 2, contents: '10' }, '12', '13' ]
 				], { headingColumns: 3 } ) );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="table">' +
 						'<table>' +
 							'<tbody>' +
@@ -274,7 +275,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 
 			it( 'should work with colspan and rowspan attributes on table cells', () => {
@@ -299,7 +300,7 @@ describe( 'downcast converters', () => {
 					[ '32', '33' ]
 				], { headingColumns: 3 } ) );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="table">' +
 						'<table>' +
 							'<tbody>' +
@@ -310,7 +311,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 
@@ -332,7 +333,7 @@ describe( 'downcast converters', () => {
 			it( 'should create table as a widget', () => {
 				setModelData( model, modelTable( [ [ '' ] ] ) );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
@@ -343,7 +344,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 	} );
@@ -365,7 +366,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ]
 			] ) );
@@ -387,7 +388,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ]
 			] ) );
@@ -401,7 +402,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ],
 				[ '', '' ]
@@ -426,7 +427,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ],
 				[ '21', '22' ],
@@ -452,7 +453,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ],
 				[ '21', '22' ],
@@ -478,7 +479,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ],
 				[ '21', '22' ],
@@ -501,7 +502,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { rowspan: 3, contents: '00' }, '01' ],
 				[ '22' ],
 				[ '' ]
@@ -529,7 +530,7 @@ describe( 'downcast converters', () => {
 				writer.insert( writer.createElement( 'tableCell' ), secondRow, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { rowspan: 3, contents: '00', isHeading: true }, '01' ],
 				[ '22' ],
 				[ '' ],
@@ -564,8 +565,7 @@ describe( 'downcast converters', () => {
 					writer.insert( writer.createElement( 'tableCell' ), firstRow, 'end' );
 				} );
 
-				expect( formatTable(
-					getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
 						'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
@@ -581,7 +581,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 	} );
@@ -600,7 +600,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 1 );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '', '01' ]
 			] ) );
 		} );
@@ -618,7 +618,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', row, 1 );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { colspan: 2, contents: '00' }, '', '13' ]
 			] ) );
 		} );
@@ -636,7 +636,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', table.getChild( 1 ), 0 );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { rowspan: 2, contents: '00' }, '', '13' ],
 				[ '', '11', '12' ]
 			] ) );
@@ -658,7 +658,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'colspan', 2, secondRow.getChild( 0 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '', '01' ],
 				[ { colspan: 2, contents: '10' }, '11' ]
 			] ) );
@@ -679,7 +679,7 @@ describe( 'downcast converters', () => {
 				writer.remove( firstRow.getChild( 1 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { colspan: 2, contents: '00' } ],
 				[ '10', '11' ]
 			] ) );
@@ -711,21 +711,21 @@ describe( 'downcast converters', () => {
 					writer.insert( writer.createElement( 'tableCell' ), row, 'end' );
 				} );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
 							'<tbody>' +
 								'<tr>' +
-					'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
-					'<span>00</span>' +
-					'</td>' +
+									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true">' +
+										'<span>00</span>' +
+									'</td>' +
 									'<td class="ck-editor__editable ck-editor__nested-editable" contenteditable="true"></td>' +
 								'</tr>' +
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 	} );
@@ -743,7 +743,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingColumns', 1, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { isHeading: true, contents: '00' }, '01' ],
 				[ { isHeading: true, contents: '10' }, '11' ]
 			], { headingColumns: 1 } ) );
@@ -761,7 +761,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingColumns', 3, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { isHeading: true, contents: '00' }, { isHeading: true, contents: '01' }, { isHeading: true, contents: '02' }, '03' ],
 				[ { isHeading: true, contents: '10' }, { isHeading: true, contents: '11' }, { isHeading: true, contents: '12' }, '13' ]
 			] ) );
@@ -779,7 +779,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingColumns', 1, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ { isHeading: true, contents: '00' }, '01', '02', '03' ],
 				[ { isHeading: true, contents: '10' }, '11', '12', '13' ]
 			], { headingColumns: 3 } ) );
@@ -796,7 +796,7 @@ describe( 'downcast converters', () => {
 				writer.removeAttribute( 'headingColumns', table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ]
 			] ) );
@@ -812,7 +812,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingColumns', 1, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table" headingColumns="1">' +
 					'<table>' +
 						'<tbody>' +
@@ -820,7 +820,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should work with adding table cells', () => {
@@ -841,7 +841,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', table.getChild( 2 ), 1 );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[
 					{ isHeading: true, rowspan: 2, contents: '00' },
 					{ isHeading: true, contents: '01' },
@@ -888,7 +888,7 @@ describe( 'downcast converters', () => {
 					writer.setAttribute( 'headingRows', 1, table );
 				} );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
@@ -901,7 +901,7 @@ describe( 'downcast converters', () => {
 							'</thead>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 	} );
@@ -920,7 +920,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingRows', 2, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ],
 				[ '20', '21' ]
@@ -940,7 +940,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingRows', 2, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ],
 				[ '20', '21' ]
@@ -961,7 +961,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingRows', 2, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ],
 				[ '20', '21' ],
@@ -981,7 +981,7 @@ describe( 'downcast converters', () => {
 				writer.removeAttribute( 'headingRows', table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ]
 			] ) );
@@ -999,7 +999,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingRows', 2, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '10', '11' ]
 			], { headingRows: 2 } ) );
@@ -1015,7 +1015,7 @@ describe( 'downcast converters', () => {
 				writer.setAttribute( 'headingRows', 1, table );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 				'<figure class="table" headingRows="1">' +
 					'<table>' +
 						'<tbody>' +
@@ -1023,7 +1023,7 @@ describe( 'downcast converters', () => {
 						'</tbody>' +
 					'</table>' +
 				'</figure>'
-			) );
+			);
 		} );
 
 		it( 'should work with adding table rows at the beginning of a table', () => {
@@ -1044,7 +1044,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', tableRow, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '', '' ],
 				[ '00', '01' ],
 				[ '10', '11' ]
@@ -1070,7 +1070,7 @@ describe( 'downcast converters', () => {
 				writer.insertElement( 'tableCell', tableRow, 'end' );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ],
 				[ '', '' ],
 				[ '10', '11' ],
@@ -1102,7 +1102,7 @@ describe( 'downcast converters', () => {
 					writer.setAttribute( 'headingColumns', 1, table );
 				} );
 
-				expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formatTable(
+				assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ),
 					'<figure class="ck-widget ck-widget_with-selection-handle table" contenteditable="false">' +
 					'<div class="ck ck-widget__selection-handle"></div>' +
 						'<table>' +
@@ -1115,7 +1115,7 @@ describe( 'downcast converters', () => {
 							'</tbody>' +
 						'</table>' +
 					'</figure>'
-				) );
+				);
 			} );
 		} );
 	} );
@@ -1133,7 +1133,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 1 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ]
 			] ) );
 		} );
@@ -1150,7 +1150,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 0 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '10', '11' ]
 			] ) );
 		} );
@@ -1167,7 +1167,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 1 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ]
 			], { headingRows: 2 } ) );
 		} );
@@ -1184,7 +1184,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 0 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '10', '11' ]
 			], { headingRows: 2 } ) );
 		} );
@@ -1202,7 +1202,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 0 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '10', '11' ]
 			] ) );
 		} );
@@ -1219,7 +1219,7 @@ describe( 'downcast converters', () => {
 				writer.remove( table.getChild( 1 ) );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00', '01' ]
 			], { headingRows: 1 } ) );
 		} );
@@ -1247,7 +1247,7 @@ describe( 'downcast converters', () => {
 				[ '00[]' ]
 			] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '00' ]
 			], { asWidget: true } ) );
 
@@ -1263,7 +1263,7 @@ describe( 'downcast converters', () => {
 				writer.setSelection( nodeByPath.nextSibling, 0 );
 			} );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '<p>00</p><p></p>' ]
 			], { asWidget: true } ) );
 		} );
@@ -1276,7 +1276,7 @@ describe( 'downcast converters', () => {
 				[ '<paragraph foo="bar">00[]</paragraph>' ]
 			] ) );
 
-			expect( formatTable( getViewData( viewDocument, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+			assertEqualMarkup( getViewData( viewDocument, { withoutSelection: true } ), viewTable( [
 				[ '<p foo="bar">00</p>' ]
 			], { asWidget: true } ) );
 		} );

+ 15 - 15
packages/ckeditor5-table/tests/converters/table-cell-paragraph-post-fixer.js

@@ -8,8 +8,8 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import { getData as getModelData, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import TableEditing from '../../src/tableediting';
-import { formatTable } from './../_utils/utils';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'Table cell paragraph post-fixer', () => {
 	let editor, model, root;
@@ -39,13 +39,13 @@ describe( 'Table cell paragraph post-fixer', () => {
 			'</table>'
 		);
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell><paragraph></paragraph></tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should add a paragraph to an empty table cell (on row insert)', () => {
@@ -63,7 +63,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.insertElement( 'tableCell', writer.createPositionAt( root.getNodeByPath( [ 0, 1 ] ), 0 ) );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell><paragraph></paragraph></tableCell>' +
@@ -72,7 +72,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 					'<tableCell><paragraph></paragraph></tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should add a paragraph to an empty table cell (on table cell insert)', () => {
@@ -89,14 +89,14 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.insertElement( 'tableCell', writer.createPositionAt( root.getNodeByPath( [ 0, 0 ] ), 'end' ) );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell><paragraph></paragraph></tableCell>' +
 					'<tableCell><paragraph></paragraph></tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should add a paragraph to an empty table cell (after remove)', () => {
@@ -113,13 +113,13 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.remove( writer.createRangeIn( root.getNodeByPath( [ 0, 0, 0 ] ) ) );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell><paragraph></paragraph></tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should wrap in paragraph $text nodes placed directly in tableCell (on table cell modification) ', () => {
@@ -143,7 +143,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.insertText( 'baz', root.getNodeByPath( [ 0, 0, 0 ] ), 'end' );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell>' +
@@ -153,7 +153,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 					'</tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should wrap in paragraph $text nodes placed directly in tableCell (on inserting table cell)', () => {
@@ -173,7 +173,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.insert( tableCell, writer.createPositionAt( root.getNodeByPath( [ 0, 0 ] ), 'end' ) );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell>' +
@@ -184,7 +184,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 					'</tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 
 	it( 'should wrap in paragraph $text nodes placed directly in tableCell (on inserting table rows)', () => {
@@ -206,7 +206,7 @@ describe( 'Table cell paragraph post-fixer', () => {
 			writer.insert( tableRow, writer.createPositionAt( root.getNodeByPath( [ 0 ] ), 'end' ) );
 		} );
 
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable(
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ),
 			'<table>' +
 				'<tableRow>' +
 					'<tableCell>' +
@@ -219,6 +219,6 @@ describe( 'Table cell paragraph post-fixer', () => {
 					'</tableCell>' +
 				'</tableRow>' +
 			'</table>'
-		) );
+		);
 	} );
 } );

+ 16 - 15
packages/ckeditor5-table/tests/converters/table-cell-refresh-post-fixer.js

@@ -7,7 +7,7 @@
 
 import { getData as getViewData } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 
-import { defaultConversion, defaultSchema, formatTable, formattedViewTable, viewTable } from '../_utils/utils';
+import { defaultConversion, defaultSchema, viewTable } from '../_utils/utils';
 import injectTableCellRefreshPostFixer from '../../src/converters/table-cell-refresh-post-fixer';
 
 import env from '@ckeditor/ckeditor5-utils/src/env';
@@ -15,6 +15,7 @@ import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
 
 import Delete from '@ckeditor/ckeditor5-typing/src/delete';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'Table cell refresh post-fixer', () => {
 	let editor, model, doc, root, view, refreshItemSpy, element;
@@ -73,7 +74,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><p></p>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -94,7 +95,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><div></div>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -113,7 +114,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setSelection( nodeByPath.nextSibling, 0 );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p>00</p><p></p>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -122,7 +123,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '00' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledTwice( refreshItemSpy );
@@ -137,7 +138,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="bar">00</p>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -152,7 +153,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.remove( table.getNodeByPath( [ 0, 0, 1 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '00' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -167,7 +168,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.removeAttribute( 'foo', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<span>00</span>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );
@@ -182,7 +183,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="baz">00</p>' ]
 		], { asWidget: true } ) );
 		// False positive: should not be called.
@@ -198,7 +199,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setAttribute( 'bar', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p bar="bar" foo="bar">00</p>' ]
 		], { asWidget: true } ) );
 
@@ -216,7 +217,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.removeAttribute( 'foo', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p bar="bar">00</p>' ]
 		], { asWidget: true } ) );
 		// False positive: should not be called.
@@ -232,7 +233,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setAttribute( 'foo', 'baz', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<p foo="baz">00</p><p>00</p>' ]
 		], { asWidget: true } ) );
 		sinon.assert.notCalled( refreshItemSpy );
@@ -247,7 +248,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.rename( table.getNodeByPath( [ 0, 0, 0 ] ), 'block' );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<div>00</div>' ]
 		], { asWidget: true } ) );
 		sinon.assert.notCalled( refreshItemSpy );
@@ -262,7 +263,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.setAttribute( 'foo', 'bar', table.getNodeByPath( [ 0, 0, 0 ] ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<div foo="bar">foo</div>' ]
 		], { asWidget: true } ) );
 		sinon.assert.notCalled( refreshItemSpy );
@@ -277,7 +278,7 @@ describe( 'Table cell refresh post-fixer', () => {
 			writer.remove( writer.createRangeOn( table.getNodeByPath( [ 0, 0, 1 ] ) ) );
 		} );
 
-		expect( formatTable( getViewData( view, { withoutSelection: true } ) ) ).to.equal( formattedViewTable( [
+		assertEqualMarkup( getViewData( view, { withoutSelection: true } ), viewTable( [
 			[ '<span>00</span>' ]
 		], { asWidget: true } ) );
 		sinon.assert.calledOnce( refreshItemSpy );

+ 30 - 29
packages/ckeditor5-table/tests/converters/table-layout-post-fixer.js

@@ -8,8 +8,9 @@ import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtest
 import { getData as getModelData, parse, setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
 import TableEditing from '../../src/tableediting';
-import { formatTable, formattedModelTable, modelTable } from './../_utils/utils';
+import { modelTable } from './../_utils/utils';
 import UndoEditing from '@ckeditor/ckeditor5-undo/src/undoediting';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'Table layout post-fixer', () => {
 	let editor, model, root;
@@ -43,7 +44,7 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[ '00', '', '' ],
 				[ '10', '11', '12' ],
 				[ '20', '21', '' ]
@@ -62,7 +63,7 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[ '00', { rowspan: 2, contents: '10' }, '', '' ],
 				[ '10', { colspan: 2, contents: '12' } ],
 				[ '20', '21', '', '' ]
@@ -81,7 +82,7 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[ { colspan: 6, contents: '00' } ],
 				[ { rowspan: 2, contents: '10' }, '11', { colspan: 3, contents: '12' }, '' ],
 				[ '21', '22', '', '', '' ]
@@ -100,7 +101,7 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[ { rowspan: 2, contents: '00' }, { rowspan: 2, contents: '01' }, '02' ],
 				[ '12' ],
 				[ '20', '21', '22' ]
@@ -119,7 +120,7 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { rowspan: 2, contents: '10' }, { rowspan: 2, contents: '11' }, '12' ],
 				[ '22' ]
@@ -147,22 +148,22 @@ describe( 'Table layout post-fixer', () => {
 				writer.insert( parsed, root );
 			} );
 
-			const expectedTableA = formattedModelTable( [
+			const expectedTableA = modelTable( [
 				[ '11', '' ],
 				[ '21', '22' ]
 			] );
-			const expectedTableB = formattedModelTable( [
+			const expectedTableB = modelTable( [
 				[ 'aa', 'ab' ],
 				[ 'ba', 'bb' ]
 			] );
-			const expectedTableC = formattedModelTable( [
+			const expectedTableC = modelTable( [
 				[ 'xx', '' ],
 				[ 'yy', 'yy' ]
 			] );
 
 			const expectedTables = expectedTableA + expectedTableB + expectedTableC;
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( expectedTables );
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), expectedTables );
 		} );
 
 		it( 'should not crash on table remove', () => {
@@ -190,13 +191,13 @@ describe( 'Table layout post-fixer', () => {
 				writer => _removeColumn( writer, 1, [ 0, 1 ] ),
 				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
 				// Table should have added empty cells.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '' ],
 					[ 'a', 'b' ],
 					[ '10', '' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '01', '' ],
 					[ 'a', 'b', '' ],
 					[ '10', '11', '' ]
@@ -212,13 +213,13 @@ describe( 'Table layout post-fixer', () => {
 				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
 				writer => _removeColumn( writer, 1, [ 0, 2 ] ),
 				// There should be empty cells added.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '' ],
 					[ 'a', 'b' ],
 					[ '10', '' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '' ],
 					[ '10', '' ]
 				] ) );
@@ -233,13 +234,13 @@ describe( 'Table layout post-fixer', () => {
 				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
 				writer => _insertColumn( writer, 1, [ 0, 2 ] ),
 				// There should be empty cells added.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '', '01' ],
 					[ 'a', 'b', '' ],
 					[ '10', '', '11' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '', '01' ],
 					[ '10', '', '11' ]
 				] ) );
@@ -254,13 +255,13 @@ describe( 'Table layout post-fixer', () => {
 				writer => _insertColumn( writer, 1, [ 0, 1 ] ),
 				writer => _insertRow( writer, 1, [ 'a', 'b' ] ),
 				// There should be empty cells added.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '', '01' ],
 					[ 'a', 'b', '' ],
 					[ '10', '', '11' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ '00', '01', '' ],
 					[ 'a', 'b', '' ],
 					[ '10', '11', '' ]
@@ -282,12 +283,12 @@ describe( 'Table layout post-fixer', () => {
 					_insertColumn( writer, 1, [ 1 ] );
 				},
 				// There should be empty cells added.
-				formattedModelTable( [
+				modelTable( [
 					[ { colspan: 4, contents: '00' }, '' ],
 					[ '10', '', '11', '', '12' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ { colspan: 3, contents: '00' }, '' ],
 					[ '10', '', '11', '12' ]
 				] ) );
@@ -308,12 +309,12 @@ describe( 'Table layout post-fixer', () => {
 					_insertColumn( writer, 3, [ 1 ] );
 				},
 				// There should be empty cells added.
-				formattedModelTable( [
+				modelTable( [
 					[ { colspan: 4, contents: '00' }, '' ],
 					[ '10', '', '11', '', '12' ]
 				] ),
 				// Table will have empty column after undo.
-				formattedModelTable( [
+				modelTable( [
 					[ { colspan: 3, contents: '00' }, '' ],
 					[ '10', '11', '', '12' ]
 				] ) );
@@ -334,11 +335,11 @@ describe( 'Table layout post-fixer', () => {
 				writer => {
 					_removeRow( writer, 1 );
 				},
-				formattedModelTable( [
+				modelTable( [
 					[ '11', '12', '13' ],
 					[ '31', '32', '33' ]
 				], { headingRows: 1 } ),
-				formattedModelTable( [
+				modelTable( [
 					[ '11', { rowspan: 2, contents: '12' }, '13', '' ],
 					[ '31', '32', '33' ]
 				] ) );
@@ -358,11 +359,11 @@ describe( 'Table layout post-fixer', () => {
 					_setAttribute( writer, 'headingRows', 1, [ 0 ] );
 					_removeAttribute( writer, 'rowspan', [ 0, 0, 1 ] );
 				},
-				formattedModelTable( [
+				modelTable( [
 					[ '11', '12', '13', '' ],
 					[ '31', '32', '33', '' ]
 				], { headingRows: 1 } ),
-				formattedModelTable( [
+				modelTable( [
 					[ '11', '12', '13', '' ],
 					[ '21', '23', '', '' ],
 					[ '31', '32', '33', '' ]
@@ -376,15 +377,15 @@ describe( 'Table layout post-fixer', () => {
 
 			model.enqueueChange( 'transparent', externalCallback );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after operations' ).to.equal( modelAfter );
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelAfter );
 
 			editor.execute( 'undo' );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after undo' ).to.equal( modelAfterUndo );
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelAfterUndo );
 
 			editor.execute( 'redo' );
 
-			expect( formatTable( getModelData( model, { withoutSelection: true } ) ), 'after redo' ).to.equal( modelAfter );
+			assertEqualMarkup( getModelData( model, { withoutSelection: true } ), modelAfter );
 		}
 
 		function _removeColumn( writer, columnIndex, rows ) {

+ 10 - 12
packages/ckeditor5-table/tests/converters/upcasttable.js

@@ -9,7 +9,9 @@ import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
 import Widget from '@ckeditor/ckeditor5-widget/src/widget';
 
-import { defaultConversion, defaultSchema, formatTable, modelTable } from '../_utils/utils';
+import { modelTable } from '../_utils/utils';
+import TableEditing from '../../src/tableediting';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'upcastTable()', () => {
 	let editor, model;
@@ -17,16 +19,12 @@ describe( 'upcastTable()', () => {
 	beforeEach( () => {
 		return VirtualTestEditor
 			.create( {
-				plugins: [ Paragraph, ImageEditing, Widget ]
+				plugins: [ TableEditing, Paragraph, ImageEditing, Widget ]
 			} )
 			.then( newEditor => {
 				editor = newEditor;
 				model = editor.model;
 
-				defaultSchema( model.schema, false );
-
-				defaultConversion( editor.conversion, true );
-
 				// Since this part of test tests only view->model conversion editing pipeline is not necessary
 				// so defining model->view converters won't be necessary.
 				editor.editing.destroy();
@@ -34,7 +32,7 @@ describe( 'upcastTable()', () => {
 	} );
 
 	function expectModel( data ) {
-		expect( formatTable( getModelData( model, { withoutSelection: true } ) ) ).to.equal( formatTable( data ) );
+		assertEqualMarkup( getModelData( model, { withoutSelection: true } ), data );
 	}
 
 	it( 'should convert table figure', () => {
@@ -551,11 +549,11 @@ describe( 'upcastTable()', () => {
 
 			expectModel(
 				'<table>' +
-					'<tableRow><tableCell><paragraph>1</paragraph></tableCell></tableRow>' +
-					'<tableRow><tableCell><paragraph>2</paragraph></tableCell></tableRow>' +
-					'<tableRow><tableCell><paragraph>3</paragraph></tableCell></tableRow>' +
-					'<tableRow><tableCell><paragraph>4</paragraph></tableCell></tableRow>' +
-					'<tableRow><tableCell><paragraph>5</paragraph></tableCell></tableRow>' +
+				'<tableRow><tableCell><paragraph>1</paragraph></tableCell></tableRow>' +
+				'<tableRow><tableCell><paragraph>2</paragraph></tableCell></tableRow>' +
+				'<tableRow><tableCell><paragraph>3</paragraph></tableCell></tableRow>' +
+				'<tableRow><tableCell><paragraph>4</paragraph></tableCell></tableRow>' +
+				'<tableRow><tableCell><paragraph>5</paragraph></tableCell></tableRow>' +
 				'</table>'
 			);
 		} );

+ 412 - 0
packages/ckeditor5-table/tests/manual/tableproperties.html

@@ -0,0 +1,412 @@
+<div style="display:flex">
+	<div>
+		<div id="editor">
+			<figure>
+				<table style="border: 1px solid green;background:#fdfd77;width:200px;height:140px">
+					<thead>
+						<tr>
+							<td><p>Column A</p></td>
+							<td style="border:2px dashed black;background-color: lightblue"><p>Column B</p></td>
+							<td style="background: palevioletred"><p>Column C</p></td>
+						</tr>
+					</thead>
+					<tbody>
+						<tr>
+							<td style="border-color: deeppink deepskyblue;border-style: solid;border-width: 1px;border-bottom-width: 2px;border-right-style: dotted;">
+								<p>A1</p>
+							</td>
+							<td style="border: 1px solid blue;"><p>B1</p></td>
+							<td style="border: 1px solid red;"><p>C1</p></td>
+						</tr>
+						<tr>
+							<td style="border-right:1px solid blue;"><p>A2</p></td>
+							<td style="background:transparent;"><p>B2</p></td>
+							<td><p>C3</p></td>
+						</tr>
+					</tbody>
+				</table>
+			</figure>
+
+			<h3>Table from Word</h3>
+
+			<table class=MsoTableGrid border=1 cellspacing=0 cellpadding=0
+				   style='border-collapse:collapse;border:none;mso-border-alt:solid windowtext .5pt;
+mso-yfti-tbllook:1184;mso-padding-alt:0in 5.4pt 0in 5.4pt'>
+				<tr style='mso-yfti-irow:0;mso-yfti-firstrow:yes'>
+					<td width=30 valign=top style='width:22.25pt;border:solid windowtext 1.0pt;
+mso-border-alt:solid windowtext .5pt;background:#92D050;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>w
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border-top:solid windowtext 1.0pt;
+border-left:none;border-bottom:dotted #FFC000 3.0pt;border-right:solid windowtext 1.0pt;
+mso-border-left-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
+mso-border-bottom-alt:dotted #FFC000 3.0pt;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border:solid windowtext 1.0pt;
+border-left:none;mso-border-left-alt:solid windowtext .5pt;mso-border-alt:
+solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border:solid windowtext 1.0pt;
+border-left:none;mso-border-left-alt:solid windowtext .5pt;mso-border-alt:
+solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=138 valign=top style='width:93.5pt;border:solid windowtext 1.0pt;
+border-left:none;mso-border-left-alt:solid windowtext .5pt;mso-border-alt:
+solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+				</tr>
+				<tr style='mso-yfti-irow:1'>
+					<td width=30 valign=top style='width:22.25pt;border-top:none;border-left:
+solid windowtext 1.0pt;border-bottom:solid windowtext 1.0pt;border-right:
+dotted #FFC000 3.0pt;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:
+solid windowtext .5pt;mso-border-right-alt:dotted #FFC000 3.0pt;background:
+#92D050;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border-top:none;border-left:
+none;border-bottom:dotted #FFC000 3.0pt;border-right:dotted #FFC000 3.0pt;
+mso-border-top-alt:dotted #FFC000 3.0pt;mso-border-left-alt:dotted #FFC000 3.0pt;
+padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>Funny border
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:dotted #FFC000 3.0pt;
+mso-border-alt:solid windowtext .5pt;mso-border-left-alt:dotted #FFC000 3.0pt;
+padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:dashed red 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;mso-border-bottom-alt:dashed red .5pt;
+padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>Border bottom
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=138 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+				</tr>
+				<tr style='mso-yfti-irow:2;height:70.35pt'>
+					<td width=30 valign=top style='width:22.25pt;border:solid windowtext 1.0pt;
+border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
+background:#92D050;padding:0in 5.4pt 0in 5.4pt;height:70.35pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:dotted #FFC000 3.0pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;mso-border-top-alt:dotted #FFC000 3.0pt;
+padding:0in 5.4pt 0in 5.4pt;height:70.35pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:70.35pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:dashed red .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;mso-border-top-alt:dashed red .5pt;
+padding:0in 5.4pt 0in 5.4pt;height:70.35pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=138 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:.1in .5in .5in .5in;height:70.35pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>margin
+							<o:p></o:p>
+						</p>
+					</td>
+				</tr>
+				<tr style='mso-yfti-irow:3;height:31.0pt'>
+					<td width=30 valign=top style='width:22.25pt;border:solid windowtext 1.0pt;
+border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
+background:#92D050;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>TL
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=125 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal align=center style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:center;line-height:normal'>TC
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=138 valign=top style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal align=right style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:right;line-height:normal'>TR
+							<o:p></o:p>
+						</p>
+					</td>
+				</tr>
+				<tr style='mso-yfti-irow:4;height:31.0pt'>
+					<td width=30 valign=top style='width:22.25pt;border:solid windowtext 1.0pt;
+border-top:none;mso-border-top-alt:solid windowtext .5pt;mso-border-alt:solid windowtext .5pt;
+background:#92D050;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border:none;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-right-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;
+height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=125 style='width:93.5pt;border-top:none;border-left:none;
+border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>L
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=125 style='width:93.5pt;border-top:none;border-left:none;
+border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal align=center style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:center;line-height:normal'>C
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=138 style='width:93.5pt;border-top:none;border-left:none;
+border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:31.0pt'>
+						<p class=MsoNormal align=right style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:right;line-height:normal'>R
+							<o:p></o:p>
+						</p>
+					</td>
+				</tr>
+				<tr style='mso-yfti-irow:5;mso-yfti-lastrow:yes;height:30.1pt'>
+					<td width=30 valign=top style='width:22.25pt;border-top:none;border-left:
+solid windowtext 1.0pt;border-bottom:solid windowtext 1.0pt;border-right:
+none;mso-border-top-alt:solid windowtext .5pt;mso-border-top-alt:solid windowtext .5pt;
+mso-border-left-alt:solid windowtext .5pt;mso-border-bottom-alt:solid windowtext .5pt;
+background:#92D050;padding:0in 5.4pt 0in 5.4pt;height:30.1pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>
+							<o:p>&nbsp;</o:p>
+						</p>
+					</td>
+					<td width=220 valign=top style='width:164.75pt;border:none;padding:0in 5.4pt 0in 5.4pt;
+height:30.1pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>No border
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=125 valign=bottom style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-top-alt:solid windowtext .5pt;
+mso-border-bottom-alt:solid windowtext .5pt;mso-border-right-alt:solid windowtext .5pt;
+padding:0in 5.4pt 0in 5.4pt;height:30.1pt'>
+						<p class=MsoNormal style='margin-bottom:0in;margin-bottom:.0001pt;line-height:
+normal'>BL
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=125 valign=bottom style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:30.1pt'>
+						<p class=MsoNormal align=center style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:center;line-height:normal'>BC
+							<o:p></o:p>
+						</p>
+					</td>
+					<td width=138 valign=bottom style='width:93.5pt;border-top:none;border-left:
+none;border-bottom:solid windowtext 1.0pt;border-right:solid windowtext 1.0pt;
+mso-border-top-alt:solid windowtext .5pt;mso-border-left-alt:solid windowtext .5pt;
+mso-border-alt:solid windowtext .5pt;padding:0in 5.4pt 0in 5.4pt;height:30.1pt'>
+						<p class=MsoNormal align=right style='margin-bottom:0in;margin-bottom:.0001pt;
+text-align:right;line-height:normal'>BR
+							<o:p></o:p>
+						</p>
+					</td>
+				</tr>
+			</table>
+
+			<h3>Table from GDocs</h3>
+			<table style="border:none;border-collapse:collapse;width:468pt;table-layout:fixed">
+				<colgroup>
+					<col>
+					<col>
+					<col>
+				</colgroup>
+				<tbody>
+					<tr style="height:21pt">
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;background-color:#fff2cc;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; background-color: transparent; font-weight: 700; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">A</span>
+							</p></td>
+						<td style="border-left:solid #000000 1pt;border-right:dotted #ff0000 1.5pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;background-color:#fff2cc;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; background-color: transparent; font-weight: 700; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">B</span>
+							</p></td>
+						<td style="border-left:dotted #ff0000 1.5pt;border-right:dotted #ff0000 1.5pt;border-bottom:dotted #ff0000 1.5pt;border-top:dotted #ff0000 1.5pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">C</span>
+							</p></td>
+					</tr>
+					<tr style="height:21pt">
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">a1</span>
+							</p></td>
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:dashed #4c1130 3pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">b1</span>
+							</p></td>
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:dotted #ff0000 1.5pt;border-top:dotted #ff0000 1.5pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">c1</span>
+							</p></td>
+					</tr>
+					<tr style="height:21pt">
+						<td style="border-left:solid #000000 1pt;border-right:dashed #4c1130 3pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+						<td style="border-left:dashed #4c1130 3pt;border-right:dashed #4c1130 3pt;border-bottom:dashed #4c1130 3pt;border-top:dashed #4c1130 3pt;vertical-align:middle;background-color:#a2c4c9;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;text-align: center;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; color: rgb(204, 0, 0); background-color: transparent; font-weight: 700; font-style: italic; font-variant-numeric: normal; font-variant-east-asian: normal; text-decoration-line: underline; text-decoration-skip-ink: none; vertical-align: baseline; white-space: pre-wrap;">AWESOME</span>
+							</p></td>
+						<td style="border-left:dashed #4c1130 3pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:dotted #ff0000 1.5pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+					</tr>
+				</tbody>
+			</table>
+
+			<h3>Table from GDocs (all styles set + column widths)</h3>
+
+			<table style="border:none;border-collapse:collapse;">
+				<colgroup>
+					<col width="27">
+					<col width="192">
+					<col width="17">
+				</colgroup>
+				<tbody>
+					<tr style="height:0pt">
+						<td style="border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+						<td style="border-left:solid #000000 1pt;border-bottom:dotted #7f6000 2.25pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<p dir="ltr" style="line-height:1.2;margin-top:0pt;margin-bottom:0pt;"><span
+								style="font-size: 11pt; font-family: Arial; font-variant-numeric: normal; font-variant-east-asian: normal; vertical-align: baseline; white-space: pre-wrap;">a</span>
+							</p></td>
+						<td style="border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+					</tr>
+					<tr style="height:144pt">
+						<td style="border-right:dotted #7f6000 2.25pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+						<td style="border-left:dotted #7f6000 2.25pt;border-right:dotted #7f6000 2.25pt;border-bottom:dotted #7f6000 2.25pt;border-top:dotted #7f6000 2.25pt;vertical-align:middle;background-color:#ffd966;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br><br></td>
+						<td style="border-left:dotted #7f6000 2.25pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+					</tr>
+					<tr style="height:0pt">
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:dotted #7f6000 2.25pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+						<td style="border-left:solid #000000 1pt;border-right:solid #000000 1pt;border-bottom:solid #000000 1pt;border-top:solid #000000 1pt;vertical-align:top;padding:5pt 5pt 5pt 5pt;overflow:hidden;overflow-wrap:break-word;">
+							<br></td>
+					</tr>
+				</tbody>
+			</table>
+		</div>
+
+	</div>
+	<div style="padding-left: 2em;">
+		<h2>Source data</h2>
+
+		<div id="cloned-source"></div>
+	</div>
+</div>
+

+ 39 - 0
packages/ckeditor5-table/tests/manual/tableproperties.js

@@ -0,0 +1,39 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+/* globals console, window, document */
+
+import ClassicEditor from '@ckeditor/ckeditor5-editor-classic/src/classiceditor';
+import ArticlePluginSet from '@ckeditor/ckeditor5-core/tests/_utils/articlepluginset';
+import Alignment from '@ckeditor/ckeditor5-alignment/src/alignment';
+import IndentBlock from '@ckeditor/ckeditor5-indent/src/indentblock';
+import Indent from '@ckeditor/ckeditor5-indent/src/indent';
+
+import TableProperties from '../../src/tableproperties';
+import TableCellProperties from '../../src/tablecellproperties';
+
+const sourceElement = document.querySelector( '#editor' );
+const clonedSource = sourceElement.cloneNode( true );
+
+document.querySelector( '#cloned-source' ).append( ...clonedSource.childNodes );
+
+ClassicEditor
+	.create( sourceElement, {
+		plugins: [ ArticlePluginSet, Alignment, Indent, IndentBlock, TableProperties, TableCellProperties ],
+		toolbar: [
+			'heading', '|', 'insertTable', '|', 'bold', 'italic', 'bulletedList', 'numberedList', 'blockQuote', 'undo', 'redo'
+		],
+		table: {
+			contentToolbar: [ 'tableColumn', 'tableRow', 'mergeTableCells' ],
+			tableToolbar: [ 'bold', 'italic' ]
+		}
+	} )
+	.then( editor => {
+		window.editor = editor;
+	} )
+	.catch( err => {
+		console.error( err.stack );
+	} );
+

+ 12 - 0
packages/ckeditor5-table/tests/manual/tableproperties.md

@@ -0,0 +1,12 @@
+### Loading
+
+The editor should be loaded with tables:
+
+1. A table with various table styles set.
+1. A "Table from Word" sample.
+1. A "Table from GDocs" sample.
+1. A "Table from GDocs" sample that has one cell with many styles set.
+
+Compare their visual styles with the ones beside the editor. Most of the styles should be preserved.
+
+**Note**: Not all styles are preserved (i.e. the column height/width in the second GDocs table). Also note that the original table might have a different cell padding.

+ 7 - 6
packages/ckeditor5-table/tests/table-integration.js

@@ -19,7 +19,8 @@ import {
 import { parse as parseView } from '@ckeditor/ckeditor5-engine/src/dev-utils/view';
 
 import TableEditing from '../src/tableediting';
-import { formatTable, formattedModelTable, modelTable, viewTable } from './_utils/utils';
+import { modelTable, viewTable } from './_utils/utils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'Table feature – integration', () => {
 	describe( 'with clipboard', () => {
@@ -41,7 +42,7 @@ describe( 'Table feature – integration', () => {
 				content: parseView( '<td>bar</td>' )
 			} );
 
-			expect( formatTable( getModelData( editor.model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( editor.model ), modelTable( [
 				[ 'foobar[]' ]
 			] ) );
 		} );
@@ -53,7 +54,7 @@ describe( 'Table feature – integration', () => {
 				content: parseView( '<td>bar</td>' )
 			} );
 
-			expect( formatTable( getModelData( editor.model ) ) ).to.equal( '<paragraph>foobar[]</paragraph>' );
+			assertEqualMarkup( getModelData( editor.model ), '<paragraph>foobar[]</paragraph>' );
 		} );
 
 		it( 'pastes list into the td', () => {
@@ -63,7 +64,7 @@ describe( 'Table feature – integration', () => {
 				content: parseView( '<li>bar</li>' )
 			} );
 
-			expect( formatTable( getModelData( editor.model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( editor.model ), modelTable( [
 				[ '<listItem listIndent="0" listType="bulleted">bar[]</listItem>' ]
 			] ) );
 		} );
@@ -75,7 +76,7 @@ describe( 'Table feature – integration', () => {
 				content: parseView( '<blockquote>bar</blockquote>' )
 			} );
 
-			expect( formatTable( getModelData( editor.model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( editor.model ), modelTable( [
 				[ '<blockQuote><paragraph>bar[]</paragraph></blockQuote>' ]
 			] ) );
 		} );
@@ -168,7 +169,7 @@ describe( 'Table feature – integration', () => {
 
 			editor.execute( 'delete' );
 
-			expect( formatTable( getModelData( editor.model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( editor.model ), modelTable( [
 				[ '<blockQuote><paragraph>Foo[]Bar</paragraph></blockQuote>' ]
 			] ) );
 		} );

+ 44 - 0
packages/ckeditor5-table/tests/tablecellproperties.js

@@ -0,0 +1,44 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableEditing from '../src/tableediting';
+import TableCellProperties from '../src/tablecellproperties';
+import TableCellPropertiesEditing from '../src/tablecellproperties/tablecellpropertiesediting';
+
+describe( 'table cell properties', () => {
+	let editor, editorElement;
+
+	describe( 'TableCellProperties', () => {
+		beforeEach( async () => {
+			editorElement = global.document.createElement( 'div' );
+			global.document.body.appendChild( editorElement );
+
+			editor = await ClassicTestEditor.create( editorElement, {
+				plugins: [ TableCellProperties, Paragraph, TableEditing ]
+			} );
+		} );
+
+		afterEach( async () => {
+			editorElement.remove();
+			await editor.destroy();
+		} );
+
+		it( 'should be loaded', () => {
+			expect( editor.plugins.get( TableCellProperties ) ).to.instanceOf( TableCellProperties );
+		} );
+
+		it( 'should load TableCellPropertiesEditing plugin', () => {
+			expect( editor.plugins.get( TableCellPropertiesEditing ) ).to.instanceOf( TableCellPropertiesEditing );
+		} );
+
+		it( 'should have pluginName', () => {
+			expect( TableCellProperties.pluginName ).to.equal( 'TableCellProperties' );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellbackgroundcolorcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellBackgroundColorCommand from '../../../src/tablecellproperties/commands/tablecellbackgroundcolorcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellBackgroundColorCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellBackgroundColorCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no backgroundColor property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has backgroundColor property', () => {
+						setData( model, modelTable( [ [ { backgroundColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { backgroundColor: 'blue', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '#f00', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should change selected table cell backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ { backgroundColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should remove backgroundColor from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { backgroundColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should change selected table cell backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should remove backgroundColor from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellbordercolorcommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable, setTableCellWithObjectAttributes } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellBorderColorCommand from '../../../src/tablecellproperties/commands/tablecellbordercolorcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellBorderColorCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellBorderColorCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no borderColor property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has borderColor property (single string)', () => {
+						setData( model, modelTable( [ [ { borderColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+
+					it( 'should be set if selected table cell has borderColor property object with same values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderColor: {
+								top: 'blue',
+								right: 'blue',
+								bottom: 'blue',
+								left: 'blue'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( 'blue' );
+					} );
+
+					it( 'should be undefined if selected table cell has borderColor property object with different values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderColor: {
+								top: 'blue',
+								right: 'red',
+								bottom: 'blue',
+								left: 'blue'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { borderColor: 'blue', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '#f00', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should change selected table cell borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ { borderColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should remove borderColor from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { borderColor: 'blue', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should change selected table cell borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableCellStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should remove borderColor from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellborderstylecommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable, setTableCellWithObjectAttributes } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellBorderStyleCommand from '../../../src/tablecellproperties/commands/tablecellborderstylecommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellBorderStyleCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellBorderStyleCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no borderStyle property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has borderStyle property (single string)', () => {
+						setData( model, modelTable( [ [ { borderStyle: 'ridge', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+
+					it( 'should be set if selected table cell has borderStyle property object with same values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderStyle: {
+								top: 'ridge',
+								right: 'ridge',
+								bottom: 'ridge',
+								left: 'ridge'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+
+					it( 'should be undefined if selected table cell has borderStyle property object with different values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderStyle: {
+								top: 'ridge',
+								right: 'dashed',
+								bottom: 'ridge',
+								left: 'ridge'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { borderStyle: 'ridge', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: 'solid', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableCellStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should change selected table cell borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ { borderStyle: 'ridge', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableCellStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should remove borderStyle from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { borderStyle: 'ridge', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableCellStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should change selected table cell borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableCellStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should remove borderStyle from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellborderwidthcommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable, setTableCellWithObjectAttributes } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellBorderWidthCommand from '../../../src/tablecellproperties/commands/tablecellborderwidthcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellBorderWidthCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellBorderWidthCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no borderWidth property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has borderWidth property (single string)', () => {
+						setData( model, modelTable( [ [ { borderWidth: '2em', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be set if selected table cell has borderWidth property object with same values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderWidth: {
+								top: '2em',
+								right: '2em',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be undefined if selected table cell has borderWidth property object with different values', () => {
+						setTableCellWithObjectAttributes( model, {
+							borderWidth: {
+								top: '2em',
+								right: '333px',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { borderWidth: '2em', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '1px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableCellStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should change selected table cell borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ { borderWidth: '2em', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableCellStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should remove borderWidth from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { borderWidth: '2em', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableCellStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should change selected table cell borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableCellStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should remove borderWidth from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellheightcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellHeightCommand from '../../../src/tablecellproperties/commands/tablecellheightcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellHeightCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellHeightCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no height property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has height property', () => {
+						setData( model, modelTable( [ [ { height: '100px', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { height: '100px', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '25px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell height to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should change selected table cell height to a passed value', () => {
+						setData( model, modelTable( [ [ { height: '100px', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should remove height from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { height: '100px', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell height to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should change selected table cell height to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should remove height from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellhorizontalalignmentcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellHorizontalAlignmentCommand from '../../../src/tablecellproperties/commands/tablecellhorizontalalignmentcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellHorizontalAlignmentCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellHorizontalAlignmentCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no horizontalAlignment property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has horizontalAlignment property', () => {
+						setData( model, modelTable( [ [ { horizontalAlignment: 'center', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( 'center' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { horizontalAlignment: 'center', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( 'center' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: 'right', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell horizontalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableCellStyle( editor, 'text-align:right;' );
+					} );
+
+					it( 'should change selected table cell horizontalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ { horizontalAlignment: 'center', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableCellStyle( editor, 'text-align:right;' );
+					} );
+
+					it( 'should remove horizontalAlignment from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { horizontalAlignment: 'center', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell horizontalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableCellStyle( editor, 'text-align:right;' );
+					} );
+
+					it( 'should change selected table cell horizontalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableCellStyle( editor, 'text-align:right;' );
+					} );
+
+					it( 'should remove horizontalAlignment from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellpaddingcommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable, setTableCellWithObjectAttributes } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellPaddingCommand from '../../../src/tablecellproperties/commands/tablecellpaddingcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellPaddingCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellPaddingCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no padding property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has padding property (single string)', () => {
+						setData( model, modelTable( [ [ { padding: '2em', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be set if selected table cell has padding property object with same values', () => {
+						setTableCellWithObjectAttributes( model, {
+							padding: {
+								top: '2em',
+								right: '2em',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be undefined if selected table cell has padding property object with different values', () => {
+						setTableCellWithObjectAttributes( model, {
+							padding: {
+								top: '2em',
+								right: '1px',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { padding: '2em', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '0.5em', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell padding to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '0.5em' } );
+
+						assertTableCellStyle( editor, 'padding:0.5em;' );
+					} );
+
+					it( 'should change selected table cell padding to a passed value', () => {
+						setData( model, modelTable( [ [ { padding: '2em', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '0.5em' } );
+
+						assertTableCellStyle( editor, 'padding:0.5em;' );
+					} );
+
+					it( 'should remove padding from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { padding: '2em', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell padding to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '0.5em' } );
+
+						assertTableCellStyle( editor, 'padding:0.5em;' );
+					} );
+
+					it( 'should change selected table cell padding to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '0.5em' } );
+
+						assertTableCellStyle( editor, 'padding:0.5em;' );
+					} );
+
+					it( 'should remove padding from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellverticalalignmentcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellVerticalAlignmentCommand from '../../../src/tablecellproperties/commands/tablecellverticalalignmentcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellVerticalAlignmentCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellVerticalAlignmentCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no verticalAlignment property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has verticalAlignment property', () => {
+						setData( model, modelTable( [ [ { verticalAlignment: 'bottom', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( 'bottom' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { verticalAlignment: 'bottom', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( 'bottom' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: 'top', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell verticalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: 'top' } );
+
+						assertTableCellStyle( editor, 'vertical-align:top;' );
+					} );
+
+					it( 'should change selected table cell verticalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ { verticalAlignment: 'bottom', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: 'top' } );
+
+						assertTableCellStyle( editor, 'vertical-align:top;' );
+					} );
+
+					it( 'should remove verticalAlignment from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { verticalAlignment: 'bottom', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell verticalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'top' } );
+
+						assertTableCellStyle( editor, 'vertical-align:top;' );
+					} );
+
+					it( 'should change selected table cell verticalAlignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'top' } );
+
+						assertTableCellStyle( editor, 'vertical-align:top;' );
+					} );
+
+					it( 'should remove verticalAlignment from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tablecellproperties/commands/tablecellwidthcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableCellStyle, modelTable } from '../../_utils/utils';
+import TableCellPropertiesEditing from '../../../src/tablecellproperties/tablecellpropertiesediting';
+import TableCellWidthCommand from '../../../src/tablecellproperties/commands/tablecellwidthcommand';
+
+describe( 'table cell properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableCellWidthCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TableCellPropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableCellWidthCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table cell has no width property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table cell has width property', () => {
+						setData( model, modelTable( [ [ { width: '100px', contents: '[]foo' } ] ] ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table cell', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table cell', () => {
+						setData( model, modelTable( [ [ { width: '100px', contents: 'f[o]o' } ] ] ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '25px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table cell width to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should change selected table cell width to a passed value', () => {
+						setData( model, modelTable( [ [ { width: '100px', contents: '[]foo' } ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should remove width from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ { width: '100px', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table cell width to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should change selected table cell width to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableCellStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should remove width from a selected table cell if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableCellStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 828 - 0
packages/ckeditor5-table/tests/tablecellproperties/tablecellpropertiesediting.js

@@ -0,0 +1,828 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableEditing from '../../src/tableediting';
+import TableCellPropertiesEditing from '../../src/tablecellproperties/tablecellpropertiesediting';
+
+import TableCellBorderColorCommand from '../../src/tablecellproperties/commands/tablecellbordercolorcommand';
+import TableCellBorderStyleCommand from '../../src/tablecellproperties/commands/tablecellborderstylecommand';
+import TableCellBorderWidthCommand from '../../src/tablecellproperties/commands/tablecellborderwidthcommand';
+import TableCellHorizontalAlignmentCommand from '../../src/tablecellproperties/commands/tablecellhorizontalalignmentcommand';
+import TableCellWidthCommand from '../../src/tablecellproperties/commands/tablecellwidthcommand';
+import TableCellHeightCommand from '../../src/tablecellproperties/commands/tablecellheightcommand';
+import TableCellVerticalAlignmentCommand from '../../src/tablecellproperties/commands/tablecellverticalalignmentcommand';
+import TableCellPaddingCommand from '../../src/tablecellproperties/commands/tablecellpaddingcommand';
+import TableCellBackgroundColorCommand from '../../src/tablecellproperties/commands/tablecellbackgroundcolorcommand';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { assertTableCellStyle, assertTRBLAttribute } from '../_utils/utils';
+
+describe( 'table cell properties', () => {
+	describe( 'TableCellPropertiesEditing', () => {
+		let editor, model;
+
+		beforeEach( async () => {
+			editor = await VirtualTestEditor.create( {
+				plugins: [ TableCellPropertiesEditing, Paragraph, TableEditing ]
+			} );
+
+			model = editor.model;
+		} );
+
+		afterEach( async () => {
+			await editor.destroy();
+		} );
+
+		it( 'should have pluginName', () => {
+			expect( TableCellPropertiesEditing.pluginName ).to.equal( 'TableCellPropertiesEditing' );
+		} );
+
+		it( 'adds tableCellBorderColor command', () => {
+			expect( editor.commands.get( 'tableCellBorderColor' ) ).to.be.instanceOf( TableCellBorderColorCommand );
+		} );
+
+		it( 'adds tableCellBorderStyle command', () => {
+			expect( editor.commands.get( 'tableCellBorderStyle' ) ).to.be.instanceOf( TableCellBorderStyleCommand );
+		} );
+
+		it( 'adds tableCellBorderWidth command', () => {
+			expect( editor.commands.get( 'tableCellBorderWidth' ) ).to.be.instanceOf( TableCellBorderWidthCommand );
+		} );
+
+		it( 'adds tableCellAlignment command', () => {
+			expect( editor.commands.get( 'tableCellHorizontalAlignment' ) ).to.be.instanceOf( TableCellHorizontalAlignmentCommand );
+		} );
+
+		it( 'adds tableCellAlignment command', () => {
+			expect( editor.commands.get( 'tableCellVerticalAlignment' ) ).to.be.instanceOf( TableCellVerticalAlignmentCommand );
+		} );
+
+		it( 'adds tableCellPadding command', () => {
+			expect( editor.commands.get( 'tableCellPadding' ) ).to.be.instanceOf( TableCellPaddingCommand );
+		} );
+
+		it( 'adds tableCellBackgroundColor command', () => {
+			expect( editor.commands.get( 'tableCellBackgroundColor' ) ).to.be.instanceOf( TableCellBackgroundColorCommand );
+		} );
+
+		it( 'adds tableCellWidth command', () => {
+			expect( editor.commands.get( 'tableCellWidth' ) ).to.be.instanceOf( TableCellWidthCommand );
+		} );
+
+		it( 'adds tableCellHeight command', () => {
+			expect( editor.commands.get( 'tableCellHeight' ) ).to.be.instanceOf( TableCellHeightCommand );
+		} );
+
+		describe( 'border', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'borderColor' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'borderStyle' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'borderWidth' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast border shorthand', () => {
+					editor.setData( '<table><tr><td style="border:1px solid #f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', '#f00' );
+					assertTRBLAttribute( tableCell, 'borderStyle', 'solid' );
+					assertTRBLAttribute( tableCell, 'borderWidth', '1px' );
+				} );
+
+				it( 'should upcast border-color shorthand', () => {
+					editor.setData( '<table><tr><td style="border-color:#f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', '#f00' );
+				} );
+
+				it( 'should upcast border-style shorthand', () => {
+					editor.setData( '<table><tr><td style="border-style:ridge">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderStyle', 'ridge' );
+				} );
+
+				it( 'should upcast border-width shorthand', () => {
+					editor.setData( '<table><tr><td style="border-width:1px">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderWidth', '1px' );
+				} );
+
+				it( 'should upcast border-top shorthand', () => {
+					editor.setData( '<table><tr><td style="border-top:1px solid #f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', '#f00', null, null, null );
+					assertTRBLAttribute( tableCell, 'borderStyle', 'solid', null, null, null );
+					assertTRBLAttribute( tableCell, 'borderWidth', '1px', null, null, null );
+				} );
+
+				it( 'should upcast border-right shorthand', () => {
+					editor.setData( '<table><tr><td style="border-right:1px solid #f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, '#f00', null, null );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, 'solid', null, null );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, '1px', null, null );
+				} );
+
+				it( 'should upcast border-bottom shorthand', () => {
+					editor.setData( '<table><tr><td style="border-bottom:1px solid #f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, null, '#f00', null );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, null, 'solid', null );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, null, '1px', null );
+				} );
+
+				it( 'should upcast border-left shorthand', () => {
+					editor.setData( '<table><tr><td style="border-left:1px solid #f00">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, null, null, '#f00' );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, null, null, 'solid' );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, null, null, '1px' );
+				} );
+
+				it( 'should upcast mixed shorthands', () => {
+					editor.setData(
+						'<table><tr><td style="border-top:1px solid #f00;border-bottom:2em ridge rgba(255,0,0,1)">foo</td></tr></table>'
+					);
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', '#f00', null, 'rgba(255, 0, 0, 1)', null );
+					assertTRBLAttribute( tableCell, 'borderStyle', 'solid', null, 'ridge', null );
+					assertTRBLAttribute( tableCell, 'borderWidth', '1px', null, '2em', null );
+				} );
+
+				it( 'should upcast border-top-* styles', () => {
+					editor.setData(
+						'<table><tr><td style="border-top-width:1px;border-top-style:solid;border-top-color:#f00">foo</td></tr></table>'
+					);
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', '#f00', null, null, null );
+					assertTRBLAttribute( tableCell, 'borderStyle', 'solid', null, null, null );
+					assertTRBLAttribute( tableCell, 'borderWidth', '1px', null, null, null );
+				} );
+
+				it( 'should upcast border-right-* styles', () => {
+					editor.setData(
+						'<table>' +
+							'<tr>' +
+								'<td style="border-right-width:1px;border-right-style:solid;border-right-color:#f00">foo</td>' +
+							'</tr>' +
+						'</table>'
+					);
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, '#f00', null, null );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, 'solid', null, null );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, '1px', null, null );
+				} );
+
+				it( 'should upcast border-bottom-* styles', () => {
+					editor.setData(
+						'<table>' +
+							'<tr>' +
+								'<td style="border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#f00">foo</td>' +
+							'</tr>' +
+						'</table>'
+					);
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, null, '#f00', null );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, null, 'solid', null );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, null, '1px', null );
+				} );
+
+				it( 'should upcast border-left-* styles', () => {
+					editor.setData(
+						'<table>' +
+							'<tr>' +
+								'<td style="border-left-width:1px;border-left-style:solid;border-left-color:#f00">foo</td>' +
+							'</tr>' +
+						'</table>'
+					);
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'borderColor', null, null, null, '#f00' );
+					assertTRBLAttribute( tableCell, 'borderStyle', null, null, null, 'solid' );
+					assertTRBLAttribute( tableCell, 'borderWidth', null, null, null, '1px' );
+				} );
+
+				it( 'should allow to be overriden (only border-top consumed)', () => {
+					editor.conversion.for( 'upcast' ).add( dispatcher => dispatcher.on( 'element:td', ( evt, data, conversionApi ) => {
+						conversionApi.consumable.consume( data.viewItem, {
+							styles: [ 'border-top' ]
+						} );
+					}, { priority: 'high' } ) );
+
+					editor.setData( '<table><tr><td style="border:1px solid blue;">foo</td></tr></table>' );
+
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'borderColor' ) ).to.be.undefined;
+					expect( tableCell.getAttribute( 'borderStyle' ) ).to.be.undefined;
+					expect( tableCell.getAttribute( 'borderWidth' ) ).to.be.undefined;
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast borderColor attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderColor', {
+						top: '#f00',
+						right: '#f00',
+						bottom: '#f00',
+						left: '#f00'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+				} );
+
+				it( 'should downcast borderColor attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderColor', {
+						top: '#f00',
+						right: 'hsla(0, 100%, 50%, 0.5)',
+						bottom: 'deeppink',
+						left: 'rgb(255, 0, 0)'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor,
+						'border-bottom:deeppink;' +
+						'border-left:rgb(255, 0, 0);' +
+						'border-right:hsla(0, 100%, 50%, 0.5);' +
+						'border-top:#f00;'
+					);
+				} );
+
+				it( 'should downcast borderStyle attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderStyle', {
+						top: 'solid',
+						right: 'solid',
+						bottom: 'solid',
+						left: 'solid'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+				} );
+
+				it( 'should downcast borderStyle attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderStyle', {
+						top: 'solid',
+						right: 'ridge',
+						bottom: 'dotted',
+						left: 'dashed'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'border-bottom:dotted;border-left:dashed;border-right:ridge;border-top:solid;' );
+				} );
+
+				it( 'should downcast borderWidth attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderWidth', {
+						top: '42px',
+						right: '.1em',
+						bottom: '1337rem',
+						left: 'thick'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'border-bottom:1337rem;border-left:thick;border-right:.1em;border-top:42px;' );
+				} );
+
+				it( 'should downcast borderWidth attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderWidth', {
+						top: '42px',
+						right: '42px',
+						bottom: '42px',
+						left: '42px'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'border-bottom:42px;border-left:42px;border-right:42px;border-top:42px;' );
+				} );
+
+				it( 'should downcast borderColor, borderStyle and borderWidth attributes together (same top, right, bottom, left)', () => {
+					model.change( writer => {
+						writer.setAttribute( 'borderColor', {
+							top: '#f00',
+							right: '#f00',
+							bottom: '#f00',
+							left: '#f00'
+						}, tableCell );
+
+						writer.setAttribute( 'borderStyle', {
+							top: 'solid',
+							right: 'solid',
+							bottom: 'solid',
+							left: 'solid'
+						}, tableCell );
+
+						writer.setAttribute( 'borderWidth', {
+							top: '42px',
+							right: '42px',
+							bottom: '42px',
+							left: '42px'
+						}, tableCell );
+					} );
+
+					assertTableCellStyle( editor,
+						'border-bottom:42px solid #f00;' +
+						'border-left:42px solid #f00;' +
+						'border-right:42px solid #f00;' +
+						'border-top:42px solid #f00;'
+					);
+				} );
+
+				it(
+					'should downcast borderColor, borderStyle and borderWidth attributes together (different top, right, bottom, left)',
+					() => {
+						model.change( writer => {
+							writer.setAttribute( 'borderColor', {
+								top: '#f00',
+								right: 'hsla(0, 100%, 50%, 0.5)',
+								bottom: 'deeppink',
+								left: 'rgb(255, 0, 0)'
+							}, tableCell );
+
+							writer.setAttribute( 'borderStyle', {
+								top: 'solid',
+								right: 'ridge',
+								bottom: 'dotted',
+								left: 'dashed'
+							}, tableCell );
+
+							writer.setAttribute( 'borderWidth', {
+								top: '42px',
+								right: '.1em',
+								bottom: '1337rem',
+								left: 'thick'
+							}, tableCell );
+						} );
+
+						assertTableCellStyle( editor,
+							'border-bottom:1337rem dotted deeppink;' +
+							'border-left:thick dashed rgb(255, 0, 0);' +
+							'border-right:.1em ridge hsla(0, 100%, 50%, 0.5);' +
+							'border-top:42px solid #f00;'
+						);
+					}
+				);
+
+				describe( 'change attribute', () => {
+					beforeEach( () => {
+						model.change( writer => {
+							writer.setAttribute( 'borderColor', {
+								top: '#f00',
+								right: '#f00',
+								bottom: '#f00',
+								left: '#f00'
+							}, tableCell );
+
+							writer.setAttribute( 'borderStyle', {
+								top: 'solid',
+								right: 'solid',
+								bottom: 'solid',
+								left: 'solid'
+							}, tableCell );
+
+							writer.setAttribute( 'borderWidth', {
+								top: '42px',
+								right: '42px',
+								bottom: '42px',
+								left: '42px'
+							}, tableCell );
+						} );
+					} );
+
+					it( 'should downcast borderColor attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderColor', {
+							top: 'deeppink',
+							right: 'deeppink',
+							bottom: 'deeppink',
+							left: 'deeppink'
+						}, tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:42px solid deeppink;' +
+							'border-left:42px solid deeppink;' +
+							'border-right:42px solid deeppink;' +
+							'border-top:42px solid deeppink;'
+						);
+					} );
+
+					it( 'should downcast borderStyle attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderStyle', {
+							top: 'ridge',
+							right: 'ridge',
+							bottom: 'ridge',
+							left: 'ridge'
+						}, tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:42px ridge #f00;' +
+							'border-left:42px ridge #f00;' +
+							'border-right:42px ridge #f00;' +
+							'border-top:42px ridge #f00;'
+						);
+					} );
+
+					it( 'should downcast borderWidth attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderWidth', {
+							top: 'thick',
+							right: 'thick',
+							bottom: 'thick',
+							left: 'thick'
+						}, tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:thick solid #f00;' +
+							'border-left:thick solid #f00;' +
+							'border-right:thick solid #f00;' +
+							'border-top:thick solid #f00;'
+						);
+					} );
+
+					it( 'should downcast borderColor attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderColor', tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:42px solid;' +
+							'border-left:42px solid;' +
+							'border-right:42px solid;' +
+							'border-top:42px solid;'
+						);
+					} );
+
+					it( 'should downcast borderStyle attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderStyle', tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:42px #f00;' +
+							'border-left:42px #f00;' +
+							'border-right:42px #f00;' +
+							'border-top:42px #f00;'
+						);
+					} );
+
+					it( 'should downcast borderWidth attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderWidth', tableCell ) );
+
+						assertTableCellStyle( editor,
+							'border-bottom:solid #f00;' +
+							'border-left:solid #f00;' +
+							'border-right:solid #f00;' +
+							'border-top:solid #f00;'
+						);
+					} );
+
+					it( 'should downcast borderColor, borderStyle and borderWidth attributes removal', () => {
+						model.change( writer => {
+							writer.removeAttribute( 'borderColor', tableCell );
+							writer.removeAttribute( 'borderStyle', tableCell );
+							writer.removeAttribute( 'borderWidth', tableCell );
+						} );
+
+						assertEqualMarkup(
+							editor.getData(),
+							'<figure class="table"><table><tbody><tr><td>foo</td></tr></tbody></table></figure>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'background color', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'backgroundColor' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast background-color', () => {
+					editor.setData( '<table><tr><td style="background-color:#f00">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'backgroundColor' ) ).to.equal( '#f00' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast backgroundColor', () => {
+					model.change( writer => writer.setAttribute( 'backgroundColor', '#f00', tableCell ) );
+
+					assertTableCellStyle( editor, 'background-color:#f00;' );
+				} );
+			} );
+		} );
+
+		describe( 'horizontal alignment', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'horizontalAlignment' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast text-align:left style', () => {
+					editor.setData( '<table><tr><td style="text-align:left">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'horizontalAlignment' ) ).to.equal( 'left' );
+				} );
+
+				it( 'should upcast text-align:right style', () => {
+					editor.setData( '<table><tr><td style="text-align:right">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'horizontalAlignment' ) ).to.equal( 'right' );
+				} );
+
+				it( 'should upcast text-align:center style', () => {
+					editor.setData( '<table><tr><td style="text-align:center">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'horizontalAlignment' ) ).to.equal( 'center' );
+				} );
+
+				it( 'should upcast text-align:justify style', () => {
+					editor.setData( '<table><tr><td style="text-align:justify">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'horizontalAlignment' ) ).to.equal( 'justify' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast horizontalAlignment=left', () => {
+					model.change( writer => writer.setAttribute( 'horizontalAlignment', 'left', tableCell ) );
+
+					assertTableCellStyle( editor, 'text-align:left;' );
+				} );
+
+				it( 'should downcast horizontalAlignment=right', () => {
+					model.change( writer => writer.setAttribute( 'horizontalAlignment', 'right', tableCell ) );
+
+					assertTableCellStyle( editor, 'text-align:right;' );
+				} );
+
+				it( 'should downcast horizontalAlignment=center', () => {
+					model.change( writer => writer.setAttribute( 'horizontalAlignment', 'center', tableCell ) );
+
+					assertTableCellStyle( editor, 'text-align:center;' );
+				} );
+
+				it( 'should downcast horizontalAlignment=justify', () => {
+					model.change( writer => writer.setAttribute( 'horizontalAlignment', 'justify', tableCell ) );
+
+					assertTableCellStyle( editor, 'text-align:justify;' );
+				} );
+			} );
+		} );
+
+		describe( 'vertical alignment', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'verticalAlignment' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast vertical-align', () => {
+					editor.setData( '<table><tr><td style="vertical-align:top">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'verticalAlignment' ) ).to.equal( 'top' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast verticalAlignment', () => {
+					model.change( writer => writer.setAttribute( 'verticalAlignment', 'middle', tableCell ) );
+
+					assertTableCellStyle( editor, 'vertical-align:middle;' );
+				} );
+			} );
+		} );
+
+		describe( 'padding', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'padding' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast padding shorthand', () => {
+					editor.setData( '<table><tr><td style="padding:2px 4em">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					assertTRBLAttribute( tableCell, 'padding', '2px', '4em' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast padding (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'padding', {
+						top: '2px',
+						right: '2px',
+						bottom: '2px',
+						left: '2px'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'padding:2px;' );
+				} );
+
+				it( 'should downcast padding (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'padding', {
+						top: '2px',
+						right: '3px',
+						bottom: '4px',
+						left: '5px'
+					}, tableCell ) );
+
+					assertTableCellStyle( editor, 'padding:2px 3px 4px 5px;' );
+				} );
+			} );
+		} );
+
+		describe( 'cell width', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'width' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast width attribute on table cell', () => {
+					editor.setData( '<table><tr><td style="width:20px">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'width' ) ).to.equal( '20px' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast width attribute', () => {
+					model.change( writer => writer.setAttribute( 'width', '20px', tableCell ) );
+
+					assertEqualMarkup(
+						editor.getData(),
+						'<figure class="table"><table><tbody><tr><td style="width:20px;">foo</td></tr></tbody></table></figure>'
+					);
+				} );
+			} );
+		} );
+
+		describe( 'cell height', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'tableCell' ], 'height' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast height attribute on table cell', () => {
+					editor.setData( '<table><tr><td style="height:20px">foo</td></tr></table>' );
+					const tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+
+					expect( tableCell.getAttribute( 'height' ) ).to.equal( '20px' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let tableCell;
+
+				beforeEach( () => {
+					setModelData(
+						model,
+						'<table headingRows="0" headingColumns="0">' +
+							'<tableRow>' +
+								'<tableCell>' +
+									'<paragraph>foo</paragraph>' +
+								'</tableCell>' +
+							'</tableRow>' +
+						'</table>'
+					);
+
+					tableCell = model.document.getRoot().getNodeByPath( [ 0, 0, 0 ] );
+				} );
+
+				it( 'should downcast height attribute', () => {
+					model.change( writer => writer.setAttribute( 'height', '20px', tableCell ) );
+
+					assertEqualMarkup(
+						editor.getData(),
+						'<figure class="table"><table><tbody><tr><td style="height:20px;">foo</td></tr></tbody></table></figure>'
+					);
+				} );
+			} );
+		} );
+	} );
+} );

+ 29 - 28
packages/ckeditor5-table/tests/tableediting.js

@@ -10,7 +10,7 @@ import { getCode } from '@ckeditor/ckeditor5-utils/src/keyboard';
 import ImageEditing from '@ckeditor/ckeditor5-image/src/image/imageediting';
 
 import TableEditing from '../src/tableediting';
-import { formatTable, formattedModelTable, modelTable } from './_utils/utils';
+import { modelTable } from './_utils/utils';
 import InsertRowCommand from '../src/commands/insertrowcommand';
 import InsertTableCommand from '../src/commands/inserttablecommand';
 import InsertColumnCommand from '../src/commands/insertcolumncommand';
@@ -21,6 +21,7 @@ import MergeCellCommand from '../src/commands/mergecellcommand';
 import SetHeaderRowCommand from '../src/commands/setheaderrowcommand';
 import SetHeaderColumnCommand from '../src/commands/setheadercolumncommand';
 import MediaEmbedEditing from '@ckeditor/ckeditor5-media-embed/src/mediaembedediting';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'TableEditing', () => {
 	let editor, model;
@@ -235,7 +236,7 @@ describe( 'TableEditing', () => {
 
 			sinon.assert.notCalled( domEvtDataStub.preventDefault );
 			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
-			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model ), modelTable( [
 				[ '11', '12[]' ]
 			] ) );
 		} );
@@ -251,7 +252,7 @@ describe( 'TableEditing', () => {
 
 			sinon.assert.notCalled( domEvtDataStub.preventDefault );
 			sinon.assert.notCalled( domEvtDataStub.stopPropagation );
-			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model ), modelTable( [
 				[ '11', '12[]' ]
 			] ) );
 		} );
@@ -266,7 +267,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.notCalled( domEvtDataStub.preventDefault );
 				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), '[]' + modelTable( [
 					[ '11', '12' ]
 				] ) );
 			} );
@@ -280,7 +281,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '[12]' ]
 				] ) );
 			} );
@@ -292,7 +293,7 @@ describe( 'TableEditing', () => {
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '12' ],
 					[ '[]', '' ]
 				] ) );
@@ -309,7 +310,7 @@ describe( 'TableEditing', () => {
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '12[]' ]
 				] ) );
 			} );
@@ -322,7 +323,7 @@ describe( 'TableEditing', () => {
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '12' ],
 					[ '[21]', '22' ]
 				] ) );
@@ -330,17 +331,17 @@ describe( 'TableEditing', () => {
 
 			it( 'should move to the next table cell if part of block content is selected', () => {
 				setModelData( model, modelTable( [
-					[ '11', '<paragraph>12</paragraph><paragraph>[foo]</paragraph><paragraph>bar</paragraph>', '13' ],
+					[ '11', '<paragraph>12</paragraph><paragraph>[foo]</paragraph><paragraph>bar</paragraph>', '13' ]
 				] ) );
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[
 						'11',
 						'<paragraph>12</paragraph><paragraph>foo</paragraph><paragraph>bar</paragraph>',
 						'[13]'
-					],
+					]
 				] ) );
 			} );
 
@@ -353,7 +354,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '<paragraph>[foo</paragraph><image></image>]' ]
 				] ) );
 			} );
@@ -373,7 +374,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '<blockQuote><paragraph>[foo]</paragraph></blockQuote>' ]
 				] ) );
 			} );
@@ -391,7 +392,7 @@ describe( 'TableEditing', () => {
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11[]', '12' ]
 				] ) );
 			} );
@@ -421,7 +422,7 @@ describe( 'TableEditing', () => {
 					sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 					sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 
-					expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+					assertEqualMarkup( getModelData( model ), modelTable( [
 						[ '[11]', '12' ]
 					] ) );
 
@@ -441,7 +442,7 @@ describe( 'TableEditing', () => {
 					sinon.assert.notCalled( domEvtDataStub.preventDefault );
 					sinon.assert.notCalled( domEvtDataStub.stopPropagation );
 
-					expect( formatTable( getModelData( model ) ) ).to.equal( '[<block>foo</block>]' );
+					assertEqualMarkup( getModelData( model ), '[<block>foo</block>]' );
 
 					// Should not cancel event.
 					sinon.assert.calledOnce( spy );
@@ -466,7 +467,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.notCalled( domEvtDataStub.preventDefault );
 				sinon.assert.notCalled( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( '[]' + formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), '[]' + modelTable( [
 					[ '11', '12' ]
 				] ) );
 			} );
@@ -481,7 +482,7 @@ describe( 'TableEditing', () => {
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '[11]', '12' ]
 				] ) );
 			} );
@@ -493,8 +494,8 @@ describe( 'TableEditing', () => {
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal(
-					'<paragraph>foo</paragraph>' + formattedModelTable( [ [ '[]11', '12' ] ] )
+				assertEqualMarkup( getModelData( model ),
+					'<paragraph>foo</paragraph>' + modelTable( [ [ '[]11', '12' ] ] )
 				);
 			} );
 
@@ -506,7 +507,7 @@ describe( 'TableEditing', () => {
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '11', '[12]' ],
 					[ '21', '22' ]
 				] ) );
@@ -514,17 +515,17 @@ describe( 'TableEditing', () => {
 
 			it( 'should move to the previous table cell if part of block content is selected', () => {
 				setModelData( model, modelTable( [
-					[ '11', '<paragraph>12</paragraph><paragraph>[foo]</paragraph><paragraph>bar</paragraph>', '13' ],
+					[ '11', '<paragraph>12</paragraph><paragraph>[foo]</paragraph><paragraph>bar</paragraph>', '13' ]
 				] ) );
 
 				editor.editing.view.document.fire( 'keydown', domEvtDataStub );
 
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[
 						'[11]',
 						'<paragraph>12</paragraph><paragraph>foo</paragraph><paragraph>bar</paragraph>',
 						'13'
-					],
+					]
 				] ) );
 			} );
 
@@ -537,7 +538,7 @@ describe( 'TableEditing', () => {
 
 				sinon.assert.calledOnce( domEvtDataStub.preventDefault );
 				sinon.assert.calledOnce( domEvtDataStub.stopPropagation );
-				expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+				assertEqualMarkup( getModelData( model ), modelTable( [
 					[ '<paragraph>[foo</paragraph><image></image>]', 'bar' ]
 				] ) );
 			} );
@@ -574,7 +575,7 @@ describe( 'TableEditing', () => {
 			viewDocument.fire( 'enter', evtDataStub );
 
 			sinon.assert.notCalled( editor.execute );
-			expect( formatTable( getModelData( model ) ) ).to.equal( '<paragraph>[]foo</paragraph>' );
+			assertEqualMarkup( getModelData( model ), '<paragraph>[]foo</paragraph>' );
 		} );
 
 		it( 'should do nothing if table cell has already a block content', () => {
@@ -585,7 +586,7 @@ describe( 'TableEditing', () => {
 			viewDocument.fire( 'enter', evtDataStub );
 
 			sinon.assert.notCalled( editor.execute );
-			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model ), modelTable( [
 				[ '<paragraph>[]11</paragraph>' ]
 			] ) );
 		} );
@@ -612,7 +613,7 @@ describe( 'TableEditing', () => {
 			viewDocument.fire( 'enter', evtDataStub );
 
 			sinon.assert.notCalled( editor.execute );
-			expect( formatTable( getModelData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getModelData( model ), modelTable( [
 				[ '[]11' ]
 			] ) );
 		} );

+ 44 - 0
packages/ckeditor5-table/tests/tableproperties.js

@@ -0,0 +1,44 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import global from '@ckeditor/ckeditor5-utils/src/dom/global';
+import ClassicTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/classictesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableEditing from '../src/tableediting';
+import TableProperties from '../src/tableproperties';
+import TablePropertiesEditing from '../src/tableproperties/tablepropertiesediting';
+
+describe( 'table properties', () => {
+	describe( 'TableProperties', () => {
+		let editor, editorElement;
+
+		beforeEach( async () => {
+			editorElement = global.document.createElement( 'div' );
+			global.document.body.appendChild( editorElement );
+
+			editor = await ClassicTestEditor.create( editorElement, {
+				plugins: [ TableProperties, Paragraph, TableEditing ]
+			} );
+		} );
+
+		afterEach( async () => {
+			editorElement.remove();
+			await editor.destroy();
+		} );
+
+		it( 'should be loaded', () => {
+			expect( editor.plugins.get( TableProperties ) ).to.instanceOf( TableProperties );
+		} );
+
+		it( 'should load TablePropertiesEditing plugin', () => {
+			expect( editor.plugins.get( TablePropertiesEditing ) ).to.instanceOf( TablePropertiesEditing );
+		} );
+
+		it( 'should have pluginName', () => {
+			expect( TableProperties.pluginName ).to.equal( 'TableProperties' );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tablealignmentcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableHorizontalAlignmentCommand from '../../../src/tableproperties/commands/tablealignmentcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableHorizontalAlignmentCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableHorizontalAlignmentCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no alignment property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has alignment property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { alignment: 'center' } ) );
+
+						expect( command.value ).to.equal( 'center' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { alignment: 'center' } ) );
+
+						expect( command.value ).to.equal( 'center' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: 'right', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table alignment to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+					} );
+
+					it( 'should change selected table alignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { alignment: 'center' } ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+					} );
+
+					it( 'should remove alignment from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { alignment: 'center' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table alignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+					} );
+
+					it( 'should change selected table alignment to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'right' } );
+
+						assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+					} );
+
+					it( 'should remove alignment from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tablebackgroundcolorcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableBackgroundColorCommand from '../../../src/tableproperties/commands/tablebackgroundcolorcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableBackgroundColorCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableBackgroundColorCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no backgroundColor property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has backgroundColor property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { backgroundColor: 'blue' } ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { backgroundColor: 'blue' } ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '#f00', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should change selected table backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { backgroundColor: 'blue' } ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should remove backgroundColor from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { backgroundColor: 'blue' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should change selected table backgroundColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'background-color:#f00;' );
+					} );
+
+					it( 'should remove backgroundColor from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tablebordercolorcommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable, setTableWithObjectAttributes } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableBorderColorCommand from '../../../src/tableproperties/commands/tablebordercolorcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableBorderColorCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableBorderColorCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no borderColor property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has borderColor property (single string)', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderColor: 'blue' } ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+
+					it( 'should be set if selected table has borderColor property object with same values', () => {
+						setTableWithObjectAttributes( model, {
+							borderColor: {
+								top: 'blue',
+								right: 'blue',
+								bottom: 'blue',
+								left: 'blue'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( 'blue' );
+					} );
+
+					it( 'should be undefined if selected table has borderColor property object with different values', () => {
+						setTableWithObjectAttributes( model, {
+							borderColor: {
+								top: 'blue',
+								right: 'red',
+								bottom: 'blue',
+								left: 'blue'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { borderColor: 'blue' } ) );
+
+						expect( command.value ).to.equal( 'blue' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '#f00', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should change selected table borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderColor: 'blue' } ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should remove borderColor from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderColor: 'blue' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should change selected table borderColor to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '#f00' } );
+
+						assertTableStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+					} );
+
+					it( 'should remove borderColor from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tableborderstylecommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable, setTableWithObjectAttributes } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableBorderStyleCommand from '../../../src/tableproperties/commands/tableborderstylecommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableBorderStyleCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableBorderStyleCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no borderStyle property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has borderStyle property (single string)', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderStyle: 'ridge' } ) );
+
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+
+					it( 'should be set if selected table has borderStyle property object with same values', () => {
+						setTableWithObjectAttributes( model, {
+							borderStyle: {
+								top: 'ridge',
+								right: 'ridge',
+								bottom: 'ridge',
+								left: 'ridge'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+
+					it( 'should be undefined if selected table has borderStyle property object with different values', () => {
+						setTableWithObjectAttributes( model, {
+							borderStyle: {
+								top: 'ridge',
+								right: 'dashed',
+								bottom: 'ridge',
+								left: 'ridge'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { borderStyle: 'ridge' } ) );
+
+						expect( command.value ).to.equal( 'ridge' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: 'solid', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should change selected table borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderStyle: 'ridge' } ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should remove borderStyle from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderStyle: 'ridge' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should change selected table borderStyle to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: 'solid' } );
+
+						assertTableStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+					} );
+
+					it( 'should remove borderStyle from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 178 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tableborderwidthcommand.js

@@ -0,0 +1,178 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable, setTableWithObjectAttributes } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableBorderWidthCommand from '../../../src/tableproperties/commands/tableborderwidthcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableBorderWidthCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableBorderWidthCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no borderWidth property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has borderWidth property (single string)', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderWidth: '2em' } ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be set if selected table has borderWidth property object with same values', () => {
+						setTableWithObjectAttributes( model, {
+							borderWidth: {
+								top: '2em',
+								right: '2em',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+						expect( command.value ).to.equal( '2em' );
+					} );
+
+					it( 'should be undefined if selected table has borderWidth property object with different values', () => {
+						setTableWithObjectAttributes( model, {
+							borderWidth: {
+								top: '2em',
+								right: '333px',
+								bottom: '2em',
+								left: '2em'
+							}
+						}, '[]foo' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { borderWidth: '2em' } ) );
+
+						expect( command.value ).to.equal( '2em' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '1px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should change selected table borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderWidth: '2em' } ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should remove borderWidth from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { borderWidth: '2em' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should change selected table borderWidth to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '1px' } );
+
+						assertTableStyle( editor, 'border-bottom:1px;border-left:1px;border-right:1px;border-top:1px;' );
+					} );
+
+					it( 'should remove borderWidth from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tableheightcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableHeightCommand from '../../../src/tableproperties/commands/tableheightcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableHeightCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableHeightCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no height property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has height property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { height: '100px' } ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { height: '100px' } ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '25px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table height to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should change selected table height to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { height: '100px' } ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should remove height from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ { height: '100px', contents: '[]foo' } ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table height to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should change selected table height to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'height:25px;' );
+					} );
+
+					it( 'should remove height from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 153 - 0
packages/ckeditor5-table/tests/tableproperties/commands/tablewidthcommand.js

@@ -0,0 +1,153 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import { setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+
+import { assertTableStyle, modelTable } from '../../_utils/utils';
+import TablePropertiesEditing from '../../../src/tableproperties/tablepropertiesediting';
+import TableWidthCommand from '../../../src/tableproperties/commands/tablewidthcommand';
+
+describe( 'table properties', () => {
+	describe( 'commands', () => {
+		describe( 'TableWidthCommand', () => {
+			let editor, model, command;
+
+			beforeEach( async () => {
+				editor = await ModelTestEditor.create( {
+					plugins: [ Paragraph, TablePropertiesEditing ]
+				} );
+
+				model = editor.model;
+				command = new TableWidthCommand( editor );
+			} );
+
+			afterEach( () => {
+				return editor.destroy();
+			} );
+
+			describe( 'isEnabled', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>foo[]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+						expect( command.isEnabled ).to.be.false;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ] ) );
+						expect( command.isEnabled ).to.be.true;
+					} );
+				} );
+			} );
+
+			describe( 'value', () => {
+				describe( 'collapsed selection', () => {
+					it( 'should be undefined if selected table has no width property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ] ) );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be set if selected table has width property', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { width: '100px' } ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should be false if selection does not have table', () => {
+						setData( model, '<paragraph>f[oo]</paragraph>' );
+
+						expect( command.value ).to.be.undefined;
+					} );
+
+					it( 'should be true is selection has table', () => {
+						setData( model, modelTable( [ [ 'f[o]o' ] ], { width: '100px' } ) );
+
+						expect( command.value ).to.equal( '100px' );
+					} );
+				} );
+			} );
+
+			describe( 'execute()', () => {
+				it( 'should use provided batch', () => {
+					setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+					const batch = model.createBatch();
+					const spy = sinon.spy( model, 'enqueueChange' );
+
+					command.execute( { value: '25px', batch } );
+					sinon.assert.calledWith( spy, batch );
+				} );
+
+				describe( 'collapsed selection', () => {
+					it( 'should set selected table width to a passed value', () => {
+						setData( model, modelTable( [ [ 'foo[]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should change selected table width to a passed value', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { width: '100px' } ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should remove width from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[]foo' ] ], { width: '100px' } ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+
+				describe( 'non-collapsed selection', () => {
+					it( 'should set selected table width to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should change selected table width to a passed value', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute( { value: '25px' } );
+
+						assertTableStyle( editor, 'width:25px;' );
+					} );
+
+					it( 'should remove width from a selected table if no value is passed', () => {
+						setData( model, modelTable( [ [ '[foo]' ] ] ) );
+
+						command.execute();
+
+						assertTableStyle( editor, '' );
+					} );
+				} );
+			} );
+		} );
+	} );
+} );

+ 716 - 0
packages/ckeditor5-table/tests/tableproperties/tablepropertiesediting.js

@@ -0,0 +1,716 @@
+/**
+ * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
+ * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
+ */
+
+import VirtualTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/virtualtesteditor';
+import Paragraph from '@ckeditor/ckeditor5-paragraph/src/paragraph';
+
+import TableEditing from '../../src/tableediting';
+import TableProperties from '../../src/tableproperties';
+
+import TableBorderColorCommand from '../../src/tableproperties/commands/tablebordercolorcommand';
+import TableBorderStyleCommand from '../../src/tableproperties/commands/tableborderstylecommand';
+import TableBorderWidthCommand from '../../src/tableproperties/commands/tableborderwidthcommand';
+import TableAlignmentCommand from '../../src/tableproperties/commands/tablealignmentcommand';
+import TableWidthCommand from '../../src/tableproperties/commands/tablewidthcommand';
+import TableHeightCommand from '../../src/tableproperties/commands/tableheightcommand';
+import TableBackgroundColorCommand from '../../src/tableproperties/commands/tablebackgroundcolorcommand';
+
+import { setData as setModelData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
+import { assertTableStyle, assertTRBLAttribute } from '../_utils/utils';
+
+describe( 'table properties', () => {
+	describe( 'TablePropertiesEditing', () => {
+		let editor, model;
+
+		beforeEach( () => {
+			return VirtualTestEditor
+				.create( {
+					plugins: [ TableProperties, Paragraph, TableEditing ]
+				} )
+				.then( newEditor => {
+					editor = newEditor;
+
+					model = editor.model;
+				} );
+		} );
+
+		afterEach( () => {
+			editor.destroy();
+		} );
+
+		it( 'should have pluginName', () => {
+			expect( TableProperties.pluginName ).to.equal( 'TableProperties' );
+		} );
+
+		it( 'adds tableBorderColor command', () => {
+			expect( editor.commands.get( 'tableBorderColor' ) ).to.be.instanceOf( TableBorderColorCommand );
+		} );
+
+		it( 'adds tableBorderStyle command', () => {
+			expect( editor.commands.get( 'tableBorderStyle' ) ).to.be.instanceOf( TableBorderStyleCommand );
+		} );
+
+		it( 'adds tableBorderWidth command', () => {
+			expect( editor.commands.get( 'tableBorderWidth' ) ).to.be.instanceOf( TableBorderWidthCommand );
+		} );
+
+		it( 'adds tableAlignment command', () => {
+			expect( editor.commands.get( 'tableAlignment' ) ).to.be.instanceOf( TableAlignmentCommand );
+		} );
+
+		it( 'adds tableWidth command', () => {
+			expect( editor.commands.get( 'tableWidth' ) ).to.be.instanceOf( TableWidthCommand );
+		} );
+
+		it( 'adds tableHeight command', () => {
+			expect( editor.commands.get( 'tableHeight' ) ).to.be.instanceOf( TableHeightCommand );
+		} );
+
+		it( 'adds tableBackgroundColor command', () => {
+			expect( editor.commands.get( 'tableBackgroundColor' ) ).to.be.instanceOf( TableBackgroundColorCommand );
+		} );
+
+		describe( 'border', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'borderColor' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'borderStyle' ) ).to.be.true;
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'borderWidth' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast border shorthand', () => {
+					editor.setData( '<table style="border:1px solid #f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', '#f00' );
+					assertTRBLAttribute( table, 'borderStyle', 'solid' );
+					assertTRBLAttribute( table, 'borderWidth', '1px' );
+				} );
+
+				it( 'should upcast border-color shorthand', () => {
+					editor.setData( '<table style="border-color:#f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', '#f00' );
+				} );
+
+				it( 'should upcast border-style shorthand', () => {
+					editor.setData( '<table style="border-style:ridge"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderStyle', 'ridge' );
+				} );
+
+				it( 'should upcast border-width shorthand', () => {
+					editor.setData( '<table style="border-width:1px"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderWidth', '1px' );
+				} );
+
+				it( 'should upcast border-top shorthand', () => {
+					editor.setData( '<table style="border-top:1px solid #f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', '#f00', null, null, null );
+					assertTRBLAttribute( table, 'borderStyle', 'solid', null, null, null );
+					assertTRBLAttribute( table, 'borderWidth', '1px', null, null, null );
+				} );
+
+				it( 'should upcast border-right shorthand', () => {
+					editor.setData( '<table style="border-right:1px solid #f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, '#f00', null, null );
+					assertTRBLAttribute( table, 'borderStyle', null, 'solid', null, null );
+					assertTRBLAttribute( table, 'borderWidth', null, '1px', null, null );
+				} );
+
+				it( 'should upcast border-bottom shorthand', () => {
+					editor.setData( '<table style="border-bottom:1px solid #f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, null, '#f00', null );
+					assertTRBLAttribute( table, 'borderStyle', null, null, 'solid', null );
+					assertTRBLAttribute( table, 'borderWidth', null, null, '1px', null );
+				} );
+
+				it( 'should upcast border-left shorthand', () => {
+					editor.setData( '<table style="border-left:1px solid #f00"><tr><td>foo</td></tr></table>' );
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, null, null, '#f00' );
+					assertTRBLAttribute( table, 'borderStyle', null, null, null, 'solid' );
+					assertTRBLAttribute( table, 'borderWidth', null, null, null, '1px' );
+				} );
+
+				it( 'should upcast border-top-* styles', () => {
+					editor.setData(
+						'<table style="border-top-width:1px;border-top-style:solid;border-top-color:#f00"><tr><td>foo</td></tr></table>'
+					);
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', '#f00', null, null, null );
+					assertTRBLAttribute( table, 'borderStyle', 'solid', null, null, null );
+					assertTRBLAttribute( table, 'borderWidth', '1px', null, null, null );
+				} );
+
+				it( 'should upcast border-right-* styles', () => {
+					editor.setData(
+						'<table style="border-right-width:1px;border-right-style:solid;border-right-color:#f00">' +
+							'<tr>' +
+								'<td>foo</td>' +
+							'</tr>' +
+						'</table>'
+					);
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, '#f00', null, null );
+					assertTRBLAttribute( table, 'borderStyle', null, 'solid', null, null );
+					assertTRBLAttribute( table, 'borderWidth', null, '1px', null, null );
+				} );
+
+				it( 'should upcast border-bottom-* styles', () => {
+					editor.setData(
+						'<table style="border-bottom-width:1px;border-bottom-style:solid;border-bottom-color:#f00">' +
+						'<tr>' +
+						'<td>foo</td>' +
+						'</tr>' +
+						'</table>'
+					);
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, null, '#f00', null );
+					assertTRBLAttribute( table, 'borderStyle', null, null, 'solid', null );
+					assertTRBLAttribute( table, 'borderWidth', null, null, '1px', null );
+				} );
+
+				it( 'should upcast border-left-* styles', () => {
+					editor.setData(
+						'<table style="border-left-width:1px;border-left-style:solid;border-left-color:#f00"><tr><td>foo</td></tr></table>'
+					);
+
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					assertTRBLAttribute( table, 'borderColor', null, null, null, '#f00' );
+					assertTRBLAttribute( table, 'borderStyle', null, null, null, 'solid' );
+					assertTRBLAttribute( table, 'borderWidth', null, null, null, '1px' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let table;
+
+				beforeEach( () => {
+					table = createEmptyTable();
+				} );
+
+				it( 'should downcast borderColor attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderColor', {
+						top: '#f00',
+						right: '#f00',
+						bottom: '#f00',
+						left: '#f00'
+					}, table ) );
+
+					assertTableStyle( editor, 'border-bottom:#f00;border-left:#f00;border-right:#f00;border-top:#f00;' );
+				} );
+
+				it( 'should downcast borderColor attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderColor', {
+						top: '#f00',
+						right: 'hsla(0, 100%, 50%, 0.5)',
+						bottom: 'deeppink',
+						left: 'rgb(255, 0, 0)'
+					}, table ) );
+
+					assertTableStyle( editor,
+						'border-bottom:deeppink;' +
+						'border-left:rgb(255, 0, 0);' +
+						'border-right:hsla(0, 100%, 50%, 0.5);' +
+						'border-top:#f00;'
+					);
+				} );
+
+				it( 'should downcast borderStyle attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderStyle', {
+						top: 'solid',
+						right: 'solid',
+						bottom: 'solid',
+						left: 'solid'
+					}, table ) );
+
+					assertTableStyle( editor, 'border-bottom:solid;border-left:solid;border-right:solid;border-top:solid;' );
+				} );
+
+				it( 'should downcast borderStyle attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderStyle', {
+						top: 'solid',
+						right: 'ridge',
+						bottom: 'dotted',
+						left: 'dashed'
+					}, table ) );
+
+					assertTableStyle( editor, 'border-bottom:dotted;border-left:dashed;border-right:ridge;border-top:solid;' );
+				} );
+
+				it( 'should downcast borderWidth attribute (same top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderWidth', {
+						top: '42px',
+						right: '.1em',
+						bottom: '1337rem',
+						left: 'thick'
+					}, table ) );
+
+					assertTableStyle( editor, 'border-bottom:1337rem;border-left:thick;border-right:.1em;border-top:42px;' );
+				} );
+
+				it( 'should downcast borderWidth attribute (different top, right, bottom, left)', () => {
+					model.change( writer => writer.setAttribute( 'borderWidth', {
+						top: '42px',
+						right: '42px',
+						bottom: '42px',
+						left: '42px'
+					}, table ) );
+
+					assertTableStyle( editor, 'border-bottom:42px;border-left:42px;border-right:42px;border-top:42px;' );
+				} );
+
+				it( 'should downcast borderColor, borderStyle and borderWidth attributes together (same top, right, bottom, left)', () => {
+					model.change( writer => {
+						writer.setAttribute( 'borderColor', {
+							top: '#f00',
+							right: '#f00',
+							bottom: '#f00',
+							left: '#f00'
+						}, table );
+
+						writer.setAttribute( 'borderStyle', {
+							top: 'solid',
+							right: 'solid',
+							bottom: 'solid',
+							left: 'solid'
+						}, table );
+
+						writer.setAttribute( 'borderWidth', {
+							top: '42px',
+							right: '42px',
+							bottom: '42px',
+							left: '42px'
+						}, table );
+					} );
+
+					assertTableStyle( editor,
+						'border-bottom:42px solid #f00;' +
+						'border-left:42px solid #f00;' +
+						'border-right:42px solid #f00;' +
+						'border-top:42px solid #f00;'
+					);
+				} );
+
+				it(
+					'should downcast borderColor, borderStyle and borderWidth attributes together (different top, right, bottom, left)',
+					() => {
+						model.change( writer => {
+							writer.setAttribute( 'borderColor', {
+								top: '#f00',
+								right: 'hsla(0, 100%, 50%, 0.5)',
+								bottom: 'deeppink',
+								left: 'rgb(255, 0, 0)'
+							}, table );
+
+							writer.setAttribute( 'borderStyle', {
+								top: 'solid',
+								right: 'ridge',
+								bottom: 'dotted',
+								left: 'dashed'
+							}, table );
+
+							writer.setAttribute( 'borderWidth', {
+								top: '42px',
+								right: '.1em',
+								bottom: '1337rem',
+								left: 'thick'
+							}, table );
+						} );
+
+						assertTableStyle( editor,
+							'border-bottom:1337rem dotted deeppink;' +
+							'border-left:thick dashed rgb(255, 0, 0);' +
+							'border-right:.1em ridge hsla(0, 100%, 50%, 0.5);' +
+							'border-top:42px solid #f00;'
+						);
+					}
+				);
+
+				describe( 'change attribute', () => {
+					beforeEach( () => {
+						model.change( writer => {
+							writer.setAttribute( 'borderColor', {
+								top: '#f00',
+								right: '#f00',
+								bottom: '#f00',
+								left: '#f00'
+							}, table );
+
+							writer.setAttribute( 'borderStyle', {
+								top: 'solid',
+								right: 'solid',
+								bottom: 'solid',
+								left: 'solid'
+							}, table );
+
+							writer.setAttribute( 'borderWidth', {
+								top: '42px',
+								right: '42px',
+								bottom: '42px',
+								left: '42px'
+							}, table );
+						} );
+					} );
+
+					it( 'should downcast borderColor attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderColor', {
+							top: 'deeppink',
+							right: 'deeppink',
+							bottom: 'deeppink',
+							left: 'deeppink'
+						}, table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:42px solid deeppink;' +
+							'border-left:42px solid deeppink;' +
+							'border-right:42px solid deeppink;' +
+							'border-top:42px solid deeppink;'
+						);
+					} );
+
+					it( 'should downcast borderStyle attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderStyle', {
+							top: 'ridge',
+							right: 'ridge',
+							bottom: 'ridge',
+							left: 'ridge'
+						}, table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:42px ridge #f00;' +
+							'border-left:42px ridge #f00;' +
+							'border-right:42px ridge #f00;' +
+							'border-top:42px ridge #f00;'
+						);
+					} );
+
+					it( 'should downcast borderWidth attribute change', () => {
+						model.change( writer => writer.setAttribute( 'borderWidth', {
+							top: 'thick',
+							right: 'thick',
+							bottom: 'thick',
+							left: 'thick'
+						}, table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:thick solid #f00;' +
+							'border-left:thick solid #f00;' +
+							'border-right:thick solid #f00;' +
+							'border-top:thick solid #f00;'
+						);
+					} );
+
+					it( 'should downcast borderColor attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderColor', table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:42px solid;' +
+							'border-left:42px solid;' +
+							'border-right:42px solid;' +
+							'border-top:42px solid;'
+						);
+					} );
+
+					it( 'should downcast borderStyle attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderStyle', table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:42px #f00;' +
+							'border-left:42px #f00;' +
+							'border-right:42px #f00;' +
+							'border-top:42px #f00;'
+						);
+					} );
+
+					it( 'should downcast borderWidth attribute removal', () => {
+						model.change( writer => writer.removeAttribute( 'borderWidth', table ) );
+
+						assertTableStyle( editor,
+							'border-bottom:solid #f00;' +
+							'border-left:solid #f00;' +
+							'border-right:solid #f00;' +
+							'border-top:solid #f00;'
+						);
+					} );
+
+					it( 'should downcast borderColor, borderStyle and borderWidth attributes removal', () => {
+						model.change( writer => {
+							writer.removeAttribute( 'borderColor', table );
+							writer.removeAttribute( 'borderStyle', table );
+							writer.removeAttribute( 'borderWidth', table );
+						} );
+
+						assertEqualMarkup(
+							editor.getData(),
+							'<figure class="table"><table><tbody><tr><td>foo</td></tr></tbody></table></figure>'
+						);
+					} );
+				} );
+			} );
+		} );
+
+		describe( 'background color', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'backgroundColor' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast background-color', () => {
+					editor.setData( '<table style="background-color:#f00"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'backgroundColor' ) ).to.equal( '#f00' );
+				} );
+
+				it( 'should upcast from background shorthand', () => {
+					editor.setData( '<table style="background:#f00 center center"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'backgroundColor' ) ).to.equal( '#f00' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let table;
+
+				beforeEach( () => {
+					table = createEmptyTable();
+				} );
+
+				it( 'should downcast backgroundColor', () => {
+					model.change( writer => writer.setAttribute( 'backgroundColor', '#f00', table ) );
+
+					assertTableStyle( editor, 'background-color:#f00;' );
+				} );
+			} );
+		} );
+
+		describe( 'width', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'width' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast width', () => {
+					editor.setData( '<table style="width:1337px"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'width' ) ).to.equal( '1337px' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let table;
+
+				beforeEach( () => {
+					table = createEmptyTable();
+				} );
+
+				it( 'should downcast width', () => {
+					model.change( writer => writer.setAttribute( 'width', '1337px', table ) );
+
+					assertTableStyle( editor, 'width:1337px;' );
+				} );
+			} );
+		} );
+
+		describe( 'height', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'height' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast height', () => {
+					editor.setData( '<table style="height:1337px"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'height' ) ).to.equal( '1337px' );
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let table;
+
+				beforeEach( () => {
+					table = createEmptyTable();
+				} );
+
+				it( 'should downcast height', () => {
+					model.change( writer => writer.setAttribute( 'height', '1337px', table ) );
+
+					assertTableStyle( editor, 'height:1337px;' );
+				} );
+			} );
+		} );
+
+		describe( 'alignment', () => {
+			it( 'should set proper schema rules', () => {
+				expect( model.schema.checkAttribute( [ '$root', 'table' ], 'alignment' ) ).to.be.true;
+			} );
+
+			describe( 'upcast conversion', () => {
+				it( 'should upcast style="margin-left:auto;margin-right:0" to right value', () => {
+					editor.setData( '<table style="margin-left:auto;margin-right:0"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'right' );
+				} );
+
+				it( 'should upcast style="margin-left:0;margin-right:auto" to left value', () => {
+					editor.setData( '<table style="margin-left:0;margin-right:auto"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'left' );
+				} );
+
+				it( 'should upcast style="margin-left:auto;margin-right:auto" to center value', () => {
+					editor.setData( '<table style="margin-left:auto;margin-right:auto"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'center' );
+				} );
+
+				it( 'should upcast style="margin-left:auto;margin-right:0pt" to right value', () => {
+					editor.setData( '<table style="margin-left:auto;margin-right:0pt"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'right' );
+				} );
+
+				it( 'should upcast style="margin-left:auto;margin-right:0%" to right value', () => {
+					editor.setData( '<table style="margin-left:auto;margin-right:0%"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'right' );
+				} );
+
+				it( 'should not upcast style="margin-left:auto;margin-right:0.23pt" to right value', () => {
+					editor.setData( '<table style="margin-left:auto;margin-right:0.23pt"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.hasAttribute( 'alignment' ) ).to.be.false;
+				} );
+
+				it( 'should upcast align=right attribute', () => {
+					editor.setData( '<table align="right"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'right' );
+				} );
+
+				it( 'should upcast align=left attribute', () => {
+					editor.setData( '<table align="left"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'left' );
+				} );
+
+				it( 'should upcast align=center attribute', () => {
+					editor.setData( '<table align="center"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.getAttribute( 'alignment' ) ).to.equal( 'center' );
+				} );
+
+				it( 'should discard align=justify attribute', () => {
+					editor.setData( '<table align="justify"><tr><td>foo</td></tr></table>' );
+					const table = model.document.getRoot().getNodeByPath( [ 0 ] );
+
+					expect( table.hasAttribute( 'alignment' ) ).to.be.false;
+				} );
+			} );
+
+			describe( 'downcast conversion', () => {
+				let table;
+
+				beforeEach( () => {
+					table = createEmptyTable();
+				} );
+
+				it( 'should downcast right alignment', () => {
+					model.change( writer => writer.setAttribute( 'alignment', 'right', table ) );
+
+					assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+				} );
+
+				it( 'should downcast left alignment', () => {
+					model.change( writer => writer.setAttribute( 'alignment', 'left', table ) );
+
+					assertTableStyle( editor, 'margin-left:0;margin-right:auto;' );
+				} );
+
+				it( 'should downcast centered alignment', () => {
+					model.change( writer => writer.setAttribute( 'alignment', 'center', table ) );
+
+					assertTableStyle( editor, 'margin-left:auto;margin-right:auto;' );
+				} );
+
+				it( 'should downcast changed alignment', () => {
+					model.change( writer => writer.setAttribute( 'alignment', 'center', table ) );
+
+					assertTableStyle( editor, 'margin-left:auto;margin-right:auto;' );
+
+					model.change( writer => writer.setAttribute( 'alignment', 'right', table ) );
+
+					assertTableStyle( editor, 'margin-left:auto;margin-right:0;' );
+				} );
+
+				it( 'should downcast removed alignment', () => {
+					model.change( writer => writer.setAttribute( 'alignment', 'center', table ) );
+
+					assertTableStyle( editor, 'margin-left:auto;margin-right:auto;' );
+
+					model.change( writer => writer.removeAttribute( 'alignment', table ) );
+
+					assertTableStyle( editor );
+				} );
+			} );
+		} );
+
+		function createEmptyTable() {
+			setModelData(
+				model,
+				'<table headingRows="0" headingColumns="0">' +
+				'<tableRow>' +
+				'<tableCell>' +
+				'<paragraph>foo</paragraph>' +
+				'</tableCell>' +
+				'</tableRow>' +
+				'</table>'
+			);
+
+			return model.document.getRoot().getNodeByPath( [ 0 ] );
+		}
+	} );
+} );

+ 39 - 38
packages/ckeditor5-table/tests/tableutils.js

@@ -6,9 +6,10 @@
 import ModelTestEditor from '@ckeditor/ckeditor5-core/tests/_utils/modeltesteditor';
 import { getData, setData } from '@ckeditor/ckeditor5-engine/src/dev-utils/model';
 
-import { defaultConversion, defaultSchema, formatTable, formattedModelTable, modelTable } from './_utils/utils';
+import { defaultConversion, defaultSchema, modelTable } from './_utils/utils';
 
 import TableUtils from '../src/tableutils';
+import { assertEqualMarkup } from '@ckeditor/ckeditor5-utils/tests/_utils/utils';
 
 describe( 'TableUtils', () => {
 	let editor, model, root, tableUtils;
@@ -59,7 +60,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12' ],
 				[ '', '' ],
 				[ '21', '22' ]
@@ -74,7 +75,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ) );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '', '' ],
 				[ '11[]', '12' ],
 				[ '21', '22' ]
@@ -90,7 +91,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12' ],
 				[ '', '' ],
 				[ '21', '22' ],
@@ -107,7 +108,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12' ],
 				[ '21', '22' ],
 				[ '', '' ],
@@ -124,7 +125,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 2, contents: '11[]' }, '13', '14' ],
 				[ { colspan: 2, rowspan: 7, contents: '21' }, '23', '24' ],
 				[ '', '' ],
@@ -143,7 +144,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
 				[ '22', '23' ],
 				[ '', '', '' ],
@@ -162,7 +163,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 2, contents: '11[]' }, '12', '13' ],
 				[ '22', '23' ],
 				[ '', '', '' ],
@@ -180,7 +181,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertRows( root.getNodeByPath( [ 0 ] ), { at: 2, rows: 3 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12' ],
 				[ '21', '22' ],
 				[ '', '' ],
@@ -199,7 +200,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '', '12' ],
 				[ '21', '', '22' ]
 			] ) );
@@ -213,7 +214,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '', '11[]', '12' ],
 				[ '', '21', '22' ]
 			] ) );
@@ -227,7 +228,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ) );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '', '11[]', '12' ],
 				[ '', '21', '22' ]
 			] ) );
@@ -243,7 +244,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00[]', '01', '', '' ],
 				[ { colspan: 2, contents: '10' }, '', '' ],
 				[ '20', { rowspan: 2, contents: '21' }, '', '' ],
@@ -262,7 +263,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 0, columns: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '', '', '00[]', '01' ],
 				[ '', '', { colspan: 2, contents: '10' } ],
 				[ '', '', '20', { rowspan: 2, contents: '21' } ],
@@ -280,7 +281,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '', '12' ],
 				[ '21', '', '22' ],
 				[ '31', '', '32' ]
@@ -296,7 +297,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12', '', '13' ],
 				[ '21', '22', '', '23' ],
 				[ '31', '32', '', '33' ]
@@ -312,7 +313,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00[]', '', '01' ],
 				[ { colspan: 3, contents: '10' } ],
 				[ '20', '', '21' ]
@@ -328,7 +329,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 2, columns: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '11[]', '12', '', '', '13', '14', '15' ],
 				[ '21', '22', '', '', { colspan: 2, contents: '23' }, '25' ],
 				[ { colspan: 6, contents: '31' }, { colspan: 2, contents: '34' } ]
@@ -344,7 +345,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 2 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 4, rowspan: 2, contents: '00[]' }, '02' ],
 				[ '12' ],
 				[ '20', '', '', '21', '22' ]
@@ -361,7 +362,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.insertColumns( root.getNodeByPath( [ 0 ] ), { at: 1, columns: 1 } );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 4, contents: '00[]' }, '', { rowspan: 2, contents: '01' }, '02' ],
 				[ '', '12' ],
 				[ '', { rowspan: 2, contents: '21' }, '22' ],
@@ -381,7 +382,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ), 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', { colspan: 3, contents: '01' }, '02' ],
 				[ '10', '[]11', '', '', '12' ],
 				[ '20', { colspan: 4, contents: '21' } ],
@@ -399,7 +400,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 1 ] ) );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', { colspan: 2, contents: '01' }, '02' ],
 				[ '10', '[]11', '', '12' ],
 				[ '20', { colspan: 3, contents: '21' } ],
@@ -417,7 +418,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 2, 1 ] ), 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ '10', '11', '12' ],
 				[ '20', '21[]', '' ],
@@ -433,7 +434,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { colspan: 2, contents: '10[]' }, '' ]
 			] ) );
@@ -447,7 +448,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02', '03' ],
 				[ { colspan: 2, contents: '10[]' }, { colspan: 2, contents: '' } ]
 			] ) );
@@ -462,7 +463,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02', '03', '04', '05' ],
 				[ { colspan: 3, rowspan: 2, contents: '10[]' }, { colspan: 2, rowspan: 2, contents: '' }, '15' ],
 				[ '25' ]
@@ -478,7 +479,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 2, contents: '00' }, '01', '02' ],
 				[ { rowspan: 2, contents: '10[]' }, { rowspan: 2, contents: '' }, { rowspan: 2, contents: '' }, '12' ],
 				[ '22' ]
@@ -493,7 +494,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 6 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 3, contents: '00' }, '01', '02', '03' ],
 				[ '10[]', '', '', '', '', '' ]
 			] ) );
@@ -507,7 +508,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellVertically( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { colspan: 3, contents: '00' }, '01' ],
 				[ '10[]', '', '', '11' ]
 			], { headingColumns: 3 } ) );
@@ -524,7 +525,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ) );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { rowspan: 2, contents: '10' }, '[]11', { rowspan: 2, contents: '12' } ],
 				[ '' ],
@@ -541,7 +542,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 1 ] ), 4 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01', '02' ],
 				[ { rowspan: 4, contents: '10' }, '[]11', { rowspan: 4, contents: '12' } ],
 				[ '' ],
@@ -560,7 +561,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 1, 0 ] ), 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 4, contents: '00' }, '01', { rowspan: 5, contents: '02' } ],
 				[ '[]11' ],
 				[ '' ],
@@ -580,7 +581,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( tableCell, 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', '01[]' ],
 				[ '10', '' ],
 				[ '20', '21' ]
@@ -598,7 +599,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( tableCell, 2 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', { colspan: 2, contents: '01[]' } ],
 				[ '10', { colspan: 2, contents: '' } ],
 				[ '20', '21', '22' ]
@@ -621,7 +622,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( tableCell, 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00', { rowspan: 3, contents: '01[]' } ],
 				[ '10' ],
 				[ '20' ],
@@ -644,7 +645,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( tableCell, 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 2, contents: '00' }, '01[]' ],
 				[ '' ],
 				[ '10', '' ],
@@ -662,7 +663,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( tableCell, 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ { rowspan: 3, contents: '00' }, { colspan: 2, contents: '01[]' } ],
 				[ { colspan: 2, contents: '' } ],
 				[ { colspan: 2, contents: '' } ],
@@ -679,7 +680,7 @@ describe( 'TableUtils', () => {
 
 			tableUtils.splitCellHorizontally( root.getNodeByPath( [ 0, 0, 0 ] ), 3 );
 
-			expect( formatTable( getData( model ) ) ).to.equal( formattedModelTable( [
+			assertEqualMarkup( getData( model ), modelTable( [
 				[ '00[]', { rowspan: 3, contents: '01' }, { rowspan: 3, contents: '02' } ],
 				[ '' ],
 				[ '' ],