浏览代码

Merge pull request #1807 from ckeditor/i/3287

Feature: Introduced CSS style normalization for conversion. Closes ckeditor/ckeditor5#6047.
Piotrek Koszuliński 5 年之前
父节点
当前提交
db481fba6c
共有 24 个文件被更改,包括 3274 次插入209 次删除
  1. 1 1
      packages/ckeditor5-engine/src/conversion/downcasthelpers.js
  2. 13 0
      packages/ckeditor5-engine/src/conversion/viewconsumable.js
  3. 17 0
      packages/ckeditor5-engine/src/view/document.js
  4. 9 1
      packages/ckeditor5-engine/src/view/downcastwriter.js
  5. 89 115
      packages/ckeditor5-engine/src/view/element.js
  6. 71 0
      packages/ckeditor5-engine/src/view/styles/background.js
  7. 276 0
      packages/ckeditor5-engine/src/view/styles/border.js
  8. 41 0
      packages/ckeditor5-engine/src/view/styles/margin.js
  9. 40 0
      packages/ckeditor5-engine/src/view/styles/padding.js
  10. 204 0
      packages/ckeditor5-engine/src/view/styles/utils.js
  11. 887 0
      packages/ckeditor5-engine/src/view/stylesmap.js
  12. 8 0
      packages/ckeditor5-engine/src/view/upcastwriter.js
  13. 81 0
      packages/ckeditor5-engine/tests/conversion/viewconsumable.js
  14. 12 0
      packages/ckeditor5-engine/tests/view/document.js
  15. 31 85
      packages/ckeditor5-engine/tests/view/element.js
  16. 2 2
      packages/ckeditor5-engine/tests/view/emptyelement.js
  17. 54 0
      packages/ckeditor5-engine/tests/view/styles/background.js
  18. 637 0
      packages/ckeditor5-engine/tests/view/styles/border.js
  19. 183 0
      packages/ckeditor5-engine/tests/view/styles/margin.js
  20. 183 0
      packages/ckeditor5-engine/tests/view/styles/padding.js
  21. 162 0
      packages/ckeditor5-engine/tests/view/styles/utils.js
  22. 268 0
      packages/ckeditor5-engine/tests/view/stylesmap.js
  23. 3 3
      packages/ckeditor5-engine/tests/view/uielement.js
  24. 2 2
      packages/ckeditor5-engine/tests/view/upcastwriter.js

+ 1 - 1
packages/ckeditor5-engine/src/conversion/downcasthelpers.js

@@ -204,7 +204,7 @@ export default class DowncastHelpers extends ConversionHelpers {
 	 *			view: modelAttributeValue => ( { key: 'class', value: 'styled-' + modelAttributeValue } )
 	 *		} );
 	 *
-	 * *Note:* Downcasting to a style property requires providing `value` as an object:
+	 * **Note**: Downcasting to a style property requires providing `value` as an object:
 	 *
 	 *		editor.conversion.for( 'downcast' ).attributeToAttribute( {
 	 *			model: 'lineHeight',

+ 13 - 0
packages/ckeditor5-engine/src/conversion/viewconsumable.js

@@ -9,6 +9,7 @@
 
 import { isArray } from 'lodash-es';
 import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
+import StylesMap from '../view/stylesmap';
 
 /**
  * Class used for handling consumption of view {@link module:engine/view/element~Element elements},
@@ -507,6 +508,12 @@ class ViewElementConsumables {
 			}
 
 			consumables.set( name, true );
+
+			if ( type === 'styles' ) {
+				for ( const alsoName of StylesMap.getRelatedStyles( name ) ) {
+					consumables.set( alsoName, true );
+				}
+			}
 		}
 	}
 
@@ -568,6 +575,12 @@ class ViewElementConsumables {
 				this._consume( consumableName, [ ...this._consumables[ consumableName ].keys() ] );
 			} else {
 				consumables.set( name, false );
+
+				if ( type == 'styles' ) {
+					for ( const toConsume of StylesMap.getRelatedStyles( name ) ) {
+						consumables.set( toConsume, false );
+					}
+				}
 			}
 		}
 	}

+ 17 - 0
packages/ckeditor5-engine/src/view/document.js

@@ -11,6 +11,7 @@ import DocumentSelection from './documentselection';
 import Collection from '@ckeditor/ckeditor5-utils/src/collection';
 import mix from '@ckeditor/ckeditor5-utils/src/mix';
 import ObservableMixin from '@ckeditor/ckeditor5-utils/src/observablemixin';
+import StylesMap from './stylesmap';
 
 // @if CK_DEBUG_ENGINE // const { logDocument } = require( '../dev-utils/utils' );
 
@@ -161,6 +162,22 @@ export default class Document {
 	}
 
 	/**
+	 * Adds a style processor normalization rules.
+	 *
+	 * The available style processors:
+	 *
+	 * * background: {@link module:engine/view/styles/background~addBackgroundRules}
+	 * * border: {@link module:engine/view/styles/border~addBorderRules}
+	 * * margin: {@link module:engine/view/styles/margin~addMarginRules}
+	 * * padding: {@link module:engine/view/styles/padding~addPaddingRules}
+	 *
+	 * @param {Function} callback
+	 */
+	addStyleProcessorRules( callback ) {
+		callback( StylesMap._styleProcessor );
+	}
+
+	/**
 	 * Performs post-fixer loops. Executes post-fixer callbacks as long as none of them has done any changes to the model.
 	 *
 	 * @protected

+ 9 - 1
packages/ckeditor5-engine/src/view/downcastwriter.js

@@ -324,6 +324,10 @@ export default class DowncastWriter {
 	 *			position: 'fixed'
 	 *		}, element );
 	 *
+	 * **Note**: The passed style can be normalized if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#set `StylesMap#set()`} for details.
+	 *
 	 * @param {String|Object} property Property name or object with key - value pairs.
 	 * @param {String} [value] Value to set. This parameter is ignored if object is provided as the first parameter.
 	 * @param {module:engine/view/element~Element} element Element to set styles on.
@@ -339,9 +343,13 @@ export default class DowncastWriter {
 	/**
 	 * Removes specified style from the element.
 	 *
-	 *		writer.removeStyle( 'color', element );  // Removes 'color' style.
+	 *		writer.removeStyle( 'color', element ); // Removes 'color' style.
 	 *		writer.removeStyle( [ 'color', 'border-top' ], element ); // Removes both 'color' and 'border-top' styles.
 	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#remove `StylesMap#remove()`} for details.
+	 *
 	 * @param {Array.<String>|String} property
 	 * @param {module:engine/view/element~Element} element
 	 */

+ 89 - 115
packages/ckeditor5-engine/src/view/element.js

@@ -14,6 +14,7 @@ import objectToMap from '@ckeditor/ckeditor5-utils/src/objecttomap';
 import isIterable from '@ckeditor/ckeditor5-utils/src/isiterable';
 import Matcher from './matcher';
 import { isPlainObject } from 'lodash-es';
+import StylesMap from './stylesmap';
 
 // @if CK_DEBUG_ENGINE // const { convertMapToTags } = require( '../dev-utils/utils' );
 
@@ -105,16 +106,17 @@ export default class Element extends Node {
 		}
 
 		/**
-		 * Map of styles.
+		 * Normalized styles.
 		 *
 		 * @protected
-		 * @member {Map} module:engine/view/element~Element#_styles
+		 * @member {module:engine/view/stylesmap~StylesMap} module:engine/view/element~Element#_styles
 		 */
-		this._styles = new Map();
+		this._styles = new StylesMap();
 
 		if ( this._attrs.has( 'style' ) ) {
 			// Remove style attribute and handle it by styles map.
-			parseInlineStyles( this._styles, this._attrs.get( 'style' ) );
+			this._styles.setTo( this._attrs.get( 'style' ) );
+
 			this._attrs.delete( 'style' );
 		}
 
@@ -221,7 +223,7 @@ export default class Element extends Node {
 			yield 'class';
 		}
 
-		if ( this._styles.size > 0 ) {
+		if ( !this._styles.isEmpty ) {
 			yield 'style';
 		}
 
@@ -243,7 +245,7 @@ export default class Element extends Node {
 			yield [ 'class', this.getAttribute( 'class' ) ];
 		}
 
-		if ( this._styles.size > 0 ) {
+		if ( !this._styles.isEmpty ) {
 			yield [ 'style', this.getAttribute( 'style' ) ];
 		}
 	}
@@ -264,17 +266,9 @@ export default class Element extends Node {
 		}
 
 		if ( key == 'style' ) {
-			if ( this._styles.size > 0 ) {
-				let styleString = '';
-
-				for ( const [ property, value ] of this._styles ) {
-					styleString += `${ property }:${ value };`;
-				}
+			const inlineStyle = this._styles.toString();
 
-				return styleString;
-			}
-
-			return undefined;
+			return inlineStyle == '' ? undefined : inlineStyle;
 		}
 
 		return this._attrs.get( key );
@@ -292,7 +286,7 @@ export default class Element extends Node {
 		}
 
 		if ( key == 'style' ) {
-			return this._styles.size > 0;
+			return !this._styles.isEmpty;
 		}
 
 		return this._attrs.has( key );
@@ -342,8 +336,11 @@ export default class Element extends Node {
 		}
 
 		// Check if styles are the same.
-		for ( const [ property, value ] of this._styles ) {
-			if ( !otherElement._styles.has( property ) || otherElement._styles.get( property ) !== value ) {
+		for ( const property of this._styles.getStyleNames() ) {
+			if (
+				!otherElement._styles.has( property ) ||
+				otherElement._styles.getAsString( property ) !== this._styles.getAsString( property )
+			) {
 				return false;
 			}
 		}
@@ -380,14 +377,67 @@ export default class Element extends Node {
 	}
 
 	/**
-	 * Returns style value for given property.
-	 * Undefined is returned if style does not exist.
+	 * Returns style value for the given property mae.
+	 * If the style does not exist `undefined` is returned.
+	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#getAsString `StylesMap#getAsString()`} for details.
+	 *
+	 * For an element with style set to `'margin:1px'`:
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		const element = view.change( writer => {
+	 *			const element = writer.createElement();
+	 *			writer.setStyle( 'margin', '1px' );
+	 *			writer.setStyle( 'margin-bottom', '3em' );
+	 *
+	 *			return element;
+	 *		} );
+	 *
+	 *		element.getStyle( 'margin' ); // -> 'margin: 1px 1px 3em;'
 	 *
 	 * @param {String} property
 	 * @returns {String|undefined}
 	 */
 	getStyle( property ) {
-		return this._styles.get( property );
+		return this._styles.getAsString( property );
+	}
+
+	/**
+	 * Returns a normalized style object or single style value.
+	 *
+	 * For an element with style set to: margin:1px 2px 3em;
+	 *
+	 *		element.getNormalizedStyle( 'margin' ) );
+	 *
+	 * will return:
+	 *
+	 *		{
+	 *			top: '1px',
+	 *			right: '2px',
+	 *			bottom: '3em',
+	 *			left: '2px'    // a normalized value from margin shorthand
+	 *		}
+	 *
+	 * and reading for single style value:
+	 *
+	 *		styles.getNormalizedStyle( 'margin-left' );
+	 *
+	 * Will return a `2px` string.
+	 *
+	 * **Note**: This method will return normalized values only if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#getNormalized `StylesMap#getNormalized()`} for details.
+	 *
+	 *
+	 * @param {String} property Name of CSS property
+	 * @returns {Object|String|undefined}
+	 */
+	getNormalizedStyle( property ) {
+		return this._styles.getNormalized( property );
 	}
 
 	/**
@@ -396,7 +446,7 @@ export default class Element extends Node {
 	 * @returns {Iterable.<String>}
 	 */
 	getStyleNames() {
-		return this._styles.keys();
+		return this._styles.getStyleNames();
 	}
 
 	/**
@@ -481,18 +531,18 @@ export default class Element extends Node {
 	 *		// returns 'foo class="baz" style="border-color:white;color:red" apple="20" banana="10"'
 	 *		element.getIdentity();
 	 *
-	 * NOTE: Classes, styles and other attributes are sorted alphabetically.
+	 * **Note**: Classes, styles and other attributes are sorted alphabetically.
 	 *
 	 * @returns {String}
 	 */
 	getIdentity() {
 		const classes = Array.from( this._classes ).sort().join( ',' );
-		const styles = Array.from( this._styles ).map( i => `${ i[ 0 ] }:${ i[ 1 ] }` ).sort().join( ';' );
+		const styles = this._styles.toString();
 		const attributes = Array.from( this._attrs ).map( i => `${ i[ 0 ] }="${ i[ 1 ] }"` ).sort().join( ' ' );
 
 		return this.name +
 			( classes == '' ? '' : ` class="${ classes }"` ) +
-			( styles == '' ? '' : ` style="${ styles }"` ) +
+			( !styles ? '' : ` style="${ styles }"` ) +
 			( attributes == '' ? '' : ` ${ attributes }` );
 	}
 
@@ -519,7 +569,7 @@ export default class Element extends Node {
 		// Classes and styles are cloned separately - this solution is faster than adding them back to attributes and
 		// parse once again in constructor.
 		cloned._classes = new Set( this._classes );
-		cloned._styles = new Map( this._styles );
+		cloned._styles.set( this._styles.getNormalized() );
 
 		// Clone custom properties.
 		cloned._customProperties = new Map( this._customProperties );
@@ -616,7 +666,7 @@ export default class Element extends Node {
 		if ( key == 'class' ) {
 			parseClasses( this._classes, value );
 		} else if ( key == 'style' ) {
-			parseInlineStyles( this._styles, value );
+			this._styles.setTo( value );
 		} else {
 			this._attrs.set( key, value );
 		}
@@ -647,7 +697,7 @@ export default class Element extends Node {
 
 		// Remove style attribute.
 		if ( key == 'style' ) {
-			if ( this._styles.size > 0 ) {
+			if ( !this._styles.isEmpty ) {
 				this._styles.clear();
 
 				return true;
@@ -705,6 +755,10 @@ export default class Element extends Node {
 	 *			position: 'fixed'
 	 *		} );
 	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#set `StylesMap#set()`} for details.
+	 *
 	 * @see module:engine/view/downcastwriter~DowncastWriter#setStyle
 	 * @protected
 	 * @param {String|Object} property Property name or object with key - value pairs.
@@ -714,15 +768,7 @@ export default class Element extends Node {
 	_setStyle( property, value ) {
 		this._fireChange( 'attributes', this );
 
-		if ( isPlainObject( property ) ) {
-			const keys = Object.keys( property );
-
-			for ( const key of keys ) {
-				this._styles.set( key, property[ key ] );
-			}
-		} else {
-			this._styles.set( property, value );
-		}
+		this._styles.set( property, value );
 	}
 
 	/**
@@ -731,6 +777,10 @@ export default class Element extends Node {
 	 *		element._removeStyle( 'color' );  // Removes 'color' style.
 	 *		element._removeStyle( [ 'color', 'border-top' ] ); // Removes both 'color' and 'border-top' styles.
 	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#remove `StylesMap#remove()`} for details.
+	 *
 	 * @see module:engine/view/downcastwriter~DowncastWriter#removeStyle
 	 * @protected
 	 * @param {Array.<String>|String} property
@@ -740,7 +790,7 @@ export default class Element extends Node {
 		this._fireChange( 'attributes', this );
 
 		property = Array.isArray( property ) ? property : [ property ];
-		property.forEach( name => this._styles.delete( name ) );
+		property.forEach( name => this._styles.remove( name ) );
 	}
 
 	/**
@@ -826,82 +876,6 @@ function parseAttributes( attrs ) {
 	return attrs;
 }
 
-// Parses inline styles and puts property - value pairs into styles map.
-// Styles map is cleared before insertion.
-//
-// @param {Map.<String, String>} stylesMap Map to insert parsed properties and values.
-// @param {String} stylesString Styles to parse.
-function parseInlineStyles( stylesMap, stylesString ) {
-	// `null` if no quote was found in input string or last found quote was a closing quote. See below.
-	let quoteType = null;
-	let propertyNameStart = 0;
-	let propertyValueStart = 0;
-	let propertyName = null;
-
-	stylesMap.clear();
-
-	// Do not set anything if input string is empty.
-	if ( stylesString === '' ) {
-		return;
-	}
-
-	// Fix inline styles that do not end with `;` so they are compatible with algorithm below.
-	if ( stylesString.charAt( stylesString.length - 1 ) != ';' ) {
-		stylesString = stylesString + ';';
-	}
-
-	// Seek the whole string for "special characters".
-	for ( let i = 0; i < stylesString.length; i++ ) {
-		const char = stylesString.charAt( i );
-
-		if ( quoteType === null ) {
-			// No quote found yet or last found quote was a closing quote.
-			switch ( char ) {
-				case ':':
-					// Most of time colon means that property name just ended.
-					// Sometimes however `:` is found inside property value (for example in background image url).
-					if ( !propertyName ) {
-						// Treat this as end of property only if property name is not already saved.
-						// Save property name.
-						propertyName = stylesString.substr( propertyNameStart, i - propertyNameStart );
-						// Save this point as the start of property value.
-						propertyValueStart = i + 1;
-					}
-
-					break;
-
-				case '"':
-				case '\'':
-					// Opening quote found (this is an opening quote, because `quoteType` is `null`).
-					quoteType = char;
-
-					break;
-
-				case ';': {
-					// Property value just ended.
-					// Use previously stored property value start to obtain property value.
-					const propertyValue = stylesString.substr( propertyValueStart, i - propertyValueStart );
-
-					if ( propertyName ) {
-						// Save parsed part.
-						stylesMap.set( propertyName.trim(), propertyValue.trim() );
-					}
-
-					propertyName = null;
-
-					// Save this point as property name start. Property name starts immediately after previous property value ends.
-					propertyNameStart = i + 1;
-
-					break;
-				}
-			}
-		} else if ( char === quoteType ) {
-			// If a quote char is found and it is a closing quote, mark this fact by `null`-ing `quoteType`.
-			quoteType = null;
-		}
-	}
-}
-
 // Parses class attribute and puts all classes into classes set.
 // Classes set s cleared before insertion.
 //

+ 71 - 0
packages/ckeditor5-engine/src/view/styles/background.js

@@ -0,0 +1,71 @@
+/**
+ * @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 engine/view/styles/background
+ */
+
+import { isAttachment, isColor, isPosition, isRepeat, isURL } from './utils';
+
+/**
+ * Adds a background CSS styles processing rules.
+ *
+ *		editor.editing.view.document.addStyleProcessorRules( addBackgroundRules );
+ *
+ * The normalized value is stored as:
+ *
+ *		const styles = {
+ *			background: {
+ *				color,
+ *				repeat,
+ *				position,
+ *				attachment,
+ *				image
+ *			}
+ *		};
+ *
+ * **Note**: Currently only `'background-color'` longhand value is parsed besides `'background'` shorthand. The reducer also supports only
+ * `'background-color'` value.
+ *
+ * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor
+ */
+export function addBackgroundRules( stylesProcessor ) {
+	stylesProcessor.setNormalizer( 'background', normalizeBackground );
+	stylesProcessor.setNormalizer( 'background-color', value => ( { path: 'background.color', value } ) );
+	stylesProcessor.setReducer( 'background', value => {
+		const ret = [];
+
+		ret.push( [ 'background-color', value.color ] );
+
+		return ret;
+	} );
+}
+
+function normalizeBackground( value ) {
+	const background = {};
+
+	const parts = value.split( ' ' );
+
+	for ( const part of parts ) {
+		if ( isRepeat( part ) ) {
+			background.repeat = background.repeat || [];
+			background.repeat.push( part );
+		} else if ( isPosition( part ) ) {
+			background.position = background.position || [];
+			background.position.push( part );
+		} else if ( isAttachment( part ) ) {
+			background.attachment = part;
+		} else if ( isColor( part ) ) {
+			background.color = part;
+		} else if ( isURL( part ) ) {
+			background.image = part;
+		}
+	}
+
+	return {
+		path: 'background',
+		value: background
+	};
+}

+ 276 - 0
packages/ckeditor5-engine/src/view/styles/border.js

@@ -0,0 +1,276 @@
+/**
+ * @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 engine/view/styles/border
+ */
+
+import { getShorthandValues, getTopRightBottomLeftValueReducer, getTopRightBottomLeftValues, isLength, isLineStyle } from './utils';
+
+/**
+ * Adds a border CSS styles processing rules.
+ *
+ *		editor.editing.view.document.addStyleProcessorRules( addBorderRules );
+ *
+ * This rules merges all [border](https://developer.mozilla.org/en-US/docs/Web/CSS/border) styles notation shorthands:
+ *
+ * - border
+ * - border-top
+ * - border-right
+ * - border-bottom
+ * - border-left
+ * - border-color
+ * - border-style
+ * - border-width
+ *
+ * and all corresponding longhand forms (like `border-top-color`, `border-top-style`, etc).
+ *
+ * It does not handle other shorthands (like `border-radius` or `border-image`).
+ *
+ * The normalized model stores border values as:
+ *
+ *		const styles = {
+ *			border: {
+ *				color: { top, right, bottom, left },
+ *				style: { top, right, bottom, left },
+ *				width: { top, right, bottom, left },
+ *			}
+ *		};
+ *
+ * The `border` value is reduced to a 4 values for each box edge (even if they could be further reduces to a single
+ * `border:<width> <style> <color>` style.
+ *
+ * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor
+ */
+export function addBorderRules( stylesProcessor ) {
+	stylesProcessor.setNormalizer( 'border', borderNormalizer );
+
+	// Border-position shorthands.
+	stylesProcessor.setNormalizer( 'border-top', getBorderPositionNormalizer( 'top' ) );
+	stylesProcessor.setNormalizer( 'border-right', getBorderPositionNormalizer( 'right' ) );
+	stylesProcessor.setNormalizer( 'border-bottom', getBorderPositionNormalizer( 'bottom' ) );
+	stylesProcessor.setNormalizer( 'border-left', getBorderPositionNormalizer( 'left' ) );
+
+	// Border-property shorthands.
+	stylesProcessor.setNormalizer( 'border-color', getBorderPropertyNormalizer( 'color' ) );
+	stylesProcessor.setNormalizer( 'border-width', getBorderPropertyNormalizer( 'width' ) );
+	stylesProcessor.setNormalizer( 'border-style', getBorderPropertyNormalizer( 'style' ) );
+
+	// Border longhands.
+	stylesProcessor.setNormalizer( 'border-top-color', getBorderPropertyPositionNormalizer( 'color', 'top' ) );
+	stylesProcessor.setNormalizer( 'border-top-style', getBorderPropertyPositionNormalizer( 'style', 'top' ) );
+	stylesProcessor.setNormalizer( 'border-top-width', getBorderPropertyPositionNormalizer( 'width', 'top' ) );
+
+	stylesProcessor.setNormalizer( 'border-right-color', getBorderPropertyPositionNormalizer( 'color', 'right' ) );
+	stylesProcessor.setNormalizer( 'border-right-style', getBorderPropertyPositionNormalizer( 'style', 'right' ) );
+	stylesProcessor.setNormalizer( 'border-right-width', getBorderPropertyPositionNormalizer( 'width', 'right' ) );
+
+	stylesProcessor.setNormalizer( 'border-bottom-color', getBorderPropertyPositionNormalizer( 'color', 'bottom' ) );
+	stylesProcessor.setNormalizer( 'border-bottom-style', getBorderPropertyPositionNormalizer( 'style', 'bottom' ) );
+	stylesProcessor.setNormalizer( 'border-bottom-width', getBorderPropertyPositionNormalizer( 'width', 'bottom' ) );
+
+	stylesProcessor.setNormalizer( 'border-left-color', getBorderPropertyPositionNormalizer( 'color', 'left' ) );
+	stylesProcessor.setNormalizer( 'border-left-style', getBorderPropertyPositionNormalizer( 'style', 'left' ) );
+	stylesProcessor.setNormalizer( 'border-left-width', getBorderPropertyPositionNormalizer( 'width', 'left' ) );
+
+	stylesProcessor.setExtractor( 'border-top', getBorderPositionExtractor( 'top' ) );
+	stylesProcessor.setExtractor( 'border-right', getBorderPositionExtractor( 'right' ) );
+	stylesProcessor.setExtractor( 'border-bottom', getBorderPositionExtractor( 'bottom' ) );
+	stylesProcessor.setExtractor( 'border-left', getBorderPositionExtractor( 'left' ) );
+
+	stylesProcessor.setExtractor( 'border-top-color', 'border.color.top' );
+	stylesProcessor.setExtractor( 'border-right-color', 'border.color.right' );
+	stylesProcessor.setExtractor( 'border-bottom-color', 'border.color.bottom' );
+	stylesProcessor.setExtractor( 'border-left-color', 'border.color.left' );
+
+	stylesProcessor.setExtractor( 'border-top-width', 'border.width.top' );
+	stylesProcessor.setExtractor( 'border-right-width', 'border.width.right' );
+	stylesProcessor.setExtractor( 'border-bottom-width', 'border.width.bottom' );
+	stylesProcessor.setExtractor( 'border-left-width', 'border.width.left' );
+
+	stylesProcessor.setExtractor( 'border-top-style', 'border.style.top' );
+	stylesProcessor.setExtractor( 'border-right-style', 'border.style.right' );
+	stylesProcessor.setExtractor( 'border-bottom-style', 'border.style.bottom' );
+	stylesProcessor.setExtractor( 'border-left-style', 'border.style.left' );
+
+	stylesProcessor.setReducer( 'border-color', getTopRightBottomLeftValueReducer( 'border-color' ) );
+	stylesProcessor.setReducer( 'border-style', getTopRightBottomLeftValueReducer( 'border-style' ) );
+	stylesProcessor.setReducer( 'border-width', getTopRightBottomLeftValueReducer( 'border-width' ) );
+	stylesProcessor.setReducer( 'border-top', getBorderPositionReducer( 'top' ) );
+	stylesProcessor.setReducer( 'border-right', getBorderPositionReducer( 'right' ) );
+	stylesProcessor.setReducer( 'border-bottom', getBorderPositionReducer( 'bottom' ) );
+	stylesProcessor.setReducer( 'border-left', getBorderPositionReducer( 'left' ) );
+	stylesProcessor.setReducer( 'border', borderReducer );
+
+	stylesProcessor.setStyleRelation( 'border', [
+		'border-color', 'border-style', 'border-width',
+		'border-top', 'border-right', 'border-bottom', 'border-left',
+		'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color',
+		'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style',
+		'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'
+	] );
+
+	stylesProcessor.setStyleRelation( 'border-color', [
+		'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color'
+	] );
+	stylesProcessor.setStyleRelation( 'border-style', [
+		'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style'
+	] );
+	stylesProcessor.setStyleRelation( 'border-width', [
+		'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width'
+	] );
+
+	stylesProcessor.setStyleRelation( 'border-top', [ 'border-top-color', 'border-top-style', 'border-top-width' ] );
+	stylesProcessor.setStyleRelation( 'border-right', [ 'border-right-color', 'border-right-style', 'border-right-width' ] );
+	stylesProcessor.setStyleRelation( 'border-bottom', [ 'border-bottom-color', 'border-bottom-style', 'border-bottom-width' ] );
+	stylesProcessor.setStyleRelation( 'border-left', [ 'border-left-color', 'border-left-style', 'border-left-width' ] );
+}
+
+function borderNormalizer( value ) {
+	const { color, style, width } = normalizeBorderShorthand( value );
+
+	return {
+		path: 'border',
+		value: {
+			color: getTopRightBottomLeftValues( color ),
+			style: getTopRightBottomLeftValues( style ),
+			width: getTopRightBottomLeftValues( width )
+		}
+	};
+}
+
+function getBorderPositionNormalizer( side ) {
+	return value => {
+		const { color, style, width } = normalizeBorderShorthand( value );
+
+		const border = {};
+
+		if ( color !== undefined ) {
+			border.color = { [ side ]: color };
+		}
+
+		if ( style !== undefined ) {
+			border.style = { [ side ]: style };
+		}
+
+		if ( width !== undefined ) {
+			border.width = { [ side ]: width };
+		}
+
+		return {
+			path: 'border',
+			value: border
+		};
+	};
+}
+
+function getBorderPropertyNormalizer( propertyName ) {
+	return value => {
+		return {
+			path: 'border',
+			value: toBorderPropertyShorthand( value, propertyName )
+		};
+	};
+}
+
+function toBorderPropertyShorthand( value, property ) {
+	return {
+		[ property ]: getTopRightBottomLeftValues( value )
+	};
+}
+
+function getBorderPropertyPositionNormalizer( property, side ) {
+	return value => {
+		return {
+			path: 'border',
+			value: {
+				[ property ]: {
+					[ side ]: value
+				}
+			}
+		};
+	};
+}
+
+function getBorderPositionExtractor( which ) {
+	return ( name, styles ) => {
+		if ( styles.border ) {
+			return extractBorderPosition( styles.border, which );
+		}
+	};
+}
+
+function extractBorderPosition( border, which ) {
+	const value = {};
+
+	if ( border.width && border.width[ which ] ) {
+		value.width = border.width[ which ];
+	}
+
+	if ( border.style && border.style[ which ] ) {
+		value.style = border.style[ which ];
+	}
+
+	if ( border.color && border.color[ which ] ) {
+		value.color = border.color[ which ];
+	}
+
+	return value;
+}
+
+function normalizeBorderShorthand( string ) {
+	const result = {};
+
+	const parts = getShorthandValues( string );
+
+	for ( const part of parts ) {
+		if ( isLength( part ) || /thin|medium|thick/.test( part ) ) {
+			result.width = part;
+		} else if ( isLineStyle( part ) ) {
+			result.style = part;
+		} else {
+			result.color = part;
+		}
+	}
+
+	return result;
+}
+
+function borderReducer( value ) {
+	const reduced = [];
+
+	reduced.push( ...reduceBorderPosition( extractBorderPosition( value, 'top' ), 'top' ) );
+	reduced.push( ...reduceBorderPosition( extractBorderPosition( value, 'right' ), 'right' ) );
+	reduced.push( ...reduceBorderPosition( extractBorderPosition( value, 'bottom' ), 'bottom' ) );
+	reduced.push( ...reduceBorderPosition( extractBorderPosition( value, 'left' ), 'left' ) );
+
+	return reduced;
+}
+
+function getBorderPositionReducer( which ) {
+	return value => reduceBorderPosition( value, which );
+}
+
+function reduceBorderPosition( value, which ) {
+	const reduced = [];
+
+	if ( value && value.width !== undefined ) {
+		reduced.push( value.width );
+	}
+
+	if ( value && value.style !== undefined ) {
+		reduced.push( value.style );
+	}
+
+	if ( value && value.color !== undefined ) {
+		reduced.push( value.color );
+	}
+
+	if ( reduced.length ) {
+		return [ [ `border-${ which }`, reduced.join( ' ' ) ] ];
+	}
+
+	return [];
+}

+ 41 - 0
packages/ckeditor5-engine/src/view/styles/margin.js

@@ -0,0 +1,41 @@
+/**
+ * @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 engine/view/styles/margin
+ */
+
+import { getPositionShorthandNormalizer, getTopRightBottomLeftValueReducer } from './utils';
+
+/**
+ * Adds a margin CSS styles processing rules.
+ *
+ *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+ *
+ * The normalized value is stored as:
+ *
+ *		const styles = {
+ *			margin: {
+ *				top,
+ *				right,
+ *				bottom,
+ *				left
+ *			}
+ *		};
+ *
+ * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor
+ */
+export function addMarginRules( stylesProcessor ) {
+	stylesProcessor.setNormalizer( 'margin', getPositionShorthandNormalizer( 'margin' ) );
+
+	stylesProcessor.setNormalizer( 'margin-top', value => ( { path: 'margin.top', value } ) );
+	stylesProcessor.setNormalizer( 'margin-right', value => ( { path: 'margin.right', value } ) );
+	stylesProcessor.setNormalizer( 'margin-bottom', value => ( { path: 'margin.bottom', value } ) );
+	stylesProcessor.setNormalizer( 'margin-left', value => ( { path: 'margin.left', value } ) );
+
+	stylesProcessor.setReducer( 'margin', getTopRightBottomLeftValueReducer( 'margin' ) );
+
+	stylesProcessor.setStyleRelation( 'margin', [ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ] );
+}

+ 40 - 0
packages/ckeditor5-engine/src/view/styles/padding.js

@@ -0,0 +1,40 @@
+/**
+ * @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 engine/view/styles/padding
+ */
+
+import { getPositionShorthandNormalizer, getTopRightBottomLeftValueReducer } from './utils';
+
+/**
+ * Adds a margin CSS styles processing rules.
+ *
+ *		editor.editing.view.document.addStyleProcessorRules( addPaddingRules );
+ *
+ * The normalized value is stored as:
+ *
+ *		const styles = {
+ *			padding: {
+ *				top,
+ *				right,
+ *				bottom,
+ *				left
+ *			}
+ *		};
+ *
+ * @param {module:engine/view/stylesmap~StylesProcessor} stylesProcessor
+ */
+export function addPaddingRules( stylesProcessor ) {
+	stylesProcessor.setNormalizer( 'padding', getPositionShorthandNormalizer( 'padding' ) );
+	stylesProcessor.setNormalizer( 'padding-top', value => ( { path: 'padding.top', value } ) );
+	stylesProcessor.setNormalizer( 'padding-right', value => ( { path: 'padding.right', value } ) );
+	stylesProcessor.setNormalizer( 'padding-bottom', value => ( { path: 'padding.bottom', value } ) );
+	stylesProcessor.setNormalizer( 'padding-left', value => ( { path: 'padding.left', value } ) );
+
+	stylesProcessor.setReducer( 'padding', getTopRightBottomLeftValueReducer( 'padding' ) );
+
+	stylesProcessor.setStyleRelation( 'padding', [ 'padding-top', 'padding-right', 'padding-bottom', 'padding-left' ] );
+}

+ 204 - 0
packages/ckeditor5-engine/src/view/styles/utils.js

@@ -0,0 +1,204 @@
+/**
+ * @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 engine/view/styles/utils
+ */
+
+const colorRegExp = /^([#0-9A-Fa-f]{3,9}$|0$|rgba?\(|hsla?\(|[a-zA-Z]+$)/;
+
+/**
+ * Checks if string contains [color](https://developer.mozilla.org/en-US/docs/Web/CSS/color) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isColor( string ) {
+	return colorRegExp.test( string );
+}
+
+const lineStyleValues = [ 'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset' ];
+
+/**
+ * Checks if string contains [line style](https://developer.mozilla.org/en-US/docs/Web/CSS/border-style) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isLineStyle( string ) {
+	return lineStyleValues.includes( string );
+}
+
+const lengthRegExp = /^([+-]?[0-9]*[.]?[0-9]+([a-z]+|%)|0)$/;
+
+/**
+ * Checks if string contains [length](https://developer.mozilla.org/en-US/docs/Web/CSS/length) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isLength( string ) {
+	return lengthRegExp.test( string );
+}
+
+const repeatValues = [ 'repeat-x', 'repeat-y', 'repeat', 'space', 'round', 'no-repeat' ];
+
+/**
+ * Checks if string contains [background repeat](https://developer.mozilla.org/en-US/docs/Web/CSS/background-repeat) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isRepeat( string ) {
+	return repeatValues.includes( string );
+}
+
+const positionValues = [ 'center', 'top', 'bottom', 'left', 'right' ];
+
+/**
+ * Checks if string contains [background position](https://developer.mozilla.org/en-US/docs/Web/CSS/background-position) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isPosition( string ) {
+	return positionValues.includes( string );
+}
+
+const attachmentValues = [ 'fixed', 'scroll', 'local' ];
+
+/**
+ * Checks if string contains [background attachment](https://developer.mozilla.org/en-US/docs/Web/CSS/background-attachment) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isAttachment( string ) {
+	return attachmentValues.includes( string );
+}
+
+const urlRegExp = /^url\(/;
+
+/**
+ * Checks if string contains [URL](https://developer.mozilla.org/en-US/docs/Web/CSS/url) CSS value.
+ *
+ * @param {String} string
+ * @returns {Boolean}
+ */
+export function isURL( string ) {
+	return urlRegExp.test( string );
+}
+
+export function getTopRightBottomLeftValues( value = '' ) {
+	if ( value === '' ) {
+		return { top: undefined, right: undefined, bottom: undefined, left: undefined };
+	}
+
+	const values = getShorthandValues( value );
+
+	const top = values[ 0 ];
+	const bottom = values[ 2 ] || top;
+	const right = values[ 1 ] || top;
+	const left = values[ 3 ] || right;
+
+	return { top, bottom, right, left };
+}
+
+/**
+ * Default reducer for CSS properties that concerns edges of a box
+ * [shorthand](https://developer.mozilla.org/en-US/docs/Web/CSS/Shorthand_properties) notations:
+ *
+ *		stylesProcessor.setReducer( 'padding', getTopRightBottomLeftValueReducer( 'padding' ) );
+ *
+ * @param {String} styleShorthand
+ * @returns {Function}
+ */
+export function getTopRightBottomLeftValueReducer( styleShorthand ) {
+	return value => {
+		const { top, right, bottom, left } = value;
+
+		const reduced = [];
+
+		if ( ![ top, right, left, bottom ].every( value => !!value ) ) {
+			if ( top ) {
+				reduced.push( [ styleShorthand + '-top', top ] );
+			}
+
+			if ( right ) {
+				reduced.push( [ styleShorthand + '-right', right ] );
+			}
+
+			if ( bottom ) {
+				reduced.push( [ styleShorthand + '-bottom', bottom ] );
+			}
+
+			if ( left ) {
+				reduced.push( [ styleShorthand + '-left', left ] );
+			}
+		} else {
+			reduced.push( [ styleShorthand, getTopRightBottomLeftShorthandValue( value ) ] );
+		}
+
+		return reduced;
+	};
+}
+
+/**
+ * Returns a proper 1-to-4 value of a CSS [shorthand](https://developer.mozilla.org/en-US/docs/Web/CSS/Shorthand_properties) notation.
+ *
+ *		getTopRightBottomLeftShorthandValue( { top: '1px', right: '1px', bottom: '2px', left: '1px' } );
+ *		// will return '1px 1px 2px'
+ *
+ * @param {String} styleShorthand
+ * @returns {Function}
+ */
+export function getTopRightBottomLeftShorthandValue( { top, right, bottom, left } ) {
+	const out = [];
+
+	if ( left !== right ) {
+		out.push( top, right, bottom, left );
+	} else if ( bottom !== top ) {
+		out.push( top, right, bottom );
+	} else if ( right !== top ) {
+		out.push( top, right );
+	} else {
+		out.push( top );
+	}
+
+	return out.join( ' ' );
+}
+
+/**
+ * Creates a normalizer for a [shorthand](https://developer.mozilla.org/en-US/docs/Web/CSS/Shorthand_properties) 1-to-4 value.
+ *
+ *		stylesProcessor.setNormalizer( 'margin', getPositionShorthandNormalizer( 'margin' ) );
+ *
+ * @param {String} shorthand
+ * @returns {Function}
+ */
+export function getPositionShorthandNormalizer( shorthand ) {
+	return value => {
+		return {
+			path: shorthand,
+			value: getTopRightBottomLeftValues( value )
+		};
+	};
+}
+
+/**
+ * Parses parts of a 1-to-4 value notation - handles some CSS values with spaces (like RGB()).
+ *
+ *		getShorthandValues( 'red blue RGB(0, 0, 0)');
+ *		// will return [ 'red', 'blue', 'RGB(0, 0, 0)' ]
+ *
+ * @param {String} string
+ * @returns {Array.<String>}
+ */
+export function getShorthandValues( string ) {
+	return string
+		.replace( /, /g, ',' ) // Exclude comma from spaces evaluation as values are separated by spaces.
+		.split( ' ' )
+		.map( string => string.replace( /,/g, ', ' ) ); // Restore original notation.
+}

+ 887 - 0
packages/ckeditor5-engine/src/view/stylesmap.js

@@ -0,0 +1,887 @@
+/**
+ * @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 engine/view/stylesmap
+ */
+
+import { get, isObject, merge, set, unset } from 'lodash-es';
+
+/**
+ * Styles map. Allows handling (adding, removing, retrieving) a set of style rules (usually, of an element).
+ *
+ * The styles map is capable of normalizing style names so e.g. the following operations are possible:
+ */
+export default class StylesMap {
+	/**
+	 * Creates Styles instance.
+	 */
+	constructor() {
+		/**
+		 * Keeps an internal representation of styles map. Normalized styles are kept as object tree to allow unified modification and
+		 * value access model using lodash's get, set, unset, etc methods.
+		 *
+		 * When no style processor rules are defined the it acts as simple key-value storage.
+		 *
+		 * @type {Object}
+		 * @private
+		 */
+		this._styles = {};
+
+		// Hide _styleProcessor from the watchdog by making this property non-enumarable. Watchdog checks errors for their editor origin
+		// by checking if two objects are connected through properties. Using singleton is against this check as it would detect
+		// that two editors are connected through single style processor instance.
+		Object.defineProperty( this, '_styleProcessor', {
+			get() {
+				return StylesMap._styleProcessor;
+			},
+			enumerable: false
+		} );
+	}
+
+	/**
+	 * Returns true if style map has no styles set.
+	 *
+	 * @returns {Boolean}
+	 */
+	get isEmpty() {
+		const entries = Object.entries( this._styles );
+		const from = Array.from( entries );
+
+		return !from.length;
+	}
+
+	/**
+	 * Number of styles defined.
+	 *
+	 * @type {Number}
+	 */
+	get size() {
+		if ( this.isEmpty ) {
+			return 0;
+		}
+
+		return this.getStyleNames().length;
+	}
+
+	/**
+	 * Set styles map to a new value.
+	 *
+	 *		styles.setTo( 'border:1px solid blue;margin-top:1px;' );
+	 *
+	 * @param {String} inlineStyle
+	 */
+	setTo( inlineStyle ) {
+		this.clear();
+
+		const parsedStyles = Array.from( parseInlineStyles( inlineStyle ).entries() );
+
+		for ( const [ key, value ] of parsedStyles ) {
+			this._styleProcessor.toNormalizedForm( key, value, this._styles );
+		}
+	}
+
+	/**
+	 * Checks if a given style is set.
+	 *
+	 *		styles.setTo( 'margin-left:1px;' );
+	 *
+	 *		styles.has( 'margin-left' );    // -> true
+	 *		styles.has( 'padding' );        // -> false
+	 *
+	 * **Note**: This check supports normalized style names.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		styles.setTo( 'margin:2px;' );
+	 *
+	 *		styles.has( 'margin' );         // -> true
+	 *		styles.has( 'margin-top' );     // -> true
+	 *		styles.has( 'margin-left' );    // -> true
+	 *
+	 *		styles.remove( 'margin-top' );
+	 *
+	 *		styles.has( 'margin' );         // -> false
+	 *		styles.has( 'margin-top' );     // -> false
+	 *		styles.has( 'margin-left' );    // -> true
+	 *
+	 * @param {String} name Style name.
+	 * @returns {Boolean}
+	 */
+	has( name ) {
+		if ( this.isEmpty ) {
+			return false;
+		}
+
+		const styles = this._styleProcessor.getReducedForm( name, this._styles );
+
+		const propertyDescriptor = styles.find( ( [ property ] ) => property === name );
+
+		// Only return a value if it is set;
+		return Array.isArray( propertyDescriptor );
+	}
+
+	/**
+	 * Sets a given style.
+	 *
+	 * Can insert one by one:
+	 *
+	 *		styles.set( 'color', 'blue' );
+	 *		styles.set( 'margin-right', '1em' );
+	 *
+	 * or many styles at once:
+	 *
+	 *		styles.set( {
+	 *			color: 'blue',
+	 *			'margin-right': '1em'
+	 *		} );
+	 *
+	 * ***Note**:* This method uses {@link module:engine/view/document~Document#addStyleProcessorRules enabled style processor rules}
+	 * to normalize passed values.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		styles.set( 'margin', '2px' );
+	 *
+	 * The above code will set margin to:
+	 *
+	 *		styles.getNormalized( 'margin' );
+	 *		// -> { top: '2px', right: '2px', bottom: '2px', left: '2px' }
+	 *
+	 * Which makes it possible to retrieve a "sub-value":
+	 *
+	 *		styles.get( 'margin-left' );       // -> '2px'
+	 *
+	 * Or modify it:
+	 *
+	 *		styles.remove( 'margin-left' );
+	 *
+	 *		styles.getNormalized( 'margin' );  // -> { top: '1px', bottom: '1px', right: '1px' }
+	 *		styles.toString();                 // -> 'margin-bottom:1px;margin-right:1px;margin-top:1px;'
+	 *
+	 * This method also allows to set normalized values directly (if a particular styles processor rule was enabled):
+	 *
+	 *		styles.set( 'border-color', { top: 'blue' } );
+	 *		styles.set( 'margin', { right: '2em' } );
+	 *
+	 *		styles.toString();                 // -> 'border-color-top:blue;margin-right:2em;'
+	 *
+	 * @param {String|Object} nameOrObject Style property name or object with multiple properties.
+	 * @param {String|Object} valueOrObject Value to set.
+	 */
+	set( nameOrObject, valueOrObject ) {
+		if ( isObject( nameOrObject ) ) {
+			for ( const [ key, value ] of Object.entries( nameOrObject ) ) {
+				this._styleProcessor.toNormalizedForm( key, value, this._styles );
+			}
+		} else {
+			this._styleProcessor.toNormalizedForm( nameOrObject, valueOrObject, this._styles );
+		}
+	}
+
+	/**
+	 * Removes given style.
+	 *
+	 *		styles.setTo( 'background:#f00;margin-right:2px;' );
+	 *
+	 *		styles.remove( 'background' );
+	 *
+	 *		styles.toString();   // -> 'margin-right:2px;'
+	 *
+	 * ***Note**:* This method uses {@link module:engine/view/document~Document#addStyleProcessorRules enabled style processor rules}
+	 * to normalize passed values.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		styles.setTo( 'margin:1px' );
+	 *
+	 *		styles.remove( 'margin-top' );
+	 *		styles.remove( 'margin-right' );
+	 *
+	 *		styles.toString(); // -> 'margin-bottom:1px;margin-left:1px;'
+	 *
+	 * @param {String} name Style name.
+	 */
+	remove( name ) {
+		unset( this._styles, toPath( name ) );
+		delete this._styles[ name ];
+	}
+
+	/**
+	 * Returns a normalized style object or a single value.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		const styles = new Styles();
+	 *		styles.setTo( 'margin:1px 2px 3em;' );
+	 *
+	 *		styles.getNormalized( 'margin' );
+	 *		// will log:
+	 *		// {
+	 *		//     top: '1px',
+	 *		//     right: '2px',
+	 *		//     bottom: '3em',
+	 *		//     left: '2px'     // normalized value from margin shorthand
+	 *		// }
+	 *
+	 *		styles.getNormalized( 'margin-left' ); // -> '2px'
+	 *
+	 * **Note**: This method will only return normalized styles if a style processor was defined.
+	 *
+	 * @param {String} name Style name.
+	 * @returns {Object|String|undefined}
+	 */
+	getNormalized( name ) {
+		return this._styleProcessor.getNormalized( name, this._styles );
+	}
+
+	/**
+	 * Returns a normalized style string. Styles are sorted by name.
+	 *
+	 *		styles.set( 'margin' , '1px' );
+	 *		styles.set( 'background', '#f00' );
+	 *
+	 *		styles.toString(); // -> 'background:#f00;margin:1px;'
+	 *
+	 * **Note**: This method supports normalized styles if defined.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		styles.set( 'margin' , '1px' );
+	 *		styles.set( 'background', '#f00' );
+	 *		styles.remove( 'margin-top' );
+	 *		styles.remove( 'margin-right' );
+	 *
+	 *		styles.toString(); // -> 'background:#f00;margin-bottom:1px;margin-left:1px;'
+	 *
+	 * @returns {String}
+	 */
+	toString() {
+		if ( this.isEmpty ) {
+			return '';
+		}
+
+		return this._getStylesEntries()
+			.map( arr => arr.join( ':' ) )
+			.sort()
+			.join( ';' ) + ';';
+	}
+
+	/**
+	 * Returns property as a value string or undefined if property is not set.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		const styles = new Styles();
+	 *		styles.setTo( 'margin:1px;' );
+	 *		styles.set( 'margin-bottom', '3em' );
+	 *
+	 *		styles.getAsString( 'margin' ); // -> 'margin: 1px 1px 3em;'
+	 *
+	 * Note, however, that all sub-values must be set for the longhand property name to return a value:
+	 *
+	 *		const styles = new Styles();
+	 *		styles.setTo( 'margin:1px;' );
+	 *		styles.remove( 'margin-bottom' );
+	 *
+	 *		styles.getAsString( 'margin' ); // -> undefined
+	 *
+	 * In the above scenario, it is not possible to return a `margin` value, so `undefined` is returned.
+	 * Instead, you should use:
+	 *
+	 *		const styles = new Styles();
+	 *		styles.setTo( 'margin:1px;' );
+	 *		styles.remove( 'margin-bottom' );
+	 *
+	 *		for ( const styleName of styles.getStyleNames() ) {
+	 *			console.log( styleName, styles.getAsString( styleName ) );
+	 *		}
+	 *		// 'margin-top', '1px'
+	 *		// 'margin-right', '1px'
+	 *		// 'margin-left', '1px'
+	 *
+	 * In general, it is recommend to iterate over style names like in the example above. This way, you will always get all
+	 * the currently set style values. So, if all the 4 margin values would be set
+	 * the for-of loop above would yield only `'margin'`, `'1px'`:
+	 *
+	 *		const styles = new Styles();
+	 *		styles.setTo( 'margin:1px;' );
+	 *
+	 *		for ( const styleName of styles.getStyleNames() ) {
+	 *			console.log( styleName, styles.getAsString( styleName ) );
+	 *		}
+	 *		// 'margin', '1px'
+	 *
+	 * **Note**: To get a normalized version of a longhand property use the {@link #getNormalized `#getNormalized()`} method.
+	 *
+	 * @param {String} propertyName
+	 * @returns {String|undefined}
+	 */
+	getAsString( propertyName ) {
+		if ( this.isEmpty ) {
+			return;
+		}
+
+		if ( this._styles[ propertyName ] && !isObject( this._styles[ propertyName ] ) ) {
+			// Try return styles set directly - values that are not parsed.
+			return this._styles[ propertyName ];
+		}
+
+		const styles = this._styleProcessor.getReducedForm( propertyName, this._styles );
+
+		const propertyDescriptor = styles.find( ( [ property ] ) => property === propertyName );
+
+		// Only return a value if it is set;
+		if ( Array.isArray( propertyDescriptor ) ) {
+			return propertyDescriptor[ 1 ];
+		}
+	}
+
+	/**
+	 * Returns style property names as they would appear when using {@link #toString `#toString()`}.
+	 *
+	 * @returns {Array.<String>}
+	 */
+	getStyleNames() {
+		if ( this.isEmpty ) {
+			return [];
+		}
+
+		const entries = this._getStylesEntries();
+
+		return entries.map( ( [ key ] ) => key );
+	}
+
+	/**
+	 * Removes all styles.
+	 */
+	clear() {
+		this._styles = {};
+	}
+
+	/**
+	 * Returns related style names.
+	 *
+	 *		// Enable 'margin' shorthand processing:
+	 *		editor.editing.view.document.addStyleProcessorRules( addMarginRules );
+	 *
+	 *		StylesMap.getRelatedStyles( 'margin' );
+	 *		// will return: [ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ];
+	 *
+	 *		StylesMap.getRelatedStyles( 'margin-top' );
+	 *		// will return: [ 'margin' ];
+	 *
+	 * **Note**: To define new style relations load an existing style processor (as shown above) or use
+	 * {@link module:engine/view/stylesmap~StylesProcessor#setStyleRelation `StylesProcessor.setStyleRelation()`}.
+	 *
+	 * @param {String} name
+	 * @returns {Array.<String>}
+	 */
+	static getRelatedStyles( name ) {
+		return this._styleProcessor.getRelatedStyles( name );
+	}
+
+	/**
+	 * Returns normalized styles entries for further processing.
+	 *
+	 * @private
+	 * @returns {Array.<module:engine/view/stylesmap~PropertyDescriptor>}
+	 */
+	_getStylesEntries() {
+		const parsed = [];
+
+		const keys = Object.keys( this._styles );
+
+		for ( const key of keys ) {
+			parsed.push( ...this._styleProcessor.getReducedForm( key, this._styles ) );
+		}
+
+		return parsed;
+	}
+
+	/**
+	 * Returns global StylesProcessor instance.
+	 *
+	 * @returns {module:engine/view/stylesmap~StylesProcessor}
+	 * @private
+	 */
+	static get _styleProcessor() {
+		if ( !this._processor ) {
+			this._processor = new StylesProcessor();
+		}
+
+		return this._processor;
+	}
+
+	/**
+	 * Set new StylesProcessor instance.
+	 *
+	 * This is an internal method used mostly in tests.
+	 *
+	 * @param processor
+	 * @protected
+	 */
+	static _setProcessor( processor ) {
+		this._processor = processor;
+	}
+}
+
+/**
+ * Style processor is responsible for writing and reading a normalized styles object.
+ */
+export class StylesProcessor {
+	/**
+	 * Creates StylesProcessor instance.
+	 *
+	 * @private
+	 */
+	constructor() {
+		this._normalizers = new Map();
+		this._extractors = new Map();
+		this._reducers = new Map();
+		this._consumables = new Map();
+	}
+
+	/**
+	 * Parse style string value to a normalized object and appends it to styles object.
+	 *
+	 *		const styles = {};
+	 *
+	 *		stylesProcessor.toNormalizedForm( 'margin', '1px', styles );
+	 *
+	 *		// styles will consist: { margin: { top: '1px', right: '1px', bottom: '1px', left: '1px; } }
+	 *
+	 * **Note**: To define normalizer callbacks use {@link #setNormalizer}.
+	 *
+	 * @param {String} name Name of style property.
+	 * @param {String} propertyValue Value of style property.
+	 * @param {Object} styles Object holding normalized styles.
+	 */
+	toNormalizedForm( name, propertyValue, styles ) {
+		if ( isObject( propertyValue ) ) {
+			appendStyleValue( styles, toPath( name ), propertyValue );
+
+			return;
+		}
+
+		if ( this._normalizers.has( name ) ) {
+			const normalizer = this._normalizers.get( name );
+
+			const { path, value } = normalizer( propertyValue );
+
+			appendStyleValue( styles, path, value );
+		} else {
+			appendStyleValue( styles, name, propertyValue );
+		}
+	}
+
+	/**
+	 * Returns a normalized version of a style property.
+	 *		const styles = {
+	 *			margin: { top: '1px', right: '1px', bottom: '1px', left: '1px; },
+	 *			background: { color: '#f00' }
+	 *		};
+	 *
+	 *		stylesProcessor.getNormalized( 'background' );
+	 *		// will return: { color: '#f00' }
+	 *
+	 *		stylesProcessor.getNormalized( 'margin-top' );
+	 *		// will return: '1px'
+	 *
+	 * **Note**: In some cases extracting single value requires defining an extractor callback {@link #setExtractor}.
+	 *
+	 * @param {String} name Name of style property.
+	 * @param {Object} styles Object holding normalized styles.
+	 * @returns {*}
+	 */
+	getNormalized( name, styles ) {
+		if ( !name ) {
+			return merge( {}, styles );
+		}
+
+		// Might be empty string.
+		if ( styles[ name ] !== undefined ) {
+			return styles[ name ];
+		}
+
+		if ( this._extractors.has( name ) ) {
+			const extractor = this._extractors.get( name );
+
+			if ( typeof extractor === 'string' ) {
+				return get( styles, extractor );
+			}
+
+			const value = extractor( name, styles );
+
+			if ( value ) {
+				return value;
+			}
+		}
+
+		return get( styles, toPath( name ) );
+	}
+
+	/**
+	 * Returns a reduced form of style property form normalized object.
+	 *
+	 * For default margin reducer, the below code:
+	 *
+	 *		stylesProcessor.getReducedForm( 'margin', {
+	 *			margin: { top: '1px', right: '1px', bottom: '2px', left: '1px; }
+	 *		} );
+	 *
+	 * will return:
+	 *
+	 *		[
+	 *			[ 'margin', '1px 1px 2px' ]
+	 *		]
+	 *
+	 * because it might be represented as a shorthand 'margin' value. However if one of margin long hand values is missing it should return:
+	 *
+	 *		[
+	 *			[ 'margin-top', '1px' ],
+	 *			[ 'margin-right', '1px' ],
+	 *			[ 'margin-bottom', '2px' ]
+	 *			// the 'left' value is missing - cannot use 'margin' shorthand.
+	 *		]
+	 *
+	 * **Note**: To define reducer callbacks use {@link #setReducer}.
+	 *
+	 * @param {String} name
+	 * @param {String} name Name of style property.
+	 * @param {Object} styles Object holding normalized styles.
+	 * @returns {Array.<module:engine/view/stylesmap~PropertyDescriptor>}
+	 */
+	getReducedForm( name, styles ) {
+		const normalizedValue = this.getNormalized( name, styles );
+
+		// Might be empty string.
+		if ( normalizedValue === undefined ) {
+			return [];
+		}
+
+		if ( this._reducers.has( name ) ) {
+			const reducer = this._reducers.get( name );
+
+			return reducer( normalizedValue );
+		}
+
+		return [ [ name, normalizedValue ] ];
+	}
+
+	/**
+	 * Returns related style names.
+	 *
+	 *		stylesProcessor.getRelatedStyles( 'margin' );
+	 *		// will return: [ 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ];
+	 *
+	 *		stylesProcessor.getRelatedStyles( 'margin-top' );
+	 *		// will return: [ 'margin' ];
+	 *
+	 * **Note**: To define new style relations load an existing style processor or use
+	 * {@link module:engine/view/stylesmap~StylesProcessor#setStyleRelation `StylesProcessor.setStyleRelation()`}.
+	 *
+	 * @param {String} name
+	 * @returns {Array.<String>}
+	 */
+	getRelatedStyles( name ) {
+		return this._consumables.get( name ) || [];
+	}
+
+	/**
+	 * Adds a normalizer method for a style property.
+	 *
+	 * A normalizer returns describing how the value should be normalized.
+	 *
+	 * For instance 'margin' style is a shorthand for four margin values:
+	 *
+	 * - 'margin-top'
+	 * - 'margin-right'
+	 * - 'margin-bottom'
+	 * - 'margin-left'
+	 *
+	 * and can be written in various ways if some values are equal to others. For instance `'margin: 1px 2em;'` is a shorthand for
+	 * `'margin-top: 1px;margin-right: 2em;margin-bottom: 1px;margin-left: 2em'`.
+	 *
+	 * A normalizer should parse various margin notations as a single object:
+	 *
+	 *		const styles = {
+	 *			margin: {
+	 *				top: '1px',
+	 *				right: '2em',
+	 *				bottom: '1px',
+	 *				left: '2em'
+	 *			}
+	 *		};
+	 *
+	 * Thus a normalizer for 'margin' style should return an object defining style path and value to store:
+	 *
+	 *		const returnValue = {
+	 *			path: 'margin',
+	 *			value: {
+	 *				top: '1px',
+	 *				right: '2em',
+	 *				bottom: '1px',
+	 *				left: '2em'
+	 *			}
+	 *		};
+	 *
+	 * Additionally to fully support all margin notations there should be also defined 4 normalizers for longhand margin notations. Below
+	 * is an example for 'margin-top' style property normalizer:
+	 *
+	 *		stylesProcessor.setNormalizer( 'margin-top', valueString => {
+	 *			return {
+	 *				path: 'margin.top',
+	 *				value: valueString
+	 *			}
+	 *		} );
+	 *
+	 * @param {String} name
+	 * @param {Function} callback
+	 */
+	setNormalizer( name, callback ) {
+		this._normalizers.set( name, callback );
+	}
+
+	/**
+	 * Adds a extractor callback for a style property.
+	 *
+	 * Most normalized style values are stored as one level objects. It is assumed that `'margin-top'` style will be stored as:
+	 *
+	 *		const styles = {
+	 *			margin: {
+	 *				top: 'value'
+	 *			}
+	 *		}
+	 *
+	 * However, some styles can have conflicting notations and thus it might be harder to extract a style value from shorthand. For instance
+	 * the 'border-top-style' can be defined using `'border-top:solid'`, `'border-style:solid none none none'` or by `'border:solid'`
+	 * shorthands. The default border styles processors stores styles as:
+	 *
+	 *		const styles = {
+	 *			border: {
+	 *				style: {
+	 *					top: 'solid'
+	 *				}
+	 *			}
+	 *		}
+	 *
+	 * as it is better to modify border style independently from other values. On the other part the output of the border might be
+	 * desired as `border-top`, `border-left`, etc notation.
+	 *
+	 * In the above example a reducer should return a side border value that combines style, color and width:
+	 *
+	 *		styleProcessor.setExtractor( 'border-top', styles => {
+	 *			return {
+	 *				color: styles.border.color.top,
+	 *				style: styles.border.style.top,
+	 *				width: styles.border.width.top
+	 *			}
+	 *		} );
+	 *
+	 * @param {String} name
+	 * @param {Function|String} callbackOrPath Callback that return a requested value or path string for single values.
+	 */
+	setExtractor( name, callbackOrPath ) {
+		this._extractors.set( name, callbackOrPath );
+	}
+
+	/**
+	 * Adds a reducer callback for a style property.
+	 *
+	 * Reducer returns a minimal notation for given style name. For longhand properties it is not required to write a reducer as
+	 * by default the direct value from style path is taken.
+	 *
+	 * For shorthand styles a reducer should return minimal style notation either by returning single name-value tuple or multiple tuples
+	 * if a shorthand cannot be used. For instance for a margin shorthand a reducer might return:
+	 *
+	 *		const marginShortHandTuple = [
+	 *			[ 'margin', '1px 1px 2px' ]
+	 *		];
+	 *
+	 * or a longhand tuples for defined values:
+	 *
+	 *		// Considering margin.bottom and margin.left are undefined.
+	 *		const marginLonghandsTuples = [
+	 *			[ 'margin-top', '1px' ],
+	 *			[ 'margin-right', '1px' ]
+	 *		];
+	 *
+	 * A reducer obtains a normalized style value:
+	 *
+	 *		// Simplified reducer that always outputs 4 values which are always present:
+	 *		stylesProcessor.setReducer( 'margin', margin => {
+	 *			return [
+	 *				[ 'margin', `${ margin.top } ${ margin.right } ${ margin.bottom } ${ margin.left }` ]
+	 *			]
+	 *		} );
+	 *
+	 * @param {String} name
+	 * @param {Function} callback
+	 */
+	setReducer( name, callback ) {
+		this._reducers.set( name, callback );
+	}
+
+	/**
+	 * Defines a style shorthand relation to other style notations.
+	 *
+	 *		stylesProcessor.setStyleRelation( 'margin', [
+	 *			'margin-top',
+	 *			'margin-right',
+	 *			'margin-bottom',
+	 *			'margin-left'
+	 *		] );
+	 *
+	 * This enables expanding of style names for shorthands. For instance, if defined,
+	 * {@link module:engine/conversion/viewconsumable~ViewConsumable view consumable} items are automatically created
+	 * for long-hand margin style notation alongside the `'margin'` item.
+	 *
+	 * This means that when an element being converted has a style `margin`, a converter for `margin-left` will work just
+	 * fine since the view consumable will contain a consumable `margin-left` item (thanks to the relation) and
+	 * `element.getStyle( 'margin-left' )` will work as well assuming that the style processor was correctly configured.
+	 * However, once `margin-left` is consumed, `margin` will not be consumable anymore.
+	 *
+	 * @param {String} shorthandName
+	 * @param {Array.<String>} styleNames
+	 */
+	setStyleRelation( shorthandName, styleNames ) {
+		this._mapStyleNames( shorthandName, styleNames );
+
+		for ( const alsoName of styleNames ) {
+			this._mapStyleNames( alsoName, [ shorthandName ] );
+		}
+	}
+
+	/**
+	 * Set two-way binding of style names.
+	 *
+	 * @param {String} name
+	 * @param {Array.<String>} styleNames
+	 * @private
+	 */
+	_mapStyleNames( name, styleNames ) {
+		if ( !this._consumables.has( name ) ) {
+			this._consumables.set( name, [] );
+		}
+
+		this._consumables.get( name ).push( ...styleNames );
+	}
+}
+
+// Parses inline styles and puts property - value pairs into styles map.
+//
+// @param {String} stylesString Styles to parse.
+// @returns {Map.<String, String>} stylesMap Map of parsed properties and values.
+function parseInlineStyles( stylesString ) {
+	// `null` if no quote was found in input string or last found quote was a closing quote. See below.
+	let quoteType = null;
+	let propertyNameStart = 0;
+	let propertyValueStart = 0;
+	let propertyName = null;
+
+	const stylesMap = new Map();
+
+	// Do not set anything if input string is empty.
+	if ( stylesString === '' ) {
+		return stylesMap;
+	}
+
+	// Fix inline styles that do not end with `;` so they are compatible with algorithm below.
+	if ( stylesString.charAt( stylesString.length - 1 ) != ';' ) {
+		stylesString = stylesString + ';';
+	}
+
+	// Seek the whole string for "special characters".
+	for ( let i = 0; i < stylesString.length; i++ ) {
+		const char = stylesString.charAt( i );
+
+		if ( quoteType === null ) {
+			// No quote found yet or last found quote was a closing quote.
+			switch ( char ) {
+				case ':':
+					// Most of time colon means that property name just ended.
+					// Sometimes however `:` is found inside property value (for example in background image url).
+					if ( !propertyName ) {
+						// Treat this as end of property only if property name is not already saved.
+						// Save property name.
+						propertyName = stylesString.substr( propertyNameStart, i - propertyNameStart );
+						// Save this point as the start of property value.
+						propertyValueStart = i + 1;
+					}
+
+					break;
+
+				case '"':
+				case '\'':
+					// Opening quote found (this is an opening quote, because `quoteType` is `null`).
+					quoteType = char;
+
+					break;
+
+				case ';': {
+					// Property value just ended.
+					// Use previously stored property value start to obtain property value.
+					const propertyValue = stylesString.substr( propertyValueStart, i - propertyValueStart );
+
+					if ( propertyName ) {
+						// Save parsed part.
+						stylesMap.set( propertyName.trim(), propertyValue.trim() );
+					}
+
+					propertyName = null;
+
+					// Save this point as property name start. Property name starts immediately after previous property value ends.
+					propertyNameStart = i + 1;
+
+					break;
+				}
+			}
+		} else if ( char === quoteType ) {
+			// If a quote char is found and it is a closing quote, mark this fact by `null`-ing `quoteType`.
+			quoteType = null;
+		}
+	}
+
+	return stylesMap;
+}
+
+// Return lodash compatible path from style name.
+function toPath( name ) {
+	return name.replace( '-', '.' );
+}
+
+// Appends style definition to the styles object.
+//
+// @param {String} nameOrPath
+// @param {String|Object} valueOrObject
+// @private
+function appendStyleValue( stylesObject, nameOrPath, valueOrObject ) {
+	let valueToSet = valueOrObject;
+
+	if ( isObject( valueOrObject ) ) {
+		valueToSet = merge( {}, get( stylesObject, nameOrPath ), valueOrObject );
+	}
+
+	set( stylesObject, nameOrPath, valueToSet );
+}
+
+/**
+ * A CSS style property descriptor that contains tuplet of two strings:
+ *
+ * - first string describes property name
+ * - second string describes property value
+ *
+ *		const marginDescriptor = [ 'margin', '2px 3em' ];
+ *		const marginTopDescriptor = [ 'margin-top', '2px' ];
+ *
+ * @typedef {Array.<String, String>} module:engine/view/stylesmap~PropertyDescriptor
+ */

+ 8 - 0
packages/ckeditor5-engine/src/view/upcastwriter.js

@@ -270,6 +270,10 @@ export default class UpcastWriter {
 	 *			position: 'fixed'
 	 *		} );
 	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#set `StylesMap#set()`} for details.
+	 *
 	 * @see module:engine/view/element~Element#_setStyle
 	 * @param {String|Object} property Property name or object with key - value pairs.
 	 * @param {String} [value] Value to set. This parameter is ignored if object is provided as the first parameter.
@@ -288,6 +292,10 @@ export default class UpcastWriter {
 	 *		writer.removeStyle( element, 'color' );  // Removes 'color' style.
 	 *		writer.removeStyle( element, [ 'color', 'border-top' ] ); // Removes both 'color' and 'border-top' styles.
 	 *
+	 * **Note**: This method can work with normalized style names if
+	 * {@link module:engine/view/document~Document#addStyleProcessorRules a particular style processor rule is enabled}.
+	 * See {@link module:engine/view/stylesmap~StylesMap#remove `StylesMap#remove()`} for details.
+	 *
 	 * @see module:engine/view/element~Element#_removeStyle
 	 * @param {Array.<String>|String} property Style property name or names to be removed.
 	 * @param {module:engine/view/element~Element} element Element from which style will be removed.

+ 81 - 0
packages/ckeditor5-engine/tests/conversion/viewconsumable.js

@@ -7,6 +7,10 @@ import ViewElement from '../../src/view/element';
 import ViewText from '../../src/view/text';
 import ViewDocumentFragment from '../../src/view/documentfragment';
 import ViewConsumable from '../../src/conversion/viewconsumable';
+import StylesMap, { StylesProcessor } from '../../src/view/stylesmap';
+import { addBorderRules } from '../../src/view/styles/border';
+import { addMarginRules } from '../../src/view/styles/margin';
+import { addPaddingRules } from '../../src/view/styles/padding';
 
 describe( 'ViewConsumable', () => {
 	let viewConsumable, el;
@@ -549,4 +553,81 @@ describe( 'ViewConsumable', () => {
 			expect( newConsumable.test( child3, { name: true, styles: 'top', classes: [ 'qux', 'bar' ] } ) ).to.be.true;
 		} );
 	} );
+
+	describe( 'style shorthands handling', () => {
+		before( () => {
+			const stylesProcessor = new StylesProcessor();
+
+			StylesMap._setProcessor( stylesProcessor );
+
+			addBorderRules( stylesProcessor );
+			addMarginRules( stylesProcessor );
+			addPaddingRules( stylesProcessor );
+		} );
+
+		describe( 'add', () => {
+			it( 'should add padding shorthands', () => {
+				viewConsumable.add( el, { styles: [ 'margin' ] } );
+
+				expect( viewConsumable.test( el, { styles: 'margin-top' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'margin-bottom' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'margin-right' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'margin-left' } ) ).to.be.true;
+			} );
+
+			it( 'should add margin shorthands', () => {
+				viewConsumable.add( el, { styles: [ 'padding' ] } );
+
+				expect( viewConsumable.test( el, { styles: 'padding-top' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'padding-bottom' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'padding-right' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'padding-left' } ) ).to.be.true;
+			} );
+
+			it( 'should add table shorthands', () => {
+				viewConsumable.add( el, { styles: [ 'border' ] } );
+
+				expect( viewConsumable.test( el, { styles: 'border-style' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-top-style' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-bottom-style' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-right-style' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-left-style' } ) ).to.be.true;
+
+				expect( viewConsumable.test( el, { styles: 'border-color' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-top-color' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-bottom-color' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-right-color' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-left-color' } ) ).to.be.true;
+
+				expect( viewConsumable.test( el, { styles: 'border-width' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-top-width' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-bottom-width' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-right-width' } ) ).to.be.true;
+				expect( viewConsumable.test( el, { styles: 'border-left-width' } ) ).to.be.true;
+			} );
+		} );
+
+		it( 'should return false when testing style shorthand for consumed longhand', () => {
+			viewConsumable.add( el, { styles: [ 'margin' ] } );
+
+			expect( viewConsumable.test( el, { styles: 'margin' } ) ).to.be.true;
+			viewConsumable.consume( el, { styles: 'margin' } );
+			expect( viewConsumable.test( el, { styles: 'margin-top' } ) ).to.be.false;
+			expect( viewConsumable.test( el, { styles: 'margin-bottom' } ) ).to.be.false;
+			expect( viewConsumable.test( el, { styles: 'margin-right' } ) ).to.be.false;
+			expect( viewConsumable.test( el, { styles: 'margin-left' } ) ).to.be.false;
+		} );
+
+		it( 'should return false when testing style shorthand for consumed shorthand', () => {
+			viewConsumable.add( el, { styles: [ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left' ] } );
+
+			expect( viewConsumable.test( el, { styles: 'margin-top' } ) ).to.be.true;
+			viewConsumable.consume( el, { styles: 'margin-top' } );
+			expect( viewConsumable.test( el, { styles: 'margin' } ) ).to.be.false;
+			expect( viewConsumable.test( el, { styles: 'margin-top' } ) ).to.be.false;
+			expect( viewConsumable.test( el, { styles: 'margin-bottom' } ) ).to.be.true;
+			expect( viewConsumable.test( el, { styles: 'margin-right' } ) ).to.be.true;
+			expect( viewConsumable.test( el, { styles: 'margin-left' } ) ).to.be.true;
+		} );
+	} );
 } );

+ 12 - 0
packages/ckeditor5-engine/tests/view/document.js

@@ -10,6 +10,7 @@ import Document from '../../src/view/document';
 import testUtils from '@ckeditor/ckeditor5-core/tests/_utils/utils';
 import count from '@ckeditor/ckeditor5-utils/src/count';
 import createViewRoot from './_utils/createroot';
+import StylesMap from '../../src/view/stylesmap';
 
 describe( 'Document', () => {
 	let domRoot, viewDocument;
@@ -91,4 +92,15 @@ describe( 'Document', () => {
 			expect( calls ).to.equal( 4 );
 		} );
 	} );
+
+	describe( 'addStyleProcessorRules()', () => {
+		it( 'should ', () => {
+			const spy = sinon.spy();
+
+			viewDocument.addStyleProcessorRules( spy );
+
+			sinon.assert.calledOnce( spy );
+			sinon.assert.calledWithExactly( spy, StylesMap._styleProcessor );
+		} );
+	} );
 } );

+ 31 - 85
packages/ckeditor5-engine/tests/view/element.js

@@ -9,8 +9,6 @@ import Element from '../../src/view/element';
 import Text from '../../src/view/text';
 import TextProxy from '../../src/view/textproxy';
 
-import encodedImage from './_utils/encodedimage.txt';
-
 describe( 'Element', () => {
 	describe( 'constructor()', () => {
 		it( 'should create element without attributes', () => {
@@ -68,17 +66,18 @@ describe( 'Element', () => {
 			expect( el._classes.has( 'three' ) ).to.be.true;
 		} );
 
-		it( 'should move style attribute to style map', () => {
+		it( 'should move style attribute to style proxy', () => {
 			const el = new Element( 'p', { id: 'test', style: 'one: style1; two:style2 ; three : url(http://ckeditor.com)' } );
 
 			expect( el._attrs.has( 'style' ) ).to.be.false;
 			expect( el._attrs.has( 'id' ) ).to.be.true;
+
 			expect( el._styles.has( 'one' ) ).to.be.true;
-			expect( el._styles.get( 'one' ) ).to.equal( 'style1' );
+			expect( el._styles.getAsString( 'one' ) ).to.equal( 'style1' );
 			expect( el._styles.has( 'two' ) ).to.be.true;
-			expect( el._styles.get( 'two' ) ).to.equal( 'style2' );
+			expect( el._styles.getAsString( 'two' ) ).to.equal( 'style2' );
 			expect( el._styles.has( 'three' ) ).to.be.true;
-			expect( el._styles.get( 'three' ) ).to.equal( 'url(http://ckeditor.com)' );
+			expect( el._styles.getAsString( 'three' ) ).to.equal( 'url(http://ckeditor.com)' );
 		} );
 	} );
 
@@ -200,9 +199,9 @@ describe( 'Element', () => {
 			expect( clone ).to.not.equal( el );
 			expect( clone.name ).to.equal( el.name );
 			expect( clone._styles.has( 'color' ) ).to.be.true;
-			expect( clone._styles.get( 'color' ) ).to.equal( 'red' );
+			expect( clone._styles.getAsString( 'color' ) ).to.equal( 'red' );
 			expect( clone._styles.has( 'font-size' ) ).to.be.true;
-			expect( clone._styles.get( 'font-size' ) ).to.equal( '12px' );
+			expect( clone._styles.getAsString( 'font-size' ) ).to.equal( '12px' );
 		} );
 
 		it( 'should clone custom properties', () => {
@@ -517,12 +516,12 @@ describe( 'Element', () => {
 			it( 'should replace all styles', () => {
 				el._setStyle( 'color', 'red' );
 				el._setStyle( 'top', '10px' );
-				el._setAttribute( 'style', 'border:none' );
+				el._setAttribute( 'style', 'margin-top:2em;' );
 
 				expect( el.hasStyle( 'color' ) ).to.be.false;
 				expect( el.hasStyle( 'top' ) ).to.be.false;
-				expect( el.hasStyle( 'border' ) ).to.be.true;
-				expect( el.getStyle( 'border' ) ).to.equal( 'none' );
+				expect( el.hasStyle( 'margin-top' ) ).to.be.true;
+				expect( el.getStyle( 'margin-top' ) ).to.equal( '2em' );
 			} );
 		} );
 
@@ -571,7 +570,7 @@ describe( 'Element', () => {
 				el._setStyle( 'font-weight', 'bold' );
 
 				expect( Array.from( el.getAttributes() ) ).to.deep.equal( [
-					[ 'class', 'abc xyz' ], [ 'style', 'width:20px;font-weight:bold;' ]
+					[ 'class', 'abc xyz' ], [ 'style', 'font-weight:bold;width:20px;' ]
 				] );
 			} );
 		} );
@@ -799,8 +798,7 @@ describe( 'Element', () => {
 			it( 'should set element style', () => {
 				el._setStyle( 'color', 'red' );
 
-				expect( el._styles.has( 'color' ) ).to.be.true;
-				expect( el._styles.get( 'color' ) ).to.equal( 'red' );
+				expect( el._styles._styles.color ).to.equal( 'red' );
 			} );
 
 			it( 'should fire change event with attributes type', done => {
@@ -818,10 +816,8 @@ describe( 'Element', () => {
 					position: 'fixed'
 				} );
 
-				expect( el._styles.has( 'color' ) ).to.be.true;
-				expect( el._styles.has( 'position' ) ).to.be.true;
-				expect( el._styles.get( 'color' ) ).to.equal( 'red' );
-				expect( el._styles.get( 'position' ) ).to.equal( 'fixed' );
+				expect( el._styles._styles.color ).to.equal( 'red' );
+				expect( el._styles._styles.position ).to.equal( 'fixed' );
 			} );
 		} );
 
@@ -829,11 +825,23 @@ describe( 'Element', () => {
 			it( 'should get style', () => {
 				el._setStyle( {
 					color: 'red',
-					border: '1px solid red'
+					'margin-top': '1px'
 				} );
 
 				expect( el.getStyle( 'color' ) ).to.equal( 'red' );
-				expect( el.getStyle( 'border' ) ).to.equal( '1px solid red' );
+				expect( el.getStyle( 'margin-top' ) ).to.equal( '1px' );
+			} );
+		} );
+
+		describe( 'getNormalizedStyle', () => {
+			it( 'should get normalized style', () => {
+				el._setStyle( {
+					color: 'red',
+					'margin-top': '1px'
+				} );
+
+				expect( el.getNormalizedStyle( 'color' ) ).to.equal( 'red' );
+				expect( el.getNormalizedStyle( 'margin-top' ) ).to.equal( '1px' );
 			} );
 		} );
 
@@ -907,68 +915,6 @@ describe( 'Element', () => {
 				expect( el.hasStyle( 'color' ) ).to.be.true;
 			} );
 		} );
-
-		describe( 'styles parsing edge cases and incorrect styles', () => {
-			it( 'should not crash and not add any styles if styles attribute was empty', () => {
-				const element = new Element( 'div', { style: '' } );
-				const styles = Array.from( element.getStyleNames() );
-
-				expect( styles ).to.deep.equal( [] );
-			} );
-
-			it( 'should be able to parse big styles definition', () => {
-				expect( () => {
-					// eslint-disable-next-line no-new
-					new Element( 'div', { style: `background-image:url('data:image/jpeg;base64,${ encodedImage }')` } );
-				} ).not.to.throw();
-			} );
-
-			it( 'should work with both types of quotes and ignore values inside quotes', () => {
-				let element;
-
-				element = new Element( 'div', { style: 'background-image:url("im;color:g.jpg")' } );
-				expect( element.getStyle( 'background-image' ) ).to.equal( 'url("im;color:g.jpg")' );
-
-				element = new Element( 'div', { style: 'background-image:url(\'im;color:g.jpg\')' } );
-				expect( element.getStyle( 'background-image' ) ).to.equal( 'url(\'im;color:g.jpg\')' );
-			} );
-
-			it( 'should not be confused by whitespaces', () => {
-				const element = new Element( 'div', { style: '\ncolor:\n red ' } );
-
-				expect( element.getStyle( 'color' ) ).to.equal( 'red' );
-			} );
-
-			it( 'should not be confused by duplicated semicolon', () => {
-				const element = new Element( 'div', { style: 'color: red;; display: inline' } );
-
-				expect( element.getStyle( 'color' ) ).to.equal( 'red' );
-				expect( element.getStyle( 'display' ) ).to.equal( 'inline' );
-			} );
-
-			it( 'should not throw when value is missing', () => {
-				const element = new Element( 'div', { style: 'color:; display: inline' } );
-
-				expect( element.getStyle( 'color' ) ).to.equal( '' );
-				expect( element.getStyle( 'display' ) ).to.equal( 'inline' );
-			} );
-
-			it( 'should not throw when colon is duplicated', () => {
-				const element = new Element( 'div', { style: 'color:: red; display: inline' } );
-
-				// The result makes no sense, but here we just check that the algorithm doesn't crash.
-				expect( element.getStyle( 'color' ) ).to.equal( ': red' );
-				expect( element.getStyle( 'display' ) ).to.equal( 'inline' );
-			} );
-
-			it( 'should not throw when random stuff passed', () => {
-				const element = new Element( 'div', { style: 'color: red;:; ;;" ":  display: inline; \'aaa;:' } );
-
-				// The result makes no sense, but here we just check that the algorithm doesn't crash.
-				expect( element.getStyle( 'color' ) ).to.equal( 'red' );
-				expect( element.getStyle( 'display' ) ).to.be.undefined;
-			} );
-		} );
 	} );
 
 	describe( 'findAncestor', () => {
@@ -1069,10 +1015,10 @@ describe( 'Element', () => {
 
 		it( 'should return styles in sorted order', () => {
 			const el = new Element( 'foo', {
-				style: 'border: 1px solid red; background-color: red'
+				style: 'margin-top: 2em; background-color: red'
 			} );
 
-			expect( el.getIdentity() ).to.equal( 'foo style="background-color:red;border:1px solid red"' );
+			expect( el.getIdentity() ).to.equal( 'foo style="background-color:red;margin-top:2em;"' );
 		} );
 
 		it( 'should return attributes in sorted order', () => {
@@ -1095,7 +1041,7 @@ describe( 'Element', () => {
 			el._addClass( [ 'three', 'two', 'one' ] );
 
 			expect( el.getIdentity() ).to.equal(
-				'baz class="one,three,two" style="border-radius:10px;text-align:center" bar="two" foo="one"'
+				'baz class="one,three,two" style="border-radius:10px;text-align:center;" bar="two" foo="one"'
 			);
 		} );
 	} );

+ 2 - 2
packages/ckeditor5-engine/tests/view/emptyelement.js

@@ -15,7 +15,7 @@ describe( 'EmptyElement', () => {
 		element = new Element( 'b' );
 		emptyElement = new EmptyElement( 'img', {
 			alt: 'alternative text',
-			style: 'border: 1px solid red;color: white;',
+			style: 'margin-top: 2em;color: white;',
 			class: 'image big'
 		} );
 	} );
@@ -88,7 +88,7 @@ describe( 'EmptyElement', () => {
 
 			expect( newEmptyElement.name ).to.equal( 'img' );
 			expect( newEmptyElement.getAttribute( 'alt' ) ).to.equal( 'alternative text' );
-			expect( newEmptyElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( newEmptyElement.getStyle( 'margin-top' ) ).to.equal( '2em' );
 			expect( newEmptyElement.getStyle( 'color' ) ).to.equal( 'white' );
 			expect( newEmptyElement.hasClass( 'image' ) ).to.be.true;
 			expect( newEmptyElement.hasClass( 'big' ) ).to.be.true;

+ 54 - 0
packages/ckeditor5-engine/tests/view/styles/background.js

@@ -0,0 +1,54 @@
+/**
+ * @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 StylesMap, { StylesProcessor } from '../../../src/view/stylesmap';
+import { addBackgroundRules } from '../../../src/view/styles/background';
+
+describe( 'Background styles normalization', () => {
+	let styles;
+
+	before( () => {
+		const stylesProcessor = new StylesProcessor();
+		StylesMap._setProcessor( stylesProcessor );
+		addBackgroundRules( stylesProcessor );
+	} );
+
+	beforeEach( () => {
+		styles = new StylesMap();
+	} );
+
+	it( 'should normalize background', () => {
+		// TODO: border-box given only for coverage test.
+		styles.setTo( 'background:url("example.jpg") center #f00 repeat-y fixed border-box;' );
+
+		expect( styles.getNormalized( 'background' ) ).to.deep.equal( {
+			attachment: 'fixed',
+			image: 'url("example.jpg")',
+			position: [ 'center' ],
+			repeat: [ 'repeat-y' ],
+			color: '#f00'
+		} );
+	} );
+
+	// TODO: define what should happen with layers
+	it.skip( 'should normalize background with layers', () => {
+		styles.setTo( 'background:url("test.jpg") repeat-y,#f00;' );
+
+		expect( styles.getNormalized( 'background' ) ).to.deep.equal( { color: '#f00' } );
+	} );
+
+	it( 'should normalize background-color', () => {
+		styles.setTo( 'background-color:#f00;' );
+
+		expect( styles.getNormalized( 'background' ) ).to.deep.equal( { color: '#f00' } );
+	} );
+
+	it( 'should output inline background-color style', () => {
+		styles.setTo( 'background:#f00;' );
+
+		expect( styles.toString() ).to.equal( 'background-color:#f00;' );
+		expect( styles.getAsString( 'background-color' ) ).to.equal( '#f00' );
+	} );
+} );

+ 637 - 0
packages/ckeditor5-engine/tests/view/styles/border.js

@@ -0,0 +1,637 @@
+/**
+ * @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 StylesMap, { StylesProcessor } from '../../../src/view/stylesmap';
+import { addBorderRules } from '../../../src/view/styles/border';
+
+describe( 'Border styles normalization', () => {
+	let styles;
+
+	before( () => {
+		const stylesProcessor = new StylesProcessor();
+		StylesMap._setProcessor( stylesProcessor );
+		addBorderRules( stylesProcessor );
+	} );
+
+	beforeEach( () => {
+		styles = new StylesMap();
+	} );
+
+	it( 'should parse border shorthand', () => {
+		styles.setTo( 'border:1px solid blue;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: 'blue', right: 'blue', bottom: 'blue', left: 'blue' },
+			style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'solid' },
+			width: { top: '1px', right: '1px', bottom: '1px', left: '1px' }
+		} );
+	} );
+
+	it( 'should parse border shorthand with only style', () => {
+		styles.setTo( 'border:solid;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: undefined, right: undefined, bottom: undefined, left: undefined },
+			style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'solid' },
+			width: { top: undefined, right: undefined, bottom: undefined, left: undefined }
+		} );
+	} );
+
+	it( 'should parse border shorthand with other shorthands', () => {
+		styles.setTo( 'border:1px solid blue;border-left:#665511 dashed 2.7em;border-top:7px dotted #ccc;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: '#ccc', right: 'blue', bottom: 'blue', left: '#665511' },
+			style: { top: 'dotted', right: 'solid', bottom: 'solid', left: 'dashed' },
+			width: { top: '7px', right: '1px', bottom: '1px', left: '2.7em' }
+		} );
+	} );
+
+	it( 'should parse border longhand', () => {
+		styles.setTo( 'border-color: #f00 #ba2;' +
+			'border-style: solid;' +
+			'border-width: 1px;' +
+			'border-bottom-width: 2px;' +
+			'border-right-style: dotted;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: '#f00', right: '#ba2', bottom: '#f00', left: '#ba2' },
+			style: { top: 'solid', right: 'dotted', bottom: 'solid', left: 'solid' },
+			width: { top: '1px', right: '1px', bottom: '2px', left: '1px' }
+		} );
+	} );
+
+	it( 'should output inline shorthand rules #1', () => {
+		styles.setTo( 'border:1px solid blue;' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:1px solid blue;' +
+			'border-left:1px solid blue;' +
+			'border-right:1px solid blue;' +
+			'border-top:1px solid blue;'
+		);
+		expect( styles.getAsString( 'border-color' ) ).to.equal( 'blue' );
+		expect( styles.getAsString( 'border-style' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-width' ) ).to.equal( '1px' );
+	} );
+
+	it( 'should output only defined inline styles', () => {
+		styles.set( 'border-color', { top: 'blue' } );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: 'blue' }
+		} );
+
+		expect( styles.toString( 'border' ) ).to.equal( 'border-top:blue;' );
+		expect( styles.has( 'border-top-color' ) ).to.be.true;
+		expect( styles.getAsString( 'border-top-color' ) ).to.equal( 'blue' );
+	} );
+
+	it( 'should output inline shorthand rules #2', () => {
+		styles.setTo( 'border:1px solid blue;border-left:#665511 dashed 2.7em;border-top:7px dotted #ccc;' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:1px solid blue;' +
+			'border-left:2.7em dashed #665511;' +
+			'border-right:1px solid blue;' +
+			'border-top:7px dotted #ccc;'
+		);
+
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.equal( '#ccc blue blue #665511' );
+		expect( styles.getAsString( 'border-style' ) ).to.equal( 'dotted solid solid dashed' );
+		expect( styles.getAsString( 'border-width' ) ).to.equal( '7px 1px 1px 2.7em' );
+	} );
+
+	it( 'should parse border + border-position(only color defined)', () => {
+		styles.setTo( 'border:1px solid blue;border-left:#665511;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: 'blue', right: 'blue', bottom: 'blue', left: '#665511' },
+			style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'solid' },
+			width: { top: '1px', right: '1px', bottom: '1px', left: '1px' }
+		} );
+	} );
+
+	it( 'should parse border + border-position(only style defined)', () => {
+		styles.setTo( 'border:1px solid blue;border-left:ridge;' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: 'blue', right: 'blue', bottom: 'blue', left: 'blue' },
+			style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'ridge' },
+			width: { top: '1px', right: '1px', bottom: '1px', left: '1px' }
+		} );
+	} );
+
+	it( 'should parse border + border-position(only width defined)', () => {
+		styles.setTo( 'border:1px solid blue;border-left:1337px' );
+
+		expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+			color: { top: 'blue', right: 'blue', bottom: 'blue', left: 'blue' },
+			style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'solid' },
+			width: { top: '1px', right: '1px', bottom: '1px', left: '1337px' }
+		} );
+	} );
+
+	it( 'should merge rules on insert other shorthand', () => {
+		styles.setTo( 'border:1px solid blue;' );
+		styles.set( 'border-left', '#665511 dashed 2.7em' );
+		styles.set( 'border-top', '7px dotted #ccc' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:1px solid blue;' +
+			'border-left:2.7em dashed #665511;' +
+			'border-right:1px solid blue;' +
+			'border-top:7px dotted #ccc;'
+		);
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.equal( '#ccc blue blue #665511' );
+		expect( styles.getAsString( 'border-style' ) ).to.equal( 'dotted solid solid dashed' );
+		expect( styles.getAsString( 'border-width' ) ).to.equal( '7px 1px 1px 2.7em' );
+	} );
+
+	it( 'should output single values if one shorthand is removed', () => {
+		styles.setTo( 'border:1px solid blue;' );
+		styles.remove( 'border-color' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:1px solid;' +
+			'border-left:1px solid;' +
+			'border-right:1px solid;' +
+			'border-top:1px solid;'
+		);
+
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-style' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-width' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'border-top' ) ).to.equal( '1px solid' );
+		expect( styles.getAsString( 'border-right' ) ).to.equal( '1px solid' );
+		expect( styles.getAsString( 'border-bottom' ) ).to.equal( '1px solid' );
+		expect( styles.getAsString( 'border-left' ) ).to.equal( '1px solid' );
+	} );
+
+	it( 'should output border with only style shorthand (style)', () => {
+		styles.setTo( 'border:solid;' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:solid;' +
+			'border-left:solid;' +
+			'border-right:solid;' +
+			'border-top:solid;'
+		);
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-style' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-width' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-top' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-right' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-bottom' ) ).to.equal( 'solid' );
+		expect( styles.getAsString( 'border-left' ) ).to.equal( 'solid' );
+	} );
+
+	it( 'should output border with only style shorthand (color)', () => {
+		styles.setTo( 'border:#f00;' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:#f00;' +
+			'border-left:#f00;' +
+			'border-right:#f00;' +
+			'border-top:#f00;'
+		);
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.equal( '#f00' );
+		expect( styles.getAsString( 'border-style' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-width' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-top' ) ).to.equal( '#f00' );
+		expect( styles.getAsString( 'border-right' ) ).to.equal( '#f00' );
+		expect( styles.getAsString( 'border-bottom' ) ).to.equal( '#f00' );
+		expect( styles.getAsString( 'border-left' ) ).to.equal( '#f00' );
+	} );
+
+	it( 'should output border with only style shorthand (width)', () => {
+		styles.setTo( 'border:1px;' );
+
+		expect( styles.toString() ).to.equal(
+			'border-bottom:1px;' +
+			'border-left:1px;' +
+			'border-right:1px;' +
+			'border-top:1px;'
+		);
+		expect( styles.getAsString( 'border' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-color' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-style' ) ).to.be.undefined;
+		expect( styles.getAsString( 'border-width' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'border-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'border-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'border-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'border-left' ) ).to.equal( '1px' );
+	} );
+
+	describe( 'normalized values getters', () => {
+		it( 'should output border-*-color', () => {
+			styles.setTo( 'border:1px solid #f00;' );
+
+			[ 'top', 'right', 'bottom', 'left' ].forEach( position => {
+				expect( styles.getNormalized( `border-${ position }-color` ) ).to.equal( '#f00' );
+			} );
+		} );
+
+		it( 'should output border-*-width', () => {
+			styles.setTo( 'border:1px solid #f00;' );
+
+			[ 'top', 'right', 'bottom', 'left' ].forEach( position => {
+				expect( styles.getNormalized( `border-${ position }-width` ) ).to.equal( '1px' );
+			} );
+		} );
+
+		it( 'should output border-*-style', () => {
+			styles.setTo( 'border:1px solid #f00;' );
+
+			[ 'top', 'right', 'bottom', 'left' ].forEach( position => {
+				expect( styles.getNormalized( `border-${ position }-style` ) ).to.equal( 'solid' );
+			} );
+		} );
+	} );
+
+	describe( 'border reducers', () => {
+		it( 'should output border-top', () => {
+			styles.setTo( 'border:1px solid #f00' );
+
+			expect( styles.getAsString( 'border-top' ) ).to.equal( '1px solid #f00' );
+		} );
+
+		it( 'should output border-right', () => {
+			styles.setTo( 'border:1px solid #f00' );
+
+			expect( styles.getAsString( 'border-right' ) ).to.equal( '1px solid #f00' );
+		} );
+
+		it( 'should output border-bottom', () => {
+			styles.setTo( 'border:1px solid #f00' );
+
+			expect( styles.getAsString( 'border-bottom' ) ).to.equal( '1px solid #f00' );
+		} );
+
+		it( 'should output border-left', () => {
+			styles.setTo( 'border:1px solid #f00' );
+
+			expect( styles.getAsString( 'border-left' ) ).to.equal( '1px solid #f00' );
+		} );
+	} );
+
+	describe( 'border-color', () => {
+		it( 'should set all border colors (1 value defined)', () => {
+			styles.setTo( 'border-color:cyan;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				color: {
+					top: 'cyan',
+					right: 'cyan',
+					bottom: 'cyan',
+					left: 'cyan'
+				}
+			} );
+		} );
+
+		it( 'should set all border colors (2 values defined)', () => {
+			styles.setTo( 'border-color:cyan magenta;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				color: {
+					top: 'cyan',
+					right: 'magenta',
+					bottom: 'cyan',
+					left: 'magenta'
+				}
+			} );
+		} );
+
+		it( 'should set all border colors (3 values defined)', () => {
+			styles.setTo( 'border-color:cyan magenta pink;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				color: {
+					top: 'cyan',
+					right: 'magenta',
+					bottom: 'pink',
+					left: 'magenta'
+				}
+			} );
+		} );
+
+		it( 'should set all border colors (4 values defined)', () => {
+			styles.setTo( 'border-color:cyan magenta pink beige;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				color: {
+					top: 'cyan',
+					right: 'magenta',
+					bottom: 'pink',
+					left: 'beige'
+				}
+			} );
+		} );
+
+		it( 'should merge with border shorthand', () => {
+			styles.setTo( 'border:1px solid blue;border-color:cyan black;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				color: { top: 'cyan', right: 'black', bottom: 'cyan', left: 'black' },
+				style: { top: 'solid', right: 'solid', bottom: 'solid', left: 'solid' },
+				width: { top: '1px', right: '1px', bottom: '1px', left: '1px' }
+			} );
+		} );
+
+		it( 'should parse #RGB color value', () => {
+			styles.setTo( 'border:#f00;' );
+
+			expect( styles.getNormalized( 'border-color' ) ).to.deep.equal( {
+				top: '#f00',
+				right: '#f00',
+				bottom: '#f00',
+				left: '#f00'
+			} );
+		} );
+
+		it( 'should parse #RGBA color value', () => {
+			styles.setTo( 'border:#f00A;' );
+
+			expect( styles.getNormalized( 'border-color' ) ).to.deep.equal( {
+				top: '#f00A',
+				right: '#f00A',
+				bottom: '#f00A',
+				left: '#f00A'
+			} );
+		} );
+
+		it( 'should parse rgb() color value', () => {
+			styles.setTo( 'border:rgb(0, 30%,35);' );
+
+			expect( styles.getNormalized( 'border-color' ) ).to.deep.equal( {
+				top: 'rgb(0, 30%, 35)',
+				right: 'rgb(0, 30%, 35)',
+				bottom: 'rgb(0, 30%, 35)',
+				left: 'rgb(0, 30%, 35)'
+			} );
+		} );
+
+		it( 'should parse hsl() color value', () => {
+			styles.setTo( 'border:hsl(0, 100%, 50%);' );
+
+			expect( styles.getNormalized( 'border-color' ) ).to.deep.equal( {
+				top: 'hsl(0, 100%, 50%)',
+				right: 'hsl(0, 100%, 50%)',
+				bottom: 'hsl(0, 100%, 50%)',
+				left: 'hsl(0, 100%, 50%)'
+			} );
+		} );
+	} );
+
+	describe( 'border-style', () => {
+		it( 'should set all border styles (1 value defined)', () => {
+			styles.setTo( 'border-style:solid;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				style: {
+					top: 'solid',
+					right: 'solid',
+					bottom: 'solid',
+					left: 'solid'
+				}
+			} );
+		} );
+
+		it( 'should set all border styles (2 values defined)', () => {
+			styles.setTo( 'border-style:solid dotted;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				style: {
+					top: 'solid',
+					right: 'dotted',
+					bottom: 'solid',
+					left: 'dotted'
+				}
+			} );
+		} );
+
+		it( 'should set all border styles (3 values defined)', () => {
+			styles.setTo( 'border-style:solid dotted dashed;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				style: {
+					top: 'solid',
+					right: 'dotted',
+					bottom: 'dashed',
+					left: 'dotted'
+				}
+			} );
+		} );
+
+		it( 'should set all border styles (4 values defined)', () => {
+			styles.setTo( 'border-style:solid dotted dashed ridge;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				style: {
+					top: 'solid',
+					right: 'dotted',
+					bottom: 'dashed',
+					left: 'ridge'
+				}
+			} );
+		} );
+
+		it( 'should parse none value', () => {
+			styles.setTo( 'border:none;' );
+
+			expect( styles.getNormalized( 'border-style' ) ).to.deep.equal( {
+				top: 'none',
+				right: 'none',
+				bottom: 'none',
+				left: 'none'
+			} );
+		} );
+
+		it( 'should parse line-style value', () => {
+			styles.setTo( 'border:solid;' );
+
+			expect( styles.getNormalized( 'border-style' ) ).to.deep.equal( {
+				top: 'solid',
+				right: 'solid',
+				bottom: 'solid',
+				left: 'solid'
+			} );
+		} );
+
+		it( 'should not parse non line-style value', () => {
+			styles.setTo( 'border:blue' );
+
+			expect( styles.getNormalized( 'border-style' ) ).to.deep.equal( {
+				top: undefined,
+				right: undefined,
+				bottom: undefined,
+				left: undefined
+			} );
+		} );
+	} );
+
+	describe( 'border-width', () => {
+		it( 'should set all border widths (1 value defined)', () => {
+			styles.setTo( 'border-width:1px;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				width: {
+					top: '1px',
+					right: '1px',
+					bottom: '1px',
+					left: '1px'
+				}
+			} );
+		} );
+
+		it( 'should set all border widths (2 values defined)', () => {
+			styles.setTo( 'border-width:1px .34cm;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				width: {
+					top: '1px',
+					right: '.34cm',
+					bottom: '1px',
+					left: '.34cm'
+				}
+			} );
+		} );
+
+		it( 'should set all border widths (3 values defined)', () => {
+			styles.setTo( 'border-width:1px .34cm 90.1rem;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				width: {
+					top: '1px',
+					right: '.34cm',
+					bottom: '90.1rem',
+					left: '.34cm'
+				}
+			} );
+		} );
+
+		it( 'should set all border widths (4 values defined)', () => {
+			styles.setTo( 'border-width:1px .34cm 90.1rem thick;' );
+
+			expect( styles.getNormalized( 'border' ) ).to.deep.equal( {
+				width: {
+					top: '1px',
+					right: '.34cm',
+					bottom: '90.1rem',
+					left: 'thick'
+				}
+			} );
+		} );
+
+		it( 'should parse px value', () => {
+			styles.setTo( 'border:1px;' );
+
+			expect( styles.getNormalized( 'border-width' ) ).to.deep.equal( {
+				top: '1px',
+				right: '1px',
+				bottom: '1px',
+				left: '1px'
+			} );
+		} );
+
+		it( 'should parse em value', () => {
+			styles.setTo( 'border:1em;' );
+
+			expect( styles.getNormalized( 'border-width' ) ).to.deep.equal( {
+				top: '1em',
+				right: '1em',
+				bottom: '1em',
+				left: '1em'
+			} );
+		} );
+
+		it( 'should parse thin value', () => {
+			styles.setTo( 'border:thin' );
+
+			expect( styles.getNormalized( 'border-width' ) ).to.deep.equal( {
+				top: 'thin',
+				right: 'thin',
+				bottom: 'thin',
+				left: 'thin'
+			} );
+		} );
+
+		it( 'should parse medium value', () => {
+			styles.setTo( 'border:medium' );
+
+			expect( styles.getNormalized( 'border-width' ) ).to.deep.equal( {
+				top: 'medium',
+				right: 'medium',
+				bottom: 'medium',
+				left: 'medium'
+			} );
+		} );
+
+		it( 'should parse thick value', () => {
+			styles.setTo( 'border:thick' );
+
+			expect( styles.getNormalized( 'border-width' ) ).to.deep.equal( {
+				top: 'thick',
+				right: 'thick',
+				bottom: 'thick',
+				left: 'thick'
+			} );
+		} );
+	} );
+
+	describe( 'border-* position', () => {
+		it( 'should output all positions', () => {
+			styles.setTo(
+				'border-top:none;' +
+				'border-left:none;' +
+				'border-bottom:dotted #FFC000 3.0pt;' +
+				'border-right:dotted #FFC000 3.0pt;'
+			);
+
+			expect( styles.toString() ).to.equal(
+				'border-bottom:3.0pt dotted #FFC000;' +
+				'border-left:none;' +
+				'border-right:3.0pt dotted #FFC000;' +
+				'border-top:none;'
+			);
+			expect( styles.getAsString( 'border-top' ) ).to.equal( 'none' );
+			expect( styles.getAsString( 'border-right' ) ).to.equal( '3.0pt dotted #FFC000' );
+			expect( styles.getAsString( 'border-bottom' ) ).to.equal( '3.0pt dotted #FFC000' );
+			expect( styles.getAsString( 'border-left' ) ).to.equal( 'none' );
+		} );
+
+		it( 'should output nothing if no border style defined', () => {
+			styles.setTo( 'color:blue;' );
+
+			expect( styles.toString() ).to.equal( 'color:blue;' );
+			expect( styles.getAsString( 'border-top' ) ).to.be.undefined;
+			expect( styles.getAsString( 'border-right' ) ).to.be.undefined;
+			expect( styles.getAsString( 'border-bottom' ) ).to.be.undefined;
+			expect( styles.getAsString( 'border-left' ) ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'getStyleNames() - border', () => {
+		it( 'should set all border colors (1 value defined)', () => {
+			styles.setTo( 'border-color: deeppink deepskyblue;' +
+				'border-style: solid;' +
+				'border-width: 1px;' +
+				'border-bottom-width: 2px;' +
+				'border-right-style: dotted;' );
+
+			expect( styles.getStyleNames() ).to.deep.equal( [
+				'border-top',
+				'border-right',
+				'border-bottom',
+				'border-left'
+			] );
+		} );
+	} );
+} );

+ 183 - 0
packages/ckeditor5-engine/tests/view/styles/margin.js

@@ -0,0 +1,183 @@
+/**
+ * @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 StylesMap, { StylesProcessor } from '../../../src/view/stylesmap';
+import { addMarginRules } from '../../../src/view/styles/margin';
+
+describe( 'Margin styles normalizer', () => {
+	let styles;
+
+	before( () => {
+		const stylesProcessor = new StylesProcessor();
+		StylesMap._setProcessor( stylesProcessor );
+		addMarginRules( stylesProcessor );
+	} );
+
+	beforeEach( () => {
+		styles = new StylesMap();
+	} );
+
+	it( 'should set all margins (1 value defined)', () => {
+		styles.setTo( 'margin:1px;' );
+
+		expect( styles.getNormalized( 'margin' ) ).to.deep.equal( {
+			top: '1px',
+			right: '1px',
+			bottom: '1px',
+			left: '1px'
+		} );
+	} );
+
+	it( 'should set all margins (2 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm;' );
+
+		expect( styles.getNormalized( 'margin' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '1px',
+			left: '.34cm'
+		} );
+	} );
+
+	it( 'should set all margins (3 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm 90.1rem;' );
+
+		expect( styles.getNormalized( 'margin' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '90.1rem',
+			left: '.34cm'
+		} );
+	} );
+
+	it( 'should set all margins (4 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm 90.1rem thick;' );
+
+		expect( styles.getNormalized( 'margin' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '90.1rem',
+			left: 'thick'
+		} );
+	} );
+
+	it( 'should output inline style (1 value defined)', () => {
+		styles.setTo( 'margin:1px;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( '1px' );
+	} );
+
+	it( 'should output inline style (2 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px .34cm;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px .34cm' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( '.34cm' );
+	} );
+
+	it( 'should output inline style (3 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm 90.1rem;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px .34cm 90.1rem;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px .34cm 90.1rem' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( '.34cm' );
+	} );
+
+	it( 'should output inline style (3 values defined, only last different)', () => {
+		styles.setTo( 'margin:1px 1px 90.1rem;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px 1px 90.1rem;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px 1px 90.1rem' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( '1px' );
+	} );
+
+	it( 'should output inline style (4 values defined)', () => {
+		styles.setTo( 'margin:1px .34cm 90.1rem thick;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px .34cm 90.1rem thick;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px .34cm 90.1rem thick' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( 'thick' );
+	} );
+
+	it( 'should output inline style (4 values defined, only last different)', () => {
+		styles.setTo( 'margin:1px 1px 1px thick;' );
+
+		expect( styles.toString() ).to.equal( 'margin:1px 1px 1px thick;' );
+		expect( styles.getAsString( 'margin' ) ).to.equal( '1px 1px 1px thick' );
+		expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'margin-left' ) ).to.equal( 'thick' );
+	} );
+
+	describe( 'margin-*', () => {
+		it( 'should set proper margin', () => {
+			styles.setTo( 'margin-top:1px;' );
+
+			expect( styles.getNormalized( 'margin' ) ).to.deep.equal( { top: '1px' } );
+			expect( styles.getNormalized( 'margin-top' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should merge margin with margin shorthand', () => {
+			styles.setTo( 'margin: 2em;margin-top:1px;' );
+
+			expect( styles.getNormalized( 'margin' ) ).to.deep.equal( {
+				top: '1px',
+				right: '2em',
+				bottom: '2em',
+				left: '2em'
+			} );
+			expect( styles.getNormalized( 'margin-top' ) ).to.equal( '1px' );
+			expect( styles.getNormalized( 'margin-right' ) ).to.equal( '2em' );
+			expect( styles.getNormalized( 'margin-bottom' ) ).to.equal( '2em' );
+			expect( styles.getNormalized( 'margin-left' ) ).to.equal( '2em' );
+		} );
+
+		it( 'should output margin-top', () => {
+			styles.setTo( 'margin-top:1px;' );
+
+			expect( styles.toString() ).to.equal( 'margin-top:1px;' );
+			expect( styles.getAsString( 'margin-top' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output margin-right', () => {
+			styles.setTo( 'margin-right:1px;' );
+
+			expect( styles.toString() ).to.equal( 'margin-right:1px;' );
+			expect( styles.getAsString( 'margin-right' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output margin-bottom', () => {
+			styles.setTo( 'margin-bottom:1px;' );
+
+			expect( styles.toString() ).to.equal( 'margin-bottom:1px;' );
+			expect( styles.getAsString( 'margin-bottom' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output margin-left', () => {
+			styles.setTo( 'margin-left:1px;' );
+
+			expect( styles.toString() ).to.equal( 'margin-left:1px;' );
+			expect( styles.getAsString( 'margin-left' ) ).to.equal( '1px' );
+		} );
+	} );
+} );

+ 183 - 0
packages/ckeditor5-engine/tests/view/styles/padding.js

@@ -0,0 +1,183 @@
+/**
+ * @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 StylesMap, { StylesProcessor } from '../../../src/view/stylesmap';
+import { addPaddingRules } from '../../../src/view/styles/padding';
+
+describe( 'Padding styles normalization', () => {
+	let styles;
+
+	before( () => {
+		const stylesProcessor = new StylesProcessor();
+		StylesMap._setProcessor( stylesProcessor );
+		addPaddingRules( stylesProcessor );
+	} );
+
+	beforeEach( () => {
+		styles = new StylesMap();
+	} );
+
+	it( 'should set all padding values (1 value defined)', () => {
+		styles.setTo( 'padding:1px;' );
+
+		expect( styles.getNormalized( 'padding' ) ).to.deep.equal( {
+			top: '1px',
+			right: '1px',
+			bottom: '1px',
+			left: '1px'
+		} );
+	} );
+
+	it( 'should set all padding values (2 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm;' );
+
+		expect( styles.getNormalized( 'padding' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '1px',
+			left: '.34cm'
+		} );
+	} );
+
+	it( 'should set all padding values (3 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm 90.1rem;' );
+
+		expect( styles.getNormalized( 'padding' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '90.1rem',
+			left: '.34cm'
+		} );
+	} );
+
+	it( 'should set all padding values (4 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm 90.1rem thick;' );
+
+		expect( styles.getNormalized( 'padding' ) ).to.deep.equal( {
+			top: '1px',
+			right: '.34cm',
+			bottom: '90.1rem',
+			left: 'thick'
+		} );
+	} );
+
+	it( 'should output inline style (1 value defined)', () => {
+		styles.setTo( 'padding:1px;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( '1px' );
+	} );
+
+	it( 'should output inline style (2 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px .34cm;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px .34cm' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( '.34cm' );
+	} );
+
+	it( 'should output inline style (3 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm 90.1rem;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px .34cm 90.1rem;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px .34cm 90.1rem' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( '.34cm' );
+	} );
+
+	it( 'should output inline style (3 values defined, only last different)', () => {
+		styles.setTo( 'padding:1px 1px 90.1rem;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px 1px 90.1rem;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px 1px 90.1rem' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( '1px' );
+	} );
+
+	it( 'should output inline style (4 values defined)', () => {
+		styles.setTo( 'padding:1px .34cm 90.1rem thick;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px .34cm 90.1rem thick;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px .34cm 90.1rem thick' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '.34cm' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '90.1rem' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( 'thick' );
+	} );
+
+	it( 'should output inline style (4 values defined, only last different)', () => {
+		styles.setTo( 'padding:1px 1px 1px thick;' );
+
+		expect( styles.toString() ).to.equal( 'padding:1px 1px 1px thick;' );
+		expect( styles.getAsString( 'padding' ) ).to.equal( '1px 1px 1px thick' );
+		expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-right' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '1px' );
+		expect( styles.getAsString( 'padding-left' ) ).to.equal( 'thick' );
+	} );
+
+	describe( 'padding-*', () => {
+		it( 'should set proper padding', () => {
+			styles.setTo( 'padding-top:1px;' );
+
+			expect( styles.getNormalized( 'padding' ) ).to.deep.equal( { top: '1px' } );
+			expect( styles.getNormalized( 'padding-top' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should merge padding with padding shorthand', () => {
+			styles.setTo( 'padding: 2em;padding-top:1px;' );
+
+			expect( styles.getNormalized( 'padding' ) ).to.deep.equal( {
+				top: '1px',
+				right: '2em',
+				bottom: '2em',
+				left: '2em'
+			} );
+			expect( styles.getNormalized( 'padding-top' ) ).to.equal( '1px' );
+			expect( styles.getNormalized( 'padding-right' ) ).to.equal( '2em' );
+			expect( styles.getNormalized( 'padding-bottom' ) ).to.equal( '2em' );
+			expect( styles.getNormalized( 'padding-left' ) ).to.equal( '2em' );
+		} );
+
+		it( 'should output padding-top', () => {
+			styles.setTo( 'padding-top:1px;' );
+
+			expect( styles.toString() ).to.equal( 'padding-top:1px;' );
+			expect( styles.getAsString( 'padding-top' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output padding-right', () => {
+			styles.setTo( 'padding-right:1px;' );
+
+			expect( styles.toString() ).to.equal( 'padding-right:1px;' );
+			expect( styles.getAsString( 'padding-right' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output padding-bottom', () => {
+			styles.setTo( 'padding-bottom:1px;' );
+
+			expect( styles.toString() ).to.equal( 'padding-bottom:1px;' );
+			expect( styles.getAsString( 'padding-bottom' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should output padding-left', () => {
+			styles.setTo( 'padding-left:1px;' );
+
+			expect( styles.toString() ).to.equal( 'padding-left:1px;' );
+			expect( styles.getAsString( 'padding-left' ) ).to.equal( '1px' );
+		} );
+	} );
+} );

+ 162 - 0
packages/ckeditor5-engine/tests/view/styles/utils.js

@@ -0,0 +1,162 @@
+/**
+ * @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 {
+	getShorthandValues,
+	getTopRightBottomLeftShorthandValue,
+	getTopRightBottomLeftValues,
+	isColor,
+	isLength,
+	isLineStyle
+} from '../../../src/view/styles/utils';
+
+describe( 'Styles utils', () => {
+	describe( 'isColor()', () => {
+		it( 'returns true for #RGB color', () => {
+			testValues( [ '#f00', '#ba2' ], isColor );
+		} );
+
+		it( 'returns true for #RRGGBB color', () => {
+			testValues( [ '#ff0000', '#bbaa22' ], isColor );
+		} );
+
+		it( 'returns true for #RGBA color', () => {
+			testValues( [ '#f000', '#ba24' ], isColor );
+		} );
+
+		it( 'returns true for #RRGGBBAA color', () => {
+			testValues( [ '#ff000000', '#bbaa2244' ], isColor );
+		} );
+
+		it( 'returns true for rgb() color', () => {
+			testValues( [ 'rgb(255, 255, 255)', 'rgb(23%,0,100%)' ], isColor );
+		} );
+
+		it( 'returns true for rgba() color', () => {
+			testValues( [ 'rgba(1,2,3,0.7)', 'rgba(12%,0,0,1)' ], isColor );
+		} );
+
+		it( 'returns true for hsl() color', () => {
+			testValues( [ 'hsl(0, 100%, 50%)', 'hsl(340,80%,40%)' ], isColor );
+		} );
+
+		it( 'returns true for hsla() color', () => {
+			testValues( [ 'hsla(240, 100%, 50%, 1)', 'hsla(240, 100%, 50%, .05)' ], isColor );
+		} );
+
+		it( 'returns true for currentColor color', () => {
+			testValues( [ 'currentColor' ], isColor );
+		} );
+	} );
+
+	describe( 'isLineStyle()', () => {
+		it( 'returns true for line style', () => {
+			testValues(
+				[ 'none', 'hidden', 'dotted', 'dashed', 'solid', 'double', 'groove', 'ridge', 'inset', 'outset' ],
+				isLineStyle
+			);
+		} );
+	} );
+
+	describe( 'isLength()', () => {
+		it( 'returns true for various units', () => {
+			testValues(
+				[ '1px', '2rem', '34.5px', '.2em', '0', '1346vmax' ],
+				isLength
+			);
+		} );
+	} );
+
+	describe( 'getTopRightBottomLeftShorthandValue()', () => {
+		it( 'should output one value for same values', () => {
+			expect( getTopRightBottomLeftShorthandValue( { top: 'foo', right: 'foo', bottom: 'foo', left: 'foo' } ) ).to.equal( 'foo' );
+		} );
+
+		it( 'should output two value for top == bottom and right == left', () => {
+			expect( getTopRightBottomLeftShorthandValue( { top: 'foo', right: 'bar', bottom: 'foo', left: 'bar' } ) ).to.equal( 'foo bar' );
+		} );
+
+		it( 'should output three values if bottom is different then top', () => {
+			expect( getTopRightBottomLeftShorthandValue( { top: 'foo', right: 'foo', bottom: 'bar', left: 'foo' } ) )
+				.to.equal( 'foo foo bar' );
+		} );
+
+		it( 'should output four values if left is different then right', () => {
+			expect( getTopRightBottomLeftShorthandValue( { top: 'foo', right: 'foo', bottom: 'foo', left: 'bar' } ) )
+				.to.equal( 'foo foo foo bar' );
+		} );
+	} );
+
+	describe( 'getTopRightBottomLeftValues()', () => {
+		it( 'should parse empty string', () => {
+			expect( getTopRightBottomLeftValues( '' ) ).to.deep.equal( {
+				top: undefined,
+				right: undefined,
+				bottom: undefined,
+				left: undefined
+			} );
+		} );
+
+		it( 'should parse one value', () => {
+			expect( getTopRightBottomLeftValues( 'foo' ) ).to.deep.equal( {
+				top: 'foo',
+				right: 'foo',
+				bottom: 'foo',
+				left: 'foo'
+			} );
+		} );
+
+		it( 'should parse one value', () => {
+			expect( getTopRightBottomLeftValues( 'foo' ) ).to.deep.equal( {
+				top: 'foo',
+				right: 'foo',
+				bottom: 'foo',
+				left: 'foo'
+			} );
+		} );
+
+		it( 'should parse two value', () => {
+			expect( getTopRightBottomLeftValues( 'foo bar' ) ).to.deep.equal( {
+				top: 'foo',
+				right: 'bar',
+				bottom: 'foo',
+				left: 'bar'
+			} );
+		} );
+
+		it( 'should parse three values', () => {
+			expect( getTopRightBottomLeftValues( 'foo foo bar' ) ).to.deep.equal( {
+				top: 'foo',
+				right: 'foo',
+				bottom: 'bar',
+				left: 'foo'
+			} );
+		} );
+
+		it( 'should output four values if left is different then right', () => {
+			expect( getTopRightBottomLeftValues( 'foo foo foo bar' ) ).to.deep.equal( {
+				top: 'foo',
+				right: 'foo',
+				bottom: 'foo',
+				left: 'bar'
+			} );
+		} );
+	} );
+
+	describe( 'getParts()', () => {
+		it( 'should split string to separate values', () => {
+			expect( getShorthandValues( 'foo bar' ) ).to.deep.equal( [ 'foo', 'bar' ] );
+		} );
+
+		it( 'should split string to separate values when value contain grouping parens', () => {
+			expect( getShorthandValues( 'foo bar(1, 3, 5) url("example.com:foo/bar?q=b")' ) )
+				.to.deep.equal( [ 'foo', 'bar(1, 3, 5)', 'url("example.com:foo/bar?q=b")' ] );
+		} );
+	} );
+
+	function testValues( values, callback ) {
+		values.map( string => expect( callback( string ), string ).to.be.true );
+	}
+} );

+ 268 - 0
packages/ckeditor5-engine/tests/view/stylesmap.js

@@ -0,0 +1,268 @@
+/**
+ * @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 StylesMap, { StylesProcessor } from '../../src/view/stylesmap';
+import encodedImage from './_utils/encodedimage.txt';
+import { addPaddingRules } from '../../src/view/styles/padding';
+import { getTopRightBottomLeftValueReducer } from '../../src/view/styles/utils';
+
+describe( 'StylesMap', () => {
+	let stylesMap, stylesProcessor;
+
+	beforeEach( () => {
+		stylesProcessor = new StylesProcessor();
+
+		// Define simple "foo" shorthand normalizers, similar to the "margin" shorthand normalizers, for testing purposes.
+		stylesProcessor.setNormalizer( 'foo', value => ( {
+			path: 'foo',
+			value: { top: value, right: value, bottom: value, left: value }
+		} ) );
+		stylesProcessor.setNormalizer( 'foo-top', value => ( {
+			path: 'foo.top',
+			value
+		} ) );
+		stylesProcessor.setReducer( 'foo', getTopRightBottomLeftValueReducer( 'foo' ) );
+
+		addPaddingRules( stylesProcessor );
+		StylesMap._setProcessor( stylesProcessor );
+		stylesMap = new StylesMap();
+	} );
+
+	describe( 'size getter', () => {
+		it( 'should return 0 if no styles are set', () => {
+			expect( stylesMap.size ).to.equal( 0 );
+		} );
+
+		it( 'should return number of set styles', () => {
+			stylesMap.setTo( 'color:blue' );
+			expect( stylesMap.size ).to.equal( 1 );
+
+			stylesMap.setTo( 'margin:1px;' );
+			expect( stylesMap.size ).to.equal( 1 );
+
+			stylesMap.setTo( 'margin-top:1px;margin-bottom:1px;' );
+			expect( stylesMap.size ).to.equal( 2 );
+		} );
+	} );
+
+	describe( 'setTo()', () => {
+		it( 'should reset styles to a new value', () => {
+			stylesMap.setTo( 'color:red;margin:1px;' );
+
+			expect( stylesMap.getNormalized() ).to.deep.equal( { color: 'red', margin: '1px' } );
+
+			stylesMap.setTo( 'overflow:hidden;' );
+
+			expect( stylesMap.getNormalized() ).to.deep.equal( { overflow: 'hidden' } );
+		} );
+
+		describe( 'styles parsing edge cases and incorrect styles', () => {
+			it( 'should not crash and not add any styles if styles attribute was empty', () => {
+				stylesMap.setTo( '' );
+
+				expect( stylesMap.getStyleNames() ).to.deep.equal( [] );
+			} );
+
+			it( 'should be able to parse big styles definition', () => {
+				expect( () => {
+					stylesMap.setTo( `background-image:url('data:image/jpeg;base64,${ encodedImage }')` );
+				} ).not.to.throw();
+			} );
+
+			it( 'should work with both types of quotes and ignore values inside quotes', () => {
+				stylesMap.setTo( 'background-image:url("im;color:g.jpg")' );
+				expect( stylesMap.getAsString( 'background-image' ) ).to.equal( 'url("im;color:g.jpg")' );
+
+				stylesMap.setTo( 'background-image:url(\'im;color:g.jpg\')' );
+				expect( stylesMap.getAsString( 'background-image' ) ).to.equal( 'url(\'im;color:g.jpg\')' );
+			} );
+
+			it( 'should not be confused by whitespaces', () => {
+				stylesMap.setTo( '\ncolor:\n red ' );
+
+				expect( stylesMap.getAsString( 'color' ) ).to.equal( 'red' );
+			} );
+
+			it( 'should not be confused by duplicated semicolon', () => {
+				stylesMap.setTo( 'color: red;; display: inline' );
+
+				expect( stylesMap.getAsString( 'color' ) ).to.equal( 'red' );
+				expect( stylesMap.getAsString( 'display' ) ).to.equal( 'inline' );
+			} );
+
+			it( 'should not throw when value is missing', () => {
+				stylesMap.setTo( 'color:; display: inline' );
+
+				expect( stylesMap.getAsString( 'color' ) ).to.equal( '' );
+				expect( stylesMap.getAsString( 'display' ) ).to.equal( 'inline' );
+			} );
+
+			it( 'should not throw when colon is duplicated', () => {
+				stylesMap.setTo( 'color:: red; display: inline' );
+
+				// The result makes no sense, but here we just check that the algorithm doesn't crash.
+				expect( stylesMap.getAsString( 'color' ) ).to.equal( ': red' );
+				expect( stylesMap.getAsString( 'display' ) ).to.equal( 'inline' );
+			} );
+
+			it( 'should not throw when random stuff passed', () => {
+				stylesMap.setTo( 'color: red;:; ;;" ":  display: inline; \'aaa;:' );
+
+				// The result makes no sense, but here we just check that the algorithm doesn't crash.
+				expect( stylesMap.getAsString( 'color' ) ).to.equal( 'red' );
+				expect( stylesMap.getAsString( 'display' ) ).to.be.undefined;
+			} );
+		} );
+	} );
+
+	describe( 'toString()', () => {
+		it( 'should return empty string for empty styles', () => {
+			expect( stylesMap.toString() ).to.equal( '' );
+		} );
+
+		it( 'should return sorted styles string if styles are set', () => {
+			stylesMap.setTo( 'margin-top:1px;color:blue;' );
+
+			expect( stylesMap.toString() ).to.equal( 'color:blue;margin-top:1px;' );
+		} );
+	} );
+
+	describe( 'getAsString()', () => {
+		it( 'should return empty string for missing shorthand', () => {
+			stylesMap.setTo( 'margin-top:1px' );
+
+			expect( stylesMap.getAsString( 'margin' ) ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'has()', () => {
+		it( 'should return false if property is not set', () => {
+			expect( stylesMap.has( 'bar' ) ).to.be.false;
+		} );
+
+		it( 'should return false if normalized longhand property is not set', () => {
+			stylesMap.setTo( 'foo-top:1px' );
+
+			expect( stylesMap.has( 'foo' ) ).to.be.false;
+		} );
+
+		it( 'should return true if normalized longhand property is set', () => {
+			stylesMap.setTo( 'foo-top:1px' );
+
+			expect( stylesMap.has( 'foo-top' ) ).to.be.true;
+		} );
+
+		it( 'should return true if non-normalized property is set', () => {
+			stylesMap.setTo( 'bar:deeppink' );
+
+			expect( stylesMap.has( 'bar' ) ).to.be.true;
+		} );
+
+		it( 'should return true if normalized shorthanded property is set', () => {
+			stylesMap.setTo( 'foo:1px' );
+
+			expect( stylesMap.has( 'foo' ) ).to.be.true;
+		} );
+
+		it( 'should return true if normalized long-hand property is set', () => {
+			stylesMap.setTo( 'foo:1px' );
+
+			expect( stylesMap.has( 'foo-top' ) ).to.be.true;
+		} );
+	} );
+
+	describe( 'set()', () => {
+		it( 'should insert new property (empty styles)', () => {
+			stylesMap.set( 'color', 'blue' );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.equal( 'blue' );
+		} );
+
+		it( 'should insert new property (other properties are set)', () => {
+			stylesMap.setTo( 'margin: 1px;' );
+			stylesMap.set( 'color', 'blue' );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.equal( 'blue' );
+		} );
+
+		it( 'should overwrite property', () => {
+			stylesMap.setTo( 'color: red;' );
+			stylesMap.set( 'color', 'blue' );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.equal( 'blue' );
+		} );
+
+		it( 'should set multiple styles by providing an object', () => {
+			stylesMap.setTo( 'color: red;' );
+			stylesMap.set( { color: 'blue', foo: '1px' } );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.equal( 'blue' );
+			expect( stylesMap.getAsString( 'foo-top' ) ).to.equal( '1px' );
+		} );
+
+		it( 'should set object property', () => {
+			stylesMap.setTo( 'foo:1px;' );
+			stylesMap.set( 'foo', { right: '2px' } );
+
+			expect( stylesMap.getAsString( 'foo-left' ) ).to.equal( '1px' );
+			expect( stylesMap.getAsString( 'foo-right' ) ).to.equal( '2px' );
+		} );
+	} );
+
+	describe( 'remove()', () => {
+		it( 'should do nothing if property is not set', () => {
+			stylesMap.remove( 'color' );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.be.undefined;
+		} );
+
+		it( 'should insert new property (other properties are set)', () => {
+			stylesMap.setTo( 'color:blue' );
+			stylesMap.remove( 'color' );
+
+			expect( stylesMap.getAsString( 'color' ) ).to.be.undefined;
+		} );
+
+		it( 'should remove normalized property', () => {
+			stylesMap.setTo( 'margin:1px' );
+
+			stylesMap.remove( 'margin-top' );
+
+			expect( stylesMap.getAsString( 'margin-top' ) ).to.be.undefined;
+		} );
+	} );
+
+	describe( 'getStyleNames()', () => {
+		it( 'should output empty array for empty styles', () => {
+			expect( stylesMap.getStyleNames() ).to.deep.equal( [] );
+		} );
+
+		it( 'should output custom style names', () => {
+			stylesMap.setTo( 'foo: 2;bar: baz;foo-bar-baz:none;' );
+
+			expect( stylesMap.getStyleNames() ).to.deep.equal( [ 'foo', 'bar', 'foo-bar-baz' ] );
+		} );
+
+		it( 'should output full names for known style names', () => {
+			stylesMap.setTo( 'foo: 1px;foo-top: 2em;' );
+
+			expect( stylesMap.getStyleNames() ).to.deep.equal( [ 'foo' ] );
+		} );
+	} );
+
+	describe( 'static _styleProcessor getter', () => {
+		it( 'should return StyleProcessor instance', () => {
+			// Set undefined to reset field value for code coverage.
+			StylesMap._setProcessor( undefined );
+			expect( StylesMap._styleProcessor ).to.be.instanceOf( StylesProcessor );
+		} );
+
+		it( 'should return the same StyleProcessor instance on consecutive calls', () => {
+			const stylesProcessor = StylesMap._styleProcessor;
+
+			expect( StylesMap._styleProcessor ).to.equal( stylesProcessor );
+		} );
+	} );
+} );

+ 3 - 3
packages/ckeditor5-engine/tests/view/uielement.js

@@ -15,7 +15,7 @@ describe( 'UIElement', () => {
 	beforeEach( () => {
 		uiElement = new UIElement( 'span', {
 			foo: 'bar',
-			style: 'border: 1px solid red;color: white;',
+			style: 'margin-top: 2em;color: white;',
 			class: 'foo bar'
 		} );
 	} );
@@ -24,7 +24,7 @@ describe( 'UIElement', () => {
 		it( 'should create instance', () => {
 			expect( uiElement.name ).to.equal( 'span' );
 			expect( uiElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( uiElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( uiElement.getStyle( 'margin-top' ) ).to.equal( '2em' );
 			expect( uiElement.getStyle( 'color' ) ).to.equal( 'white' );
 			expect( uiElement.hasClass( 'foo' ) ).to.true;
 			expect( uiElement.hasClass( 'bar' ) ).to.true;
@@ -101,7 +101,7 @@ describe( 'UIElement', () => {
 
 			expect( newUIElement.name ).to.equal( 'span' );
 			expect( newUIElement.getAttribute( 'foo' ) ).to.equal( 'bar' );
-			expect( newUIElement.getStyle( 'border' ) ).to.equal( '1px solid red' );
+			expect( newUIElement.getStyle( 'margin-top' ) ).to.equal( '2em' );
 			expect( newUIElement.getStyle( 'color' ) ).to.equal( 'white' );
 			expect( newUIElement.hasClass( 'foo' ) ).to.true;
 			expect( newUIElement.hasClass( 'bar' ) ).to.true;

+ 2 - 2
packages/ckeditor5-engine/tests/view/upcastwriter.js

@@ -508,8 +508,8 @@ describe( 'UpcastWriter', () => {
 
 			writer.removeStyle( [ 'color', 'position' ], el );
 
-			expect( el.hasStyle( 'color' ) ).to.false;
-			expect( el.hasStyle( 'position' ) ).to.false;
+			expect( el.hasStyle( 'color' ) ).to.be.false;
+			expect( el.hasStyle( 'position' ) ).to.be.false;
 			expect( Array.from( el.getStyleNames() ).length ).to.equal( 0 );
 		} );