UNPKG

node-webodf

Version:

WebODF - JavaScript Document Engine http://webodf.org/

2,265 lines • 104 kB
/**
 * Copyright (C) 2013 KO GmbH <copyright@kogmbh.com>
 *
 * @licstart
 * This file is part of WebODF.
 *
 * WebODF is free software: you can redistribute it and/or modify it
 * under the terms of the GNU Affero General Public License (GNU AGPL)
 * as published by the Free Software Foundation, either version 3 of
 * the License, or (at your option) any later version.
 *
 * WebODF is distributed in the hope that it will be useful, but
 * WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with WebODF.  If not, see <http://www.gnu.org/licenses/>.
 * @licend
 *
 * @source: http://www.webodf.org/
 * @source: https://github.com/kogmbh/WebODF/
 */

/*global runtime, ops */

/**
 * @constructor
 */
ops.OperationTransformMatrix = function OperationTransformMatrix() {
    "use strict";

    /* Utility methods */

    /**
     * Inverts the range spanned up by the spec's parameter position and length,
     * so that position is at the other end of the range and length relative to that.
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @return {undefined}
     */
    function invertMoveCursorSpecRange(moveCursorSpec) {
        moveCursorSpec.position = moveCursorSpec.position + moveCursorSpec.length;
        moveCursorSpec.length *= -1;
    }

    /**
     * Inverts the range spanned up by position and length if the length is negative.
     * Returns true if an inversion was done, false otherwise.
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @return {!boolean}
     */
    function invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec) {
        var isBackwards = (moveCursorSpec.length < 0);

        if (isBackwards) {
            invertMoveCursorSpecRange(moveCursorSpec);
        }
        return isBackwards;
    }

    /**
     * Returns a list with all attributes in setProperties that refer to styleName
     * @param {?odf.Formatting.StyleData} setProperties
     * @param {!string} styleName
     * @return {!Array.<!string>}
     */
    function getStyleReferencingAttributes(setProperties, styleName) {
        var attributes = [];
        /**
         * @param {string} attributeName
         */
        function check(attributeName) {
            if (setProperties[attributeName] === styleName) {
                attributes.push(attributeName);
            }
        }
        if (setProperties) {
            ['style:parent-style-name', 'style:next-style-name'].forEach(check);
        }
        return attributes;
    }
    /**
     * @param {?odf.Formatting.StyleData} setProperties
     * @param {!string} deletedStyleName
     * @return {undefined}
     */
    function dropStyleReferencingAttributes(setProperties, deletedStyleName) {
        /**
         * @param {string} attributeName
         */
        function del(attributeName) {
            if (setProperties[attributeName] === deletedStyleName) {
                delete setProperties[attributeName];
            }
        }
        if (setProperties) {
            ['style:parent-style-name', 'style:next-style-name'].forEach(del);
        }
    }

    /**
     * Creates a deep copy of the opspec
     * @param {!Object} opspec
     * @return {!Object}
     */
    function cloneOpspec(opspec) {
        var result = {};

        Object.keys(opspec).forEach(function (key) {
            if (typeof opspec[key] === 'object') {
                result[key] = cloneOpspec(opspec[key]);
            } else {
                result[key] = opspec[key];
            }
        });

        return result;
    }

    /**
     * @param {?Object.<string,*>} minorSetProperties
     * @param {?{attributes:string}} minorRemovedProperties
     * @param {?Object.<string,*>} majorSetProperties
     * @param {?{attributes:string}} majorRemovedProperties
     * @return {!{majorChanged:boolean,minorChanged:boolean}}
     */
    function dropOverruledAndUnneededAttributes(minorSetProperties, minorRemovedProperties, majorSetProperties, majorRemovedProperties) {
        var i, name,
            majorChanged = false, minorChanged = false,
            removedPropertyNames,
            /**@type{!Array.<string>}*/
            majorRemovedPropertyNames = [];
        if (majorRemovedProperties && majorRemovedProperties.attributes) {
            majorRemovedPropertyNames = majorRemovedProperties.attributes.split(',');
        }

        // iterate over all properties and see which get overwritten or deleted
        // by the overruling, so they have to be dropped
        if (minorSetProperties && (majorSetProperties || majorRemovedPropertyNames.length > 0)) {
            Object.keys(minorSetProperties).forEach(function (key) {
                var value = minorSetProperties[key],
                    overrulingPropertyValue;
                // TODO: support more than one level
                if (typeof value !== "object") {
                    if (majorSetProperties) {
                        overrulingPropertyValue = majorSetProperties[key];
                    }
                    if (overrulingPropertyValue !== undefined) {
                        // drop overruled
                        delete minorSetProperties[key];
                        minorChanged = true;

                        // major sets to same value?
                        if (overrulingPropertyValue === value) {
                            // drop major as well
                            delete majorSetProperties[key];
                            majorChanged = true;
                        }
                    } else if (majorRemovedPropertyNames.indexOf(key) !== -1) {
                        // drop overruled
                        delete minorSetProperties[key];
                        minorChanged = true;
                    }
                }
            });
        }

        // iterate over all overruling removed properties and drop any duplicates from
        // the removed property names
        if (minorRemovedProperties && minorRemovedProperties.attributes && (majorSetProperties || majorRemovedPropertyNames.length > 0)) {
            removedPropertyNames = minorRemovedProperties.attributes.split(',');
            for (i = 0; i < removedPropertyNames.length; i += 1) {
                name = removedPropertyNames[i];
                if ((majorSetProperties && majorSetProperties[name] !== undefined) ||
                        (majorRemovedPropertyNames && majorRemovedPropertyNames.indexOf(name) !== -1)) {
                    // drop
                    removedPropertyNames.splice(i, 1);
                    i -= 1;
                    minorChanged = true;
                }
            }
            // set back
            if (removedPropertyNames.length > 0) {
                minorRemovedProperties.attributes = removedPropertyNames.join(',');
            } else {
                delete minorRemovedProperties.attributes;
            }
        }

        return {
            majorChanged: majorChanged,
            minorChanged: minorChanged
        };
    }

    /**
     * Estimates if there are any properties set in the given properties object.
     * @param {!odf.Formatting.StyleData} properties
     * @return {!boolean}
     */
    function hasProperties(properties) {
        var /**@type{string}*/
            key;

        for (key in properties) {
            if (properties.hasOwnProperty(key)) {
                return true;
            }
        }
        return false;
    }

    /**
     * Estimates if there are any properties set in the given properties object.
     * @param {!{attributes:string}} properties
     * @return {!boolean}
     */
    function hasRemovedProperties(properties) {
        var /**@type{string}*/
            key;

        for (key in properties) {
            if (properties.hasOwnProperty(key)) {
                // handle empty 'attribute' as not existing
                if (key !== 'attributes' || properties.attributes.length > 0) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * @param {?odf.Formatting.StyleData} minorSet
     * @param {?Object.<string,{attributes:string}>} minorRem
     * @param {?odf.Formatting.StyleData} majorSet
     * @param {?Object.<string,{attributes:string}>} majorRem
     * @param {!string} propertiesName
     * @return {?{majorChanged:boolean,minorChanged:boolean}}
     */
    function dropOverruledAndUnneededProperties(minorSet, minorRem, majorSet, majorRem, propertiesName) {
        var minorSP = /**@type{?odf.Formatting.StyleData}*/(minorSet ? minorSet[propertiesName] : null),
            minorRP = minorRem ? minorRem[propertiesName] : null,
            majorSP = /**@type{?odf.Formatting.StyleData}*/(majorSet ? majorSet[propertiesName] : null),
            majorRP = majorRem ? majorRem[propertiesName] : null,
            result;

        // TODO: also care for nested properties, like there can be e.g. with text:paragraph-properties
        result = dropOverruledAndUnneededAttributes(minorSP, minorRP, majorSP, majorRP);

        // remove empty setProperties
        if (minorSP && !hasProperties(minorSP)) {
            delete minorSet[propertiesName];
        }
        // remove empty removedProperties
        if (minorRP && !hasRemovedProperties(minorRP)) {
            delete minorRem[propertiesName];
        }

        // remove empty setProperties
        if (majorSP && !hasProperties(majorSP)) {
            delete majorSet[propertiesName];
        }
        // remove empty removedProperties
        if (majorRP && !hasRemovedProperties(majorRP)) {
            delete majorRem[propertiesName];
        }

        return result;
    }



    /* Transformation methods */

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpecA
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationAddAnnotation(addAnnotationSpecA, addAnnotationSpecB, hasAPriority) {
        var firstAnnotationSpec, secondAnnotationSpec;

        if (addAnnotationSpecA.position < addAnnotationSpecB.position) {
            firstAnnotationSpec = addAnnotationSpecA;
            secondAnnotationSpec = addAnnotationSpecB;
        } else if (addAnnotationSpecB.position < addAnnotationSpecA.position) {
            firstAnnotationSpec = addAnnotationSpecB;
            secondAnnotationSpec = addAnnotationSpecA;
        } else {
            firstAnnotationSpec = hasAPriority ? addAnnotationSpecA : addAnnotationSpecB;
            secondAnnotationSpec = hasAPriority ? addAnnotationSpecB : addAnnotationSpecA;
        }

        if (secondAnnotationSpec.position < firstAnnotationSpec.position + firstAnnotationSpec.length) {
            firstAnnotationSpec.length += 2;
        }
        secondAnnotationSpec.position += 2;

        return {
            opSpecsA:  [addAnnotationSpecA],
            opSpecsB:  [addAnnotationSpecB]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationApplyDirectStyling(addAnnotationSpec, applyDirectStylingSpec) {
        if (addAnnotationSpec.position <= applyDirectStylingSpec.position) {
            applyDirectStylingSpec.position += 2;
        } else if (addAnnotationSpec.position <= applyDirectStylingSpec.position + applyDirectStylingSpec.length) {
            applyDirectStylingSpec.length += 2;
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [applyDirectStylingSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationInsertText(addAnnotationSpec, insertTextSpec) {
        if (insertTextSpec.position <= addAnnotationSpec.position) {
            addAnnotationSpec.position += insertTextSpec.text.length;
        } else {
            if (addAnnotationSpec.length !== undefined) {
                if (insertTextSpec.position <= addAnnotationSpec.position + addAnnotationSpec.length) {
                    addAnnotationSpec.length += insertTextSpec.text.length;
                }
            }
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            insertTextSpec.position += 2;
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [insertTextSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationMergeParagraph(addAnnotationSpec, mergeParagraphSpec) {
        if (mergeParagraphSpec.sourceStartPosition <= addAnnotationSpec.position) {
            addAnnotationSpec.position -= 1;
        } else {
            if (addAnnotationSpec.length !== undefined) {
                if (mergeParagraphSpec.sourceStartPosition <= addAnnotationSpec.position + addAnnotationSpec.length) {
                    addAnnotationSpec.length -= 1;
                }
            }

            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            mergeParagraphSpec.sourceStartPosition += 2;

            if (addAnnotationSpec.position < mergeParagraphSpec.destinationStartPosition) {
                mergeParagraphSpec.destinationStartPosition += 2;
            }
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [mergeParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationMoveCursor(addAnnotationSpec, moveCursorSpec) {
        var isMoveCursorSpecRangeInverted = invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec);

        // adapt movecursor spec to inserted positions
        if (addAnnotationSpec.position < moveCursorSpec.position) {
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            moveCursorSpec.position += 2;
        } else if (addAnnotationSpec.position < moveCursorSpec.position + moveCursorSpec.length) {
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            moveCursorSpec.length += 2;
        }

        if (isMoveCursorSpecRangeInverted) {
            invertMoveCursorSpecRange(moveCursorSpec);
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [moveCursorSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationRemoveAnnotation(addAnnotationSpec, removeAnnotationSpec) {
        // adapt movecursor spec to inserted positions
        if (addAnnotationSpec.position < removeAnnotationSpec.position) {
            if (removeAnnotationSpec.position < addAnnotationSpec.position + addAnnotationSpec.length) {
                addAnnotationSpec.length -= removeAnnotationSpec.length + 2;
            }
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            removeAnnotationSpec.position += 2;
        } else {
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            addAnnotationSpec.position -= removeAnnotationSpec.length + 2;
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [removeAnnotationSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationRemoveText(addAnnotationSpec, removeTextSpec) {
        var removeTextSpecPosition = removeTextSpec.position,
            removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length,
            annotationSpecEnd,
            helperOpspec,
            addAnnotationSpecResult = [addAnnotationSpec],
            removeTextSpecResult = [removeTextSpec];

        // adapt removeTextSpec
        if (addAnnotationSpec.position <= removeTextSpec.position) {
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            removeTextSpec.position += 2;
        } else if (addAnnotationSpec.position < removeTextSpecEnd) {
            // we have to split the removal into two ops, before and after the annotation start
            removeTextSpec.length = addAnnotationSpec.position - removeTextSpec.position;
            helperOpspec = {
                optype: "RemoveText",
                memberid: removeTextSpec.memberid,
                timestamp: removeTextSpec.timestamp,
                position: addAnnotationSpec.position + 2,
                length: removeTextSpecEnd - addAnnotationSpec.position
            };
            removeTextSpecResult.unshift(helperOpspec); // helperOp first, so its position is not affected by the real op
        }

        // adapt addAnnotationSpec (using already changed removeTextSpec and new helperOpspec, be aware)
        if (removeTextSpec.position + removeTextSpec.length <= addAnnotationSpec.position) {
            addAnnotationSpec.position -= removeTextSpec.length;
            if ((addAnnotationSpec.length !== undefined) && helperOpspec) {
                if (helperOpspec.length >= addAnnotationSpec.length) {
                    addAnnotationSpec.length = 0;
                } else {
                    addAnnotationSpec.length -= helperOpspec.length;
                }
            }
        } else if (addAnnotationSpec.length !== undefined) {
            annotationSpecEnd = addAnnotationSpec.position + addAnnotationSpec.length;
            if (removeTextSpecEnd <= annotationSpecEnd) {
                addAnnotationSpec.length -= removeTextSpec.length;
            } else if (removeTextSpecPosition < annotationSpecEnd) {
                addAnnotationSpec.length = removeTextSpecPosition - addAnnotationSpec.position;
            }
        }

        return {
            opSpecsA:  addAnnotationSpecResult,
            opSpecsB:  removeTextSpecResult
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationSetParagraphStyle(addAnnotationSpec, setParagraphStyleSpec) {
        if (addAnnotationSpec.position < setParagraphStyleSpec.position) {
            setParagraphStyleSpec.position += 2;
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [setParagraphStyleSpec]
        };
    }

    /**
     * @param {!ops.OpAddAnnotation.Spec} addAnnotationSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddAnnotationSplitParagraph(addAnnotationSpec, splitParagraphSpec) {
        if (addAnnotationSpec.position < splitParagraphSpec.sourceParagraphPosition) {
            splitParagraphSpec.sourceParagraphPosition += 2;
        }

        if (splitParagraphSpec.position <= addAnnotationSpec.position) {
            addAnnotationSpec.position += 1;
        } else {
            if (addAnnotationSpec.length !== undefined) {
                if (splitParagraphSpec.position <= addAnnotationSpec.position + addAnnotationSpec.length) {
                    addAnnotationSpec.length += 1;
                }
            }
            // 2, because 1 for pos inside annotation comment, 1 for new pos before annotated range
            splitParagraphSpec.position += 2;
        }

        return {
            opSpecsA:  [addAnnotationSpec],
            opSpecsB:  [splitParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpAddStyle.Spec} addStyleSpec
     * @param {!ops.OpRemoveStyle.Spec} removeStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformAddStyleRemoveStyle(addStyleSpec, removeStyleSpec) {
        var setAttributes,
            helperOpspec,
            addStyleSpecResult = [addStyleSpec],
            removeStyleSpecResult = [removeStyleSpec];

        if (addStyleSpec.styleFamily === removeStyleSpec.styleFamily) {
            // deleted style brought into use by addstyle op?
            setAttributes = getStyleReferencingAttributes(addStyleSpec.setProperties, removeStyleSpec.styleName);
            if (setAttributes.length > 0) {
                // just create a updateparagraph style op preceding to us which removes any set style from the paragraph
                helperOpspec = {
                    optype: "UpdateParagraphStyle",
                    memberid: removeStyleSpec.memberid,
                    timestamp: removeStyleSpec.timestamp,
                    styleName: addStyleSpec.styleName,
                    removedProperties: { attributes: setAttributes.join(',') }
                };
                removeStyleSpecResult.unshift(helperOpspec);
            }
            // in the addstyle op drop any attributes referencing the style deleted
            dropStyleReferencingAttributes(addStyleSpec.setProperties, removeStyleSpec.styleName);
        }

        return {
            opSpecsA:  addStyleSpecResult,
            opSpecsB:  removeStyleSpecResult
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpecA
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingApplyDirectStyling(applyDirectStylingSpecA, applyDirectStylingSpecB, hasAPriority) {
        var majorSpec, minorSpec, majorSpecResult, minorSpecResult,
            majorSpecEnd, minorSpecEnd, dropResult,
            originalMajorSpec, originalMinorSpec,
            helperOpspecBefore, helperOpspecAfter,
            applyDirectStylingSpecAResult = [applyDirectStylingSpecA],
            applyDirectStylingSpecBResult = [applyDirectStylingSpecB];

        // overlapping and any conflicting attributes?
        if (!(applyDirectStylingSpecA.position + applyDirectStylingSpecA.length <= applyDirectStylingSpecB.position ||
              applyDirectStylingSpecA.position >= applyDirectStylingSpecB.position + applyDirectStylingSpecB.length)) {
            // adapt to priority
            majorSpec = hasAPriority ? applyDirectStylingSpecA : applyDirectStylingSpecB;
            minorSpec = hasAPriority ? applyDirectStylingSpecB : applyDirectStylingSpecA;

            // might need original opspecs?
            if (applyDirectStylingSpecA.position !== applyDirectStylingSpecB.position ||
                    applyDirectStylingSpecA.length !== applyDirectStylingSpecB.length) {
                originalMajorSpec = cloneOpspec(majorSpec);
                originalMinorSpec = cloneOpspec(minorSpec);
            }

            // for the part that is overlapping reduce setProperties by the overruled properties
            dropResult = dropOverruledAndUnneededProperties(
                minorSpec.setProperties,
                null,
                majorSpec.setProperties,
                null,
                'style:text-properties'
            );

            if (dropResult.majorChanged || dropResult.minorChanged) {
                // split the less-priority op into several ops for the overlapping and non-overlapping ranges
                majorSpecResult = [];
                minorSpecResult = [];

                majorSpecEnd = majorSpec.position + majorSpec.length;
                minorSpecEnd = minorSpec.position + minorSpec.length;

                // find if there is a part before and if there is a part behind,
                // create range-adapted copies of the original opspec, if the spec has changed
                if (minorSpec.position < majorSpec.position) {
                    if (dropResult.minorChanged) {
                        helperOpspecBefore = cloneOpspec(/**@type{!Object}*/(originalMinorSpec));
                        helperOpspecBefore.length = majorSpec.position - minorSpec.position;
                        minorSpecResult.push(helperOpspecBefore);

                        minorSpec.position = majorSpec.position;
                        minorSpec.length = minorSpecEnd - minorSpec.position;
                    }
                } else if (majorSpec.position < minorSpec.position) {
                    if (dropResult.majorChanged) {
                        helperOpspecBefore = cloneOpspec(/**@type{!Object}*/(originalMajorSpec));
                        helperOpspecBefore.length = minorSpec.position - majorSpec.position;
                        majorSpecResult.push(helperOpspecBefore);

                        majorSpec.position = minorSpec.position;
                        majorSpec.length = majorSpecEnd - majorSpec.position;
                    }
                }
                if (minorSpecEnd > majorSpecEnd) {
                    if (dropResult.minorChanged) {
                        helperOpspecAfter = originalMinorSpec;
                        helperOpspecAfter.position = majorSpecEnd;
                        helperOpspecAfter.length = minorSpecEnd - majorSpecEnd;
                        minorSpecResult.push(helperOpspecAfter);

                        minorSpec.length = majorSpecEnd - minorSpec.position;
                    }
                } else if (majorSpecEnd > minorSpecEnd) {
                    if (dropResult.majorChanged) {
                        helperOpspecAfter = originalMajorSpec;
                        helperOpspecAfter.position = minorSpecEnd;
                        helperOpspecAfter.length = majorSpecEnd - minorSpecEnd;
                        majorSpecResult.push(helperOpspecAfter);

                        majorSpec.length = minorSpecEnd - majorSpec.position;
                    }
                }

                // check if there are any changes left and this op has not become a noop
                if (majorSpec.setProperties && hasProperties(majorSpec.setProperties)) {
                    majorSpecResult.push(majorSpec);
                }
                // check if there are any changes left and this op has not become a noop
                if (minorSpec.setProperties && hasProperties(minorSpec.setProperties)) {
                    minorSpecResult.push(minorSpec);
                }

                if (hasAPriority) {
                    applyDirectStylingSpecAResult = majorSpecResult;
                    applyDirectStylingSpecBResult = minorSpecResult;
                } else {
                    applyDirectStylingSpecAResult = minorSpecResult;
                    applyDirectStylingSpecBResult = majorSpecResult;
                }
            }
        }

        return {
            opSpecsA:  applyDirectStylingSpecAResult,
            opSpecsB:  applyDirectStylingSpecBResult
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingInsertText(applyDirectStylingSpec, insertTextSpec) {
        // adapt applyDirectStyling spec to inserted positions
        if (insertTextSpec.position <= applyDirectStylingSpec.position) {
            applyDirectStylingSpec.position += insertTextSpec.text.length;
        } else if (insertTextSpec.position <= applyDirectStylingSpec.position + applyDirectStylingSpec.length) {
            applyDirectStylingSpec.length += insertTextSpec.text.length;
        }

        return {
            opSpecsA:  [applyDirectStylingSpec],
            opSpecsB:  [insertTextSpec]
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingMergeParagraph(applyDirectStylingSpec, mergeParagraphSpec) {
        var pointA = applyDirectStylingSpec.position,
            pointB = applyDirectStylingSpec.position + applyDirectStylingSpec.length;

        // adapt applyDirectStyling spec to merged paragraph
        if (pointA >= mergeParagraphSpec.sourceStartPosition) {
            pointA -= 1;
        }
        if (pointB >= mergeParagraphSpec.sourceStartPosition) {
            pointB -= 1;
        }
        applyDirectStylingSpec.position = pointA;
        applyDirectStylingSpec.length = pointB - pointA;

        return {
            opSpecsA:  [applyDirectStylingSpec],
            opSpecsB:  [mergeParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingRemoveAnnotation(applyDirectStylingSpec, removeAnnotationSpec) {
        var pointA = applyDirectStylingSpec.position,
            pointB = applyDirectStylingSpec.position + applyDirectStylingSpec.length,
            removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            applyDirectStylingSpecResult = [applyDirectStylingSpec],
            removeAnnotationSpecResult = [removeAnnotationSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= pointA && pointB <= removeAnnotationEnd) {
            applyDirectStylingSpecResult = [];
        } else {
            // adapt applyDirectStyling spec to removed annotation content
            if (removeAnnotationEnd < pointA) {
                pointA -= removeAnnotationSpec.length + 2;
            }
            if (removeAnnotationEnd < pointB) {
                pointB -= removeAnnotationSpec.length + 2;
            }
            applyDirectStylingSpec.position = pointA;
            applyDirectStylingSpec.length = pointB - pointA;
        }

        return {
            opSpecsA:  applyDirectStylingSpecResult,
            opSpecsB:  removeAnnotationSpecResult
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingRemoveText(applyDirectStylingSpec, removeTextSpec) {
        var applyDirectStylingSpecEnd = applyDirectStylingSpec.position + applyDirectStylingSpec.length,
            removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length,
            applyDirectStylingSpecResult = [applyDirectStylingSpec],
            removeTextSpecResult = [removeTextSpec];

        // transform applyDirectStylingSpec
        // removed positions by object up to move cursor position?
        if (removeTextSpecEnd <= applyDirectStylingSpec.position) {
            // adapt by removed position
            applyDirectStylingSpec.position -= removeTextSpec.length;
        // overlapping?
        } else if (removeTextSpec.position < applyDirectStylingSpecEnd) {
            // still to select range starting at cursor position?
            if (applyDirectStylingSpec.position < removeTextSpec.position) {
                // still to select range ending at selection?
                if (removeTextSpecEnd < applyDirectStylingSpecEnd) {
                    applyDirectStylingSpec.length -= removeTextSpec.length;
                } else {
                    applyDirectStylingSpec.length = removeTextSpec.position - applyDirectStylingSpec.position;
                }
            // remove overlapping section
            } else {
                // fall at start of removed section
                applyDirectStylingSpec.position = removeTextSpec.position;
                // still to select range at selection end?
                if (removeTextSpecEnd < applyDirectStylingSpecEnd) {
                    applyDirectStylingSpec.length = applyDirectStylingSpecEnd - removeTextSpecEnd;
                } else {
                    // completely overlapped by other, so becomes no-op
                    // TODO: once we can address spans, removeTextSpec would need to get a helper op
                    // to remove the empty span left over
                    applyDirectStylingSpecResult = [];
                }
            }
        }

        return {
            opSpecsA:  applyDirectStylingSpecResult,
            opSpecsB:  removeTextSpecResult
        };
    }

    /**
     * @param {!ops.OpApplyDirectStyling.Spec} applyDirectStylingSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformApplyDirectStylingSplitParagraph(applyDirectStylingSpec, splitParagraphSpec) {
        // transform applyDirectStylingSpec
        if (splitParagraphSpec.position < applyDirectStylingSpec.position) {
            applyDirectStylingSpec.position += 1;
        } else if (splitParagraphSpec.position < applyDirectStylingSpec.position + applyDirectStylingSpec.length) {
            applyDirectStylingSpec.length += 1;
        }

        return {
            opSpecsA:  [applyDirectStylingSpec],
            opSpecsB:  [splitParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpecA
     * @param {!ops.OpInsertText.Spec} insertTextSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextInsertText(insertTextSpecA, insertTextSpecB, hasAPriority) {
        if (insertTextSpecA.position < insertTextSpecB.position) {
            insertTextSpecB.position += insertTextSpecA.text.length;
        } else if (insertTextSpecA.position > insertTextSpecB.position) {
            insertTextSpecA.position += insertTextSpecB.text.length;
        } else {
            if (hasAPriority) {
                insertTextSpecB.position += insertTextSpecA.text.length;
            } else {
                insertTextSpecA.position += insertTextSpecB.text.length;
            }
        }

        return {
            opSpecsA:  [insertTextSpecA],
            opSpecsB:  [insertTextSpecB]
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextMergeParagraph(insertTextSpec, mergeParagraphSpec) {
        if (insertTextSpec.position >= mergeParagraphSpec.sourceStartPosition) {
            insertTextSpec.position -= 1;
        } else {
            if (insertTextSpec.position < mergeParagraphSpec.sourceStartPosition) {
                mergeParagraphSpec.sourceStartPosition += insertTextSpec.text.length;
            }
            if (insertTextSpec.position < mergeParagraphSpec.destinationStartPosition) {
                mergeParagraphSpec.destinationStartPosition += insertTextSpec.text.length;
            }
        }

        return {
            opSpecsA:  [insertTextSpec],
            opSpecsB:  [mergeParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextMoveCursor(insertTextSpec, moveCursorSpec) {
        var isMoveCursorSpecRangeInverted = invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec);

        // adapt movecursor spec to inserted positions
        if (insertTextSpec.position < moveCursorSpec.position) {
            moveCursorSpec.position += insertTextSpec.text.length;
        } else if (insertTextSpec.position < moveCursorSpec.position + moveCursorSpec.length) {
            moveCursorSpec.length += insertTextSpec.text.length;
        }

        if (isMoveCursorSpecRangeInverted) {
            invertMoveCursorSpecRange(moveCursorSpec);
        }

        return {
            opSpecsA:  [insertTextSpec],
            opSpecsB:  [moveCursorSpec]
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextRemoveAnnotation(insertTextSpec, removeAnnotationSpec) {
        var insertTextSpecPosition = insertTextSpec.position,
            removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            insertTextSpecResult = [insertTextSpec],
            removeAnnotationSpecResult = [removeAnnotationSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= insertTextSpecPosition && insertTextSpecPosition <= removeAnnotationEnd) {
            insertTextSpecResult = [];
            removeAnnotationSpec.length += insertTextSpec.text.length;
        } else {
            // adapt insertText spec to removed annotation content
            if (removeAnnotationEnd < insertTextSpec.position) {
                insertTextSpec.position -= removeAnnotationSpec.length + 2;
            } else {
                removeAnnotationSpec.position += insertTextSpec.text.length;
            }
        }

        return {
            opSpecsA:  insertTextSpecResult,
            opSpecsB:  removeAnnotationSpecResult
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextRemoveText(insertTextSpec, removeTextSpec) {
        var helperOpspec,
            removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length,
            insertTextSpecResult = [insertTextSpec],
            removeTextSpecResult = [removeTextSpec];

        // update insertTextSpec
        // removed before/up to insertion point?
        if (removeTextSpecEnd <= insertTextSpec.position) {
            insertTextSpec.position -= removeTextSpec.length;
        // removed at/behind insertion point
        } else if (insertTextSpec.position <= removeTextSpec.position) {
            removeTextSpec.position += insertTextSpec.text.length;
        // insertion in middle of removed range
        } else {
            // we have to split the removal into two ops, before and after the insertion point
            removeTextSpec.length = insertTextSpec.position - removeTextSpec.position;
            helperOpspec = {
                optype: "RemoveText",
                memberid: removeTextSpec.memberid,
                timestamp: removeTextSpec.timestamp,
                position: insertTextSpec.position + insertTextSpec.text.length,
                length: removeTextSpecEnd - insertTextSpec.position
            };
            removeTextSpecResult.unshift(helperOpspec); // helperOp first, so its position is not affected by the real op
            // drop insertion point to begin of removed range
            // original insertTextSpec.position is used for removeTextSpec changes, so only change now
            insertTextSpec.position = removeTextSpec.position;
        }

        return {
            opSpecsA:  insertTextSpecResult,
            opSpecsB:  removeTextSpecResult
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextSetParagraphStyle(insertTextSpec, setParagraphStyleSpec) {
        if (setParagraphStyleSpec.position > insertTextSpec.position) {
            setParagraphStyleSpec.position += insertTextSpec.text.length;
        }

        return {
            opSpecsA:  [insertTextSpec],
            opSpecsB:  [setParagraphStyleSpec]
        };
    }

    /**
     * @param {!ops.OpInsertText.Spec} insertTextSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformInsertTextSplitParagraph(insertTextSpec, splitParagraphSpec) {
        if (insertTextSpec.position < splitParagraphSpec.sourceParagraphPosition) {
            splitParagraphSpec.sourceParagraphPosition += insertTextSpec.text.length;
        }

        if (insertTextSpec.position <= splitParagraphSpec.position) {
            splitParagraphSpec.position += insertTextSpec.text.length;
        } else {
            insertTextSpec.position += 1;
        }

        return {
            opSpecsA:  [insertTextSpec],
            opSpecsB:  [splitParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpecA
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphMergeParagraph(mergeParagraphSpecA, mergeParagraphSpecB, hasAPriority) {
        var specsForB = [mergeParagraphSpecA],
            specsForA = [mergeParagraphSpecB],
            priorityOp,
            styleParagraphFixup,
            moveCursorA,
            moveCursorB;

        if (mergeParagraphSpecA.destinationStartPosition === mergeParagraphSpecB.destinationStartPosition) {
            // Two merge commands for the same paragraph result in a noop to both sides, as the same
            // paragraph can only be merged once.
            specsForB = [];
            specsForA = [];
            // If the moveCursor flag is set, the cursor will still need to be adjusted to the right location
            if (mergeParagraphSpecA.moveCursor) {
                moveCursorA = /**@type{!ops.OpMoveCursor.Spec}*/({
                    optype: "MoveCursor",
                    memberid: mergeParagraphSpecA.memberid,
                    timestamp: mergeParagraphSpecA.timestamp,
                    position: mergeParagraphSpecA.sourceStartPosition - 1
                });
                specsForB.push(moveCursorA);
            }
            if (mergeParagraphSpecB.moveCursor) {
                moveCursorB = /**@type{!ops.OpMoveCursor.Spec}*/({
                    optype: "MoveCursor",
                    memberid: mergeParagraphSpecB.memberid,
                    timestamp: mergeParagraphSpecB.timestamp,
                    position: mergeParagraphSpecB.sourceStartPosition - 1
                });
                specsForA.push(moveCursorB);
            }

            // Determine which merge style wins
            priorityOp = hasAPriority ? mergeParagraphSpecA : mergeParagraphSpecB;
            styleParagraphFixup = /**@type{!ops.OpSetParagraphStyle.Spec}*/({
                optype: "SetParagraphStyle",
                memberid: priorityOp.memberid,
                timestamp: priorityOp.timestamp,
                position: priorityOp.destinationStartPosition,
                styleName: priorityOp.paragraphStyleName
            });
            if (hasAPriority) {
                specsForB.push(styleParagraphFixup);
            } else {
                specsForA.push(styleParagraphFixup);
            }
        } else if (mergeParagraphSpecB.sourceStartPosition === mergeParagraphSpecA.destinationStartPosition) {
            // Two consecutive paragraphs are being merged. E.g., A <- B <- C.
            // Use the styleName of the lowest destination paragraph to set the paragraph style (A <- B)
            mergeParagraphSpecA.destinationStartPosition = mergeParagraphSpecB.destinationStartPosition;
            mergeParagraphSpecA.sourceStartPosition -= 1;
            mergeParagraphSpecA.paragraphStyleName = mergeParagraphSpecB.paragraphStyleName;
        } else if (mergeParagraphSpecA.sourceStartPosition === mergeParagraphSpecB.destinationStartPosition) {
            // Two consecutive paragraphs are being merged. E.g., A <- B <- C.
            // Use the styleName of the lowest destination paragraph to set the paragraph style (A <- B)
            mergeParagraphSpecB.destinationStartPosition = mergeParagraphSpecA.destinationStartPosition;
            mergeParagraphSpecB.sourceStartPosition -= 1;
            mergeParagraphSpecB.paragraphStyleName = mergeParagraphSpecA.paragraphStyleName;
        } else if (mergeParagraphSpecA.destinationStartPosition < mergeParagraphSpecB.destinationStartPosition) {
            mergeParagraphSpecB.destinationStartPosition -= 1;
            mergeParagraphSpecB.sourceStartPosition -= 1;
        } else { // mergeParagraphSpecB.destinationStartPosition < mergeParagraphSpecA.destinationStartPosition
            mergeParagraphSpecA.destinationStartPosition -= 1;
            mergeParagraphSpecA.sourceStartPosition -= 1;
        }

        return {
            opSpecsA:  specsForB,
            opSpecsB:  specsForA
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphMoveCursor(mergeParagraphSpec, moveCursorSpec) {
        var pointA = moveCursorSpec.position,
            pointB = moveCursorSpec.position + moveCursorSpec.length,
            start = Math.min(pointA, pointB),
            end = Math.max(pointA, pointB);

        if (start >= mergeParagraphSpec.sourceStartPosition) {
            start -= 1;
        }
        if (end >= mergeParagraphSpec.sourceStartPosition) {
            end -= 1;
        }

        // When updating the cursor spec, ensure the selection direction is preserved.
        // If the length was previously positive, it should remain positive.
        if (moveCursorSpec.length >= 0) {
            moveCursorSpec.position = start;
            moveCursorSpec.length = end - start;
        } else {
            moveCursorSpec.position = end;
            moveCursorSpec.length = start - end;
        }

        return {
            opSpecsA:  [mergeParagraphSpec],
            opSpecsB:  [moveCursorSpec]
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphRemoveAnnotation(mergeParagraphSpec, removeAnnotationSpec) {
        var removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            mergeParagraphSpecResult = [mergeParagraphSpec],
            removeAnnotationSpecResult = [removeAnnotationSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= mergeParagraphSpec.destinationStartPosition && mergeParagraphSpec.sourceStartPosition <= removeAnnotationEnd) {
            mergeParagraphSpecResult = [];
            removeAnnotationSpec.length -= 1;
        } else {
            if (mergeParagraphSpec.sourceStartPosition < removeAnnotationSpec.position) {
                removeAnnotationSpec.position -= 1;
            } else {
                if (removeAnnotationEnd < mergeParagraphSpec.destinationStartPosition) {
                    mergeParagraphSpec.destinationStartPosition -= removeAnnotationSpec.length + 2;
                }
                if (removeAnnotationEnd < mergeParagraphSpec.sourceStartPosition) {
                    mergeParagraphSpec.sourceStartPosition -= removeAnnotationSpec.length + 2;
                }
            }
        }

        return {
            opSpecsA:  mergeParagraphSpecResult,
            opSpecsB:  removeAnnotationSpecResult
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphRemoveText(mergeParagraphSpec, removeTextSpec) {
        // RemoveText ops can't cross paragraph boundaries, so only the position needs to be checked
        if (removeTextSpec.position >= mergeParagraphSpec.sourceStartPosition) {
            removeTextSpec.position -= 1;
        } else {
            if (removeTextSpec.position < mergeParagraphSpec.destinationStartPosition) {
                mergeParagraphSpec.destinationStartPosition -= removeTextSpec.length;
            }
            if (removeTextSpec.position < mergeParagraphSpec.sourceStartPosition) {
                mergeParagraphSpec.sourceStartPosition -= removeTextSpec.length;
            }
        }

        return {
            opSpecsA:  [mergeParagraphSpec],
            opSpecsB:  [removeTextSpec]
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphSetParagraphStyle(mergeParagraphSpec, setParagraphStyleSpec) {
        var opSpecsA = [mergeParagraphSpec],
            opSpecsB = [setParagraphStyleSpec];

        // SetParagraphStyle ops can't cross paragraph boundaries
        if (setParagraphStyleSpec.position > mergeParagraphSpec.sourceStartPosition) {
            // Paragraph beyond the ones region affected by the merge
            setParagraphStyleSpec.position -= 1;
        } else if (setParagraphStyleSpec.position === mergeParagraphSpec.destinationStartPosition
                    || setParagraphStyleSpec.position === mergeParagraphSpec.sourceStartPosition) {
            // Attempting to style a merging paragraph
            setParagraphStyleSpec.position = mergeParagraphSpec.destinationStartPosition;
            mergeParagraphSpec.paragraphStyleName = setParagraphStyleSpec.styleName;
        }

        return {
            opSpecsA:  opSpecsA,
            opSpecsB:  opSpecsB
        };
    }

    /**
     * @param {!ops.OpMergeParagraph.Spec} mergeParagraphSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMergeParagraphSplitParagraph(mergeParagraphSpec, splitParagraphSpec) {
        var styleSplitParagraph,
            moveCursorOp,
            opSpecsA = [mergeParagraphSpec],
            opSpecsB = [splitParagraphSpec];

        if (splitParagraphSpec.position < mergeParagraphSpec.destinationStartPosition) {
            // Split occurs before the merge destination
            // Splitting a paragraph inserts one step, moving the merge along
            mergeParagraphSpec.destinationStartPosition += 1;
            mergeParagraphSpec.sourceStartPosition += 1;
        } else if (splitParagraphSpec.position >= mergeParagraphSpec.destinationStartPosition
            && splitParagraphSpec.position < mergeParagraphSpec.sourceStartPosition) {
            // split occurs within the paragraphs being merged
            splitParagraphSpec.paragraphStyleName = mergeParagraphSpec.paragraphStyleName;
            styleSplitParagraph = /**@type{!ops.OpSetParagraphStyle.Spec}*/({
                optype: "SetParagraphStyle",
                memberid: mergeParagraphSpec.memberid,
                timestamp: mergeParagraphSpec.timestamp,
                position: mergeParagraphSpec.destinationStartPosition,
                styleName: mergeParagraphSpec.paragraphStyleName
            });
            opSpecsA.push(styleSplitParagraph);
            if (splitParagraphSpec.position === mergeParagraphSpec.sourceStartPosition - 1
                    && mergeParagraphSpec.moveCursor) {
                // OdtDocument.getTextNodeAtStep + Spec.moveCursor make it very difficult to control cursor placement
                // When a split + merge combines, there is a tricky situation because the split will leave other cursors
                // on the last step in the new paragraph.
                // When the merge is relocated to attach to the front of the newly inserted paragraph below, the cursor
                // will end up at the start of the new paragraph. Workaround this by manually setting the cursor back
                // to the appropriate location after the merge completes
                moveCursorOp = /**@type{!ops.OpMoveCursor.Spec}*/({
                    optype: "MoveCursor",
                    memberid: mergeParagraphSpec.memberid,
                    timestamp: mergeParagraphSpec.timestamp,
                    position: splitParagraphSpec.position,
                    length: 0
                });
                opSpecsA.push(moveCursorOp);
            }

            // SplitParagraph ops effectively create new paragraph boundaries. The user intent
            // is for the source paragraph to be joined to the END of the dest paragraph. If the
            // split occurs in the dest paragraph, the source should be joined to the newly created
            // paragraph instead
            mergeParagraphSpec.destinationStartPosition = splitParagraphSpec.position + 1;
            mergeParagraphSpec.sourceStartPosition += 1;
        } else if (splitParagraphSpec.position >= mergeParagraphSpec.sourceStartPosition) {
            // Split occurs after the merge source
            // Merging paragraphs remove one step
            splitParagraphSpec.position -= 1;
            splitParagraphSpec.sourceParagraphPosition -= 1;
        }

        return {
            opSpecsA:  opSpecsA,
            opSpecsB:  opSpecsB
        };
    }

    /**
     * @param {!ops.OpUpdateParagraphStyle.Spec} updateParagraphStyleSpecA
     * @param {!ops.OpUpdateParagraphStyle.Spec} updateParagraphStyleSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformUpdateParagraphStyleUpdateParagraphStyle(updateParagraphStyleSpecA, updateParagraphStyleSpecB, hasAPriority) {
        var majorSpec, minorSpec,
            updateParagraphStyleSpecAResult = [updateParagraphStyleSpecA],
            updateParagraphStyleSpecBResult = [updateParagraphStyleSpecB];

        // same style updated by other op?
        if (updateParagraphStyleSpecA.styleName === updateParagraphStyleSpecB.styleName) {
            majorSpec = hasAPriority ? updateParagraphStyleSpecA : updateParagraphStyleSpecB;
            minorSpec = hasAPriority ? updateParagraphStyleSpecB : updateParagraphStyleSpecA;

            // any properties which are set by other update op need to be dropped
            dropOverruledAndUnneededProperties(minorSpec.setProperties,
                minorSpec.removedProperties, majorSpec.setProperties,
                majorSpec.removedProperties, 'style:paragraph-properties');
            dropOverruledAndUnneededProperties(minorSpec.setProperties,
                minorSpec.removedProperties, majorSpec.setProperties,
                majorSpec.removedProperties, 'style:text-properties');
            dropOverruledAndUnneededAttributes(minorSpec.setProperties || null,
                /**@type{{attributes: string}}*/(minorSpec.removedProperties) || null,
                majorSpec.setProperties || null,
                /**@type{{attributes: string}}*/(majorSpec.removedProperties) || null);

            // check if there are any changes left and the major op has not become a noop
            if (!(majorSpec.setProperties && hasProperties(majorSpec.setProperties)) &&
                    !(majorSpec.removedProperties && hasRemovedProperties(majorSpec.removedProperties))) {
                // set major spec to noop
                if (hasAPriority) {
                    updateParagraphStyleSpecAResult = [];
                } else {
                    updateParagraphStyleSpecBResult = [];
                }
            }
            // check if there are any changes left and the minor op has not become a noop
            if (!(minorSpec.setProperties && hasProperties(minorSpec.setProperties)) &&
                    !(minorSpec.removedProperties && hasRemovedProperties(minorSpec.removedProperties))) {
                // set minor spec to noop 
                if (hasAPriority) {
                    updateParagraphStyleSpecBResult = [];
                } else {
                    updateParagraphStyleSpecAResult = [];
                }
            }
        }

        return {
            opSpecsA:  updateParagraphStyleSpecAResult,
            opSpecsB:  updateParagraphStyleSpecBResult
        };
    }

    /**
     * @param {!ops.OpUpdateMetadata.Spec} updateMetadataSpecA
     * @param {!ops.OpUpdateMetadata.Spec} updateMetadataSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformUpdateMetadataUpdateMetadata(updateMetadataSpecA, updateMetadataSpecB, hasAPriority) {
        var majorSpec, minorSpec,
            updateMetadataSpecAResult = [updateMetadataSpecA],
            updateMetadataSpecBResult = [updateMetadataSpecB];

        majorSpec = hasAPriority ? updateMetadataSpecA : updateMetadataSpecB;
        minorSpec = hasAPriority ? updateMetadataSpecB : updateMetadataSpecA;

        // any properties which are set by other update op need to be dropped
        dropOverruledAndUnneededAttributes(minorSpec.setProperties || null,
                            minorSpec.removedProperties || null,
                            majorSpec.setProperties || null,
                            majorSpec.removedProperties || null);

        // check if there are any changes left and the major op has not become a noop
        if (!(majorSpec.setProperties && hasProperties(majorSpec.setProperties)) &&
                !(majorSpec.removedProperties && hasRemovedProperties(majorSpec.removedProperties))) {
            // set major spec to noop
            if (hasAPriority) {
                updateMetadataSpecAResult = [];
            } else {
                updateMetadataSpecBResult = [];
            }
        }
        // check if there are any changes left and the minor op has not become a noop
        if (!(minorSpec.setProperties && hasProperties(minorSpec.setProperties)) &&
                !(minorSpec.removedProperties && hasRemovedProperties(minorSpec.removedProperties))) {
            // set minor spec to noop 
            if (hasAPriority) {
                updateMetadataSpecBResult = [];
            } else {
                updateMetadataSpecAResult = [];
            }
        }

        return {
            opSpecsA:  updateMetadataSpecAResult,
            opSpecsB:  updateMetadataSpecBResult
        };
    }

    /**
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpecA
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformSetParagraphStyleSetParagraphStyle(setParagraphStyleSpecA, setParagraphStyleSpecB, hasAPriority) {
        if (setParagraphStyleSpecA.position === setParagraphStyleSpecB.position) {
            if (hasAPriority) {
                setParagraphStyleSpecB.styleName = setParagraphStyleSpecA.styleName;
            } else {
                setParagraphStyleSpecA.styleName = setParagraphStyleSpecB.styleName;
            }
        }

        return {
            opSpecsA:  [setParagraphStyleSpecA],
            opSpecsB:  [setParagraphStyleSpecB]
        };
    }

    /**
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformSetParagraphStyleSplitParagraph(setParagraphStyleSpec, splitParagraphSpec) {
        var opSpecsA = [setParagraphStyleSpec],
            opSpecsB = [splitParagraphSpec],
            setParagraphClone;

        if (setParagraphStyleSpec.position > splitParagraphSpec.position) {
            setParagraphStyleSpec.position += 1;
        } else if (setParagraphStyleSpec.position === splitParagraphSpec.sourceParagraphPosition) {
            // When a set paragraph style & split conflict, the set paragraph style always wins

            splitParagraphSpec.paragraphStyleName = setParagraphStyleSpec.styleName;
            // The new paragraph that resulted from the already executed split op should be styled with
            // the original paragraph style.
            setParagraphClone = cloneOpspec(setParagraphStyleSpec);
            // A split paragraph op introduces a new paragraph boundary just passed the point where the split occurs
            setParagraphClone.position = splitParagraphSpec.position + 1;
            opSpecsA.push(setParagraphClone);
        }

        return {
            opSpecsA:  opSpecsA,
            opSpecsB:  opSpecsB
        };
    }

    /**
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpecA
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpecB
     * @param {!boolean} hasAPriority
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformSplitParagraphSplitParagraph(splitParagraphSpecA, splitParagraphSpecB, hasAPriority) {
        var specABeforeB,
            specBBeforeA;

        if (splitParagraphSpecA.position < splitParagraphSpecB.position) {
            specABeforeB =  true;
        } else if (splitParagraphSpecB.position < splitParagraphSpecA.position) {
            specBBeforeA = true;
        } else if (splitParagraphSpecA.position === splitParagraphSpecB.position) {
            if (hasAPriority) {
                specABeforeB =  true;
            } else {
                specBBeforeA = true;
            }
        }

        if (specABeforeB) {
            splitParagraphSpecB.position += 1;
            if (splitParagraphSpecA.position < splitParagraphSpecB.sourceParagraphPosition) {
                splitParagraphSpecB.sourceParagraphPosition += 1;
            } else {
                // Split occurs between specB's split position & it's source paragraph position
                // This means specA introduces a NEW paragraph boundary
                splitParagraphSpecB.sourceParagraphPosition = splitParagraphSpecA.position + 1;
            }
        } else if (specBBeforeA) {
            splitParagraphSpecA.position += 1;
            if (splitParagraphSpecB.position < splitParagraphSpecB.sourceParagraphPosition) {
                splitParagraphSpecA.sourceParagraphPosition += 1;
            } else {
                // Split occurs between specA's split position & it's source paragraph position
                // This means specB introduces a NEW paragraph boundary
                splitParagraphSpecA.sourceParagraphPosition = splitParagraphSpecB.position + 1;
            }
        }

        return {
            opSpecsA:  [splitParagraphSpecA],
            opSpecsB:  [splitParagraphSpecB]
        };
    }

    /**
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMoveCursorRemoveAnnotation(moveCursorSpec, removeAnnotationSpec) {
        var isMoveCursorSpecRangeInverted = invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec),
            moveCursorSpecEnd = moveCursorSpec.position + moveCursorSpec.length,
            removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length;

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= moveCursorSpec.position && moveCursorSpecEnd <= removeAnnotationEnd) {
            moveCursorSpec.position = removeAnnotationSpec.position - 1;
            moveCursorSpec.length = 0;
        } else {
            if (removeAnnotationEnd < moveCursorSpec.position) {
                moveCursorSpec.position -= removeAnnotationSpec.length + 2;
            } else if (removeAnnotationEnd < moveCursorSpecEnd) {
                moveCursorSpec.length -= removeAnnotationSpec.length + 2;
            }
            if (isMoveCursorSpecRangeInverted) {
                invertMoveCursorSpecRange(moveCursorSpec);
            }
        }

        return {
            opSpecsA:  [moveCursorSpec],
            opSpecsB:  [removeAnnotationSpec]
        };
    }

    /**
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @param {!ops.OpRemoveCursor.Spec} removeCursorSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMoveCursorRemoveCursor(moveCursorSpec, removeCursorSpec) {
        var isSameCursorRemoved = (moveCursorSpec.memberid === removeCursorSpec.memberid);

        return {
            opSpecsA:  isSameCursorRemoved ? [] : [moveCursorSpec],
            opSpecsB:  [removeCursorSpec]
        };
    }

    /**
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMoveCursorRemoveText(moveCursorSpec, removeTextSpec) {
        var isMoveCursorSpecRangeInverted = invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec),
            moveCursorSpecEnd = moveCursorSpec.position + moveCursorSpec.length,
            removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length;

        // transform moveCursorSpec
        // removed positions by object up to move cursor position?
        if (removeTextSpecEnd <= moveCursorSpec.position) {
            // adapt by removed position
            moveCursorSpec.position -= removeTextSpec.length;
        // overlapping?
        } else if (removeTextSpec.position < moveCursorSpecEnd) {
            // still to select range starting at cursor position?
            if (moveCursorSpec.position < removeTextSpec.position) {
                // still to select range ending at selection?
                if (removeTextSpecEnd < moveCursorSpecEnd) {
                    moveCursorSpec.length -= removeTextSpec.length;
                } else {
                    moveCursorSpec.length = removeTextSpec.position - moveCursorSpec.position;
                }
            // remove overlapping section
            } else {
                // fall at start of removed section
                moveCursorSpec.position = removeTextSpec.position;
                // still to select range at selection end?
                if (removeTextSpecEnd < moveCursorSpecEnd) {
                    moveCursorSpec.length = moveCursorSpecEnd - removeTextSpecEnd;
                } else {
                    // completely overlapped by other, so selection gets void
                    moveCursorSpec.length = 0;
                }
            }
        }

        if (isMoveCursorSpecRangeInverted) {
            invertMoveCursorSpecRange(moveCursorSpec);
        }

        return {
            opSpecsA:  [moveCursorSpec],
            opSpecsB:  [removeTextSpec]
        };
    }

    /**
     * @param {!ops.OpMoveCursor.Spec} moveCursorSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformMoveCursorSplitParagraph(moveCursorSpec, splitParagraphSpec) {
        var isMoveCursorSpecRangeInverted = invertMoveCursorSpecRangeOnNegativeLength(moveCursorSpec);

        // transform moveCursorSpec
        if (splitParagraphSpec.position < moveCursorSpec.position) {
            moveCursorSpec.position += 1;
        } else if (splitParagraphSpec.position < moveCursorSpec.position + moveCursorSpec.length) {
            moveCursorSpec.length += 1;
        }

        if (isMoveCursorSpecRangeInverted) {
            invertMoveCursorSpecRange(moveCursorSpec);
        }

        return {
            opSpecsA:  [moveCursorSpec],
            opSpecsB:  [splitParagraphSpec]
        };
    }

    /**
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpecA
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpecB
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveAnnotationRemoveAnnotation(removeAnnotationSpecA, removeAnnotationSpecB) {
        var removeAnnotationSpecAResult = [removeAnnotationSpecA],
            removeAnnotationSpecBResult = [removeAnnotationSpecB];

        // check if removing the same annotation
        if (removeAnnotationSpecA.position === removeAnnotationSpecB.position && removeAnnotationSpecA.length === removeAnnotationSpecB.length) {
            removeAnnotationSpecAResult = [];
            removeAnnotationSpecBResult = [];
        } else {
            if (removeAnnotationSpecA.position < removeAnnotationSpecB.position) {
                removeAnnotationSpecB.position -= removeAnnotationSpecA.length + 2;
            } else {
                removeAnnotationSpecA.position -= removeAnnotationSpecB.length + 2;
            }
        }

        return {
            opSpecsA:  removeAnnotationSpecAResult,
            opSpecsB:  removeAnnotationSpecBResult
        };
    }

    /**
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveAnnotationRemoveText(removeAnnotationSpec, removeTextSpec) {
        var removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length,
            removeAnnotationSpecResult = [removeAnnotationSpec],
            removeTextSpecResult = [removeTextSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= removeTextSpec.position && removeTextSpecEnd <= removeAnnotationEnd) {
            removeTextSpecResult = [];
            removeAnnotationSpec.length -= removeTextSpec.length;
        } else {
            if (removeTextSpecEnd < removeAnnotationSpec.position) {
                removeAnnotationSpec.position -= removeTextSpec.length;
            } else if (removeTextSpec.position < removeAnnotationSpec.position) {
                removeAnnotationSpec.position = removeTextSpec.position + 1;
                removeTextSpec.length -= removeAnnotationSpec.length + 2;
            } else {
                removeTextSpec.position -= removeAnnotationSpec.length + 2;
            }
        }

        return {
            opSpecsA:  removeAnnotationSpecResult,
            opSpecsB:  removeTextSpecResult
        };
    }

    /**
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveAnnotationSetParagraphStyle(removeAnnotationSpec, setParagraphStyleSpec) {
        var setParagraphStyleSpecPosition = setParagraphStyleSpec.position,
            removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            removeAnnotationSpecResult = [removeAnnotationSpec],
            setParagraphStyleSpecResult = [setParagraphStyleSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= setParagraphStyleSpecPosition && setParagraphStyleSpecPosition <= removeAnnotationEnd) {
            setParagraphStyleSpecResult = [];
        } else {
            if (removeAnnotationEnd < setParagraphStyleSpecPosition) {
                setParagraphStyleSpec.position -= removeAnnotationSpec.length + 2;
            }
        }

        return {
            opSpecsA:  removeAnnotationSpecResult,
            opSpecsB:  setParagraphStyleSpecResult
        };
    }

    /**
     * @param {!ops.OpRemoveAnnotation.Spec} removeAnnotationSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveAnnotationSplitParagraph(removeAnnotationSpec, splitParagraphSpec) {
        var splitParagraphSpecPosition = splitParagraphSpec.position,
            removeAnnotationEnd = removeAnnotationSpec.position + removeAnnotationSpec.length,
            removeAnnotationSpecResult = [removeAnnotationSpec],
            splitParagraphSpecResult = [splitParagraphSpec];

        // check if inside removed annotation
        if (removeAnnotationSpec.position <= splitParagraphSpecPosition && splitParagraphSpecPosition <= removeAnnotationEnd) {
            splitParagraphSpecResult = [];
            removeAnnotationSpec.length += 1;
        } else {
            if (removeAnnotationEnd < splitParagraphSpec.sourceParagraphPosition) {
                splitParagraphSpec.sourceParagraphPosition -= removeAnnotationSpec.length + 2;
            }
            if (removeAnnotationEnd < splitParagraphSpecPosition) {
                splitParagraphSpec.position -= removeAnnotationSpec.length + 2;
            } else {
                removeAnnotationSpec.position += 1;
            }
        }

        return {
            opSpecsA:  removeAnnotationSpecResult,
            opSpecsB:  splitParagraphSpecResult
        };
    }

    /**
     * @param {!ops.OpRemoveCursor.Spec} removeCursorSpecA
     * @param {!ops.OpRemoveCursor.Spec} removeCursorSpecB
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveCursorRemoveCursor(removeCursorSpecA, removeCursorSpecB) {
        var isSameMemberid = (removeCursorSpecA.memberid === removeCursorSpecB.memberid);

        // if both are removing the same cursor, their transformed counter-ops become noops
        return {
            opSpecsA:  isSameMemberid ? [] : [removeCursorSpecA],
            opSpecsB:  isSameMemberid ? [] : [removeCursorSpecB]
        };
    }

    /**
     * @param {!ops.OpRemoveStyle.Spec} removeStyleSpecA
     * @param {!ops.OpRemoveStyle.Spec} removeStyleSpecB
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveStyleRemoveStyle(removeStyleSpecA, removeStyleSpecB) {
        var isSameStyle = (removeStyleSpecA.styleName === removeStyleSpecB.styleName && removeStyleSpecA.styleFamily === removeStyleSpecB.styleFamily);

        // if both are removing the same style, their transformed counter-ops become noops
        return {
            opSpecsA:  isSameStyle ? [] : [removeStyleSpecA],
            opSpecsB:  isSameStyle ? [] : [removeStyleSpecB]
        };
    }

    /**
     * @param {!ops.OpRemoveStyle.Spec} removeStyleSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveStyleSetParagraphStyle(removeStyleSpec, setParagraphStyleSpec) {
        var helperOpspec,
            removeStyleSpecResult = [removeStyleSpec],
            setParagraphStyleSpecResult = [setParagraphStyleSpec];

        if (removeStyleSpec.styleFamily === "paragraph" && removeStyleSpec.styleName === setParagraphStyleSpec.styleName) {
            // transform removeStyleSpec
            // just create a setstyle op preceding to us which removes any set style from the paragraph
            helperOpspec = {
                optype: "SetParagraphStyle",
                memberid: removeStyleSpec.memberid,
                timestamp: removeStyleSpec.timestamp,
                position: setParagraphStyleSpec.position,
                styleName: ""
            };
            removeStyleSpecResult.unshift(helperOpspec);

            // transform setParagraphStyleSpec
            // instead of setting now remove any existing style from the paragraph
            setParagraphStyleSpec.styleName = "";
        }

        return {
            opSpecsA:  removeStyleSpecResult,
            opSpecsB:  setParagraphStyleSpecResult
        };
    }

    /**
     * @param {!ops.OpRemoveStyle.Spec} removeStyleSpec
     * @param {!ops.OpUpdateParagraphStyle.Spec} updateParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveStyleUpdateParagraphStyle(removeStyleSpec, updateParagraphStyleSpec) {
        var setAttributes, helperOpspec,
            removeStyleSpecResult = [removeStyleSpec],
            updateParagraphStyleSpecResult = [updateParagraphStyleSpec];

        if (removeStyleSpec.styleFamily === "paragraph") {
            // transform removeStyleSpec
            // style brought into use by other op?
            setAttributes = getStyleReferencingAttributes(updateParagraphStyleSpec.setProperties, removeStyleSpec.styleName);
            if (setAttributes.length > 0) {
                // just create a updateparagraph style op preceding to us which removes any set style from the paragraph
                helperOpspec = {
                    optype: "UpdateParagraphStyle",
                    memberid: removeStyleSpec.memberid,
                    timestamp: removeStyleSpec.timestamp,
                    styleName: updateParagraphStyleSpec.styleName,
                    removedProperties: { attributes: setAttributes.join(',') }
                };
                removeStyleSpecResult.unshift(helperOpspec);
            }

            // transform updateParagraphStyleSpec
            // target style to update deleted by removeStyle?
            if (removeStyleSpec.styleName === updateParagraphStyleSpec.styleName) {
                // don't touch the dead
                updateParagraphStyleSpecResult = [];
            } else {
                // otherwise drop any attributes referencing the style deleted
                dropStyleReferencingAttributes(updateParagraphStyleSpec.setProperties, removeStyleSpec.styleName);
            }
        }

        return {
            opSpecsA:  removeStyleSpecResult,
            opSpecsB:  updateParagraphStyleSpecResult
        };
    }

    /**
     * @param {!ops.OpRemoveText.Spec} removeTextSpecA
     * @param {!ops.OpRemoveText.Spec} removeTextSpecB
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveTextRemoveText(removeTextSpecA, removeTextSpecB) {
        var removeTextSpecAEnd = removeTextSpecA.position + removeTextSpecA.length,
            removeTextSpecBEnd = removeTextSpecB.position + removeTextSpecB.length,
            removeTextSpecAResult = [removeTextSpecA],
            removeTextSpecBResult = [removeTextSpecB];

        // B removed positions by object up to As start position?
        if (removeTextSpecBEnd <= removeTextSpecA.position) {
            // adapt A by removed position
            removeTextSpecA.position -= removeTextSpecB.length;
        // A removed positions by object up to Bs start position?
        } else if (removeTextSpecAEnd <= removeTextSpecB.position) {
            // adapt B by removed position
            removeTextSpecB.position -= removeTextSpecA.length;
        // overlapping?
        // (removeTextSpecBEnd <= removeTextSpecA.position above catches non-overlapping from this condition)
        } else if (removeTextSpecB.position < removeTextSpecAEnd) {
            // A removes in front of B?
            if (removeTextSpecA.position < removeTextSpecB.position) {
                // A still to remove range at its end?
                if (removeTextSpecBEnd < removeTextSpecAEnd) {
                    removeTextSpecA.length = removeTextSpecA.length - removeTextSpecB.length;
                } else {
                    removeTextSpecA.length = removeTextSpecB.position - removeTextSpecA.position;
                }
                // B still to remove range at its end?
                if (removeTextSpecAEnd < removeTextSpecBEnd) {
                    removeTextSpecB.position = removeTextSpecA.position;
                    removeTextSpecB.length = removeTextSpecBEnd - removeTextSpecAEnd;
                } else {
                    // B completely overlapped by other, so it becomes a noop
                    removeTextSpecBResult = [];
                }
            // B removes in front of or starting at same like A
            } else {
                // B still to remove range at its end?
                if (removeTextSpecAEnd < removeTextSpecBEnd) {
                    removeTextSpecB.length = removeTextSpecB.length - removeTextSpecA.length;
                } else {
                    // B still to remove range at its start?
                    if (removeTextSpecB.position < removeTextSpecA.position) {
                        removeTextSpecB.length = removeTextSpecA.position - removeTextSpecB.position;
                    } else {
                        // B completely overlapped by other, so it becomes a noop
                        removeTextSpecBResult = [];
                    }
                }
                // A still to remove range at its end?
                if (removeTextSpecBEnd < removeTextSpecAEnd) {
                    removeTextSpecA.position = removeTextSpecB.position;
                    removeTextSpecA.length = removeTextSpecAEnd - removeTextSpecBEnd;
                } else {
                    // A completely overlapped by other, so it becomes a noop
                    removeTextSpecAResult = [];
                }
            }
        }
        return {
            opSpecsA:  removeTextSpecAResult,
            opSpecsB:  removeTextSpecBResult
        };
    }

    /**
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @param {!ops.OpSetParagraphStyle.Spec} setParagraphStyleSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveTextSetParagraphStyle(removeTextSpec, setParagraphStyleSpec) {
        // Removal is done entirely in some preceding paragraph
        if (removeTextSpec.position < setParagraphStyleSpec.position) {
            setParagraphStyleSpec.position -= removeTextSpec.length;
        }

        return {
            opSpecsA:  [removeTextSpec],
            opSpecsB:  [setParagraphStyleSpec]
        };
    }

    /**
     * @param {!ops.OpRemoveText.Spec} removeTextSpec
     * @param {!ops.OpSplitParagraph.Spec} splitParagraphSpec
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function transformRemoveTextSplitParagraph(removeTextSpec, splitParagraphSpec) {
        var removeTextSpecEnd = removeTextSpec.position + removeTextSpec.length,
            helperOpspec,
            removeTextSpecResult = [removeTextSpec],
            splitParagraphSpecResult = [splitParagraphSpec];

        // adapt removeTextSpec
        if (splitParagraphSpec.position <= removeTextSpec.position) {
            removeTextSpec.position += 1;
        } else if (splitParagraphSpec.position < removeTextSpecEnd) {
            // we have to split the removal into two ops, before and after the insertion
            removeTextSpec.length = splitParagraphSpec.position - removeTextSpec.position;
            helperOpspec = {
                optype: "RemoveText",
                memberid: removeTextSpec.memberid,
                timestamp: removeTextSpec.timestamp,
                position: splitParagraphSpec.position + 1,
                length: removeTextSpecEnd - splitParagraphSpec.position
            };
            removeTextSpecResult.unshift(helperOpspec); // helperOp first, so its position is not affected by the real op
        }

        // adapt splitParagraphSpec
        if (removeTextSpec.position + removeTextSpec.length <= splitParagraphSpec.position) {
            splitParagraphSpec.position -= removeTextSpec.length;
        } else if (removeTextSpec.position < splitParagraphSpec.position) {
            splitParagraphSpec.position = removeTextSpec.position;
        }

        if (removeTextSpec.position + removeTextSpec.length < splitParagraphSpec.sourceParagraphPosition) {
            // Removed text is before the source paragraph
            splitParagraphSpec.sourceParagraphPosition -= removeTextSpec.length;
        }
        // removeText ops can't cross over paragraph boundaries, so don't check this case

        return {
            opSpecsA:  removeTextSpecResult,
            opSpecsB:  splitParagraphSpecResult
        };
    }

    /**
     * Does an OT on the two passed opspecs, where they are not modified at all,
     * and so simply returns them in the result arrays.
     * @param {!Object} opSpecA
     * @param {!Object} opSpecB
     * @return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
     */
    function passUnchanged(opSpecA, opSpecB) {
        return {
            opSpecsA:  [opSpecA],
            opSpecsB:  [opSpecB]
        };
    }


    var /**
         * This is the lower-left half of the sparse NxN matrix with all the
         * transformation methods on the possible pairs of ops. As the matrix
         * is symmetric, only that half is used. So the user of this matrix has
         * to ensure the proper order of opspecs on lookup and on calling the
         * picked transformation method.
         *
         * Each transformation method takes the two opspecs (and optionally
         * a flag if the first has a higher priority, in case of tie breaking
         * having to be done). The method returns a record with the two
         * resulting arrays of ops, with key names "opSpecsA" and "opSpecsB".
         * Those arrays could have more than the initial respective opspec
         * inside, in case some additional helper opspecs are needed, or be
         * empty if the opspec turned into a no-op in the transformation.
         * If a transformation is not doable, the method returns "null".
         *
         * Some operations are added onto the stack only by the master session,
         * for example AddMember, RemoveMember, and UpdateMember. These therefore need
         * not be transformed against each other, since the master session is the
         * only originator of these ops. Therefore, their pairing entries in the
         * matrix are missing. They do however require a passUnchanged entry
         * with the other ops.
         *
         * Here the CC signature of each transformation method:
         * param {!Object} opspecA
         * param {!Object} opspecB
         * (param {!boolean} hasAPriorityOverB)  can be left out
         * return {?{opSpecsA:!Array.<!Object>, opSpecsB:!Array.<!Object>}}
         *
         * Empty cells in this matrix mean there is no such transformation
         * possible, and should be handled as if the method returns "null".
         *
         * @type {!Object.<string,!Object.<string,function(!Object,!Object,boolean=):?{opSpecsA:!Array.<!{optype:string}>, opSpecsB:!Array.<!{optype:string}>}>>}
         */
        transformations;
    transformations = {
        "AddAnnotation": {
            "AddAnnotation":        transformAddAnnotationAddAnnotation,
            "AddCursor":            passUnchanged,
            "AddMember":            passUnchanged,
            "AddStyle":             passUnchanged,
            "ApplyDirectStyling":   transformAddAnnotationApplyDirectStyling,
            "InsertText":           transformAddAnnotationInsertText,
            "MergeParagraph":       transformAddAnnotationMergeParagraph,
            "MoveCursor":           transformAddAnnotationMoveCursor,
            "RemoveAnnotation":     transformAddAnnotationRemoveAnnotation,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformAddAnnotationRemoveText,
            "SetParagraphStyle":    transformAddAnnotationSetParagraphStyle,
            "SplitParagraph":       transformAddAnnotationSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "AddCursor": {
            "AddCursor":            passUnchanged,
            "AddMember":            passUnchanged,
            "AddStyle":             passUnchanged,
            "ApplyDirectStyling":   passUnchanged,
            "InsertText":           passUnchanged,
            "MergeParagraph":       passUnchanged,
            "MoveCursor":           passUnchanged,
            "RemoveAnnotation":     passUnchanged,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       passUnchanged,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "AddMember": {
            "AddStyle":             passUnchanged,
            "ApplyDirectStyling":   passUnchanged,
            "InsertText":           passUnchanged,
            "MergeParagraph":       passUnchanged,
            "MoveCursor":           passUnchanged,
            "RemoveAnnotation":     passUnchanged,
            "RemoveCursor":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "AddStyle": {
            "AddStyle":             passUnchanged,
            "ApplyDirectStyling":   passUnchanged,
            "InsertText":           passUnchanged,
            "MergeParagraph":       passUnchanged,
            "MoveCursor":           passUnchanged,
            "RemoveAnnotation":     passUnchanged,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          transformAddStyleRemoveStyle,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       passUnchanged,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "ApplyDirectStyling": {
            "ApplyDirectStyling":   transformApplyDirectStylingApplyDirectStyling,
            "InsertText":           transformApplyDirectStylingInsertText,
            "MergeParagraph":       transformApplyDirectStylingMergeParagraph,
            "MoveCursor":           passUnchanged,
            "RemoveAnnotation":     transformApplyDirectStylingRemoveAnnotation,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformApplyDirectStylingRemoveText,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       transformApplyDirectStylingSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "InsertText": {
            "InsertText":           transformInsertTextInsertText,
            "MergeParagraph":       transformInsertTextMergeParagraph,
            "MoveCursor":           transformInsertTextMoveCursor,
            "RemoveAnnotation":     transformInsertTextRemoveAnnotation,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformInsertTextRemoveText,
            "SetParagraphStyle":    transformInsertTextSetParagraphStyle,
            "SplitParagraph":       transformInsertTextSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "MergeParagraph": {
            "MergeParagraph":       transformMergeParagraphMergeParagraph,
            "MoveCursor":           transformMergeParagraphMoveCursor,
            "RemoveAnnotation":     transformMergeParagraphRemoveAnnotation,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformMergeParagraphRemoveText,
            "SetParagraphStyle":    transformMergeParagraphSetParagraphStyle,
            "SplitParagraph":       transformMergeParagraphSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "MoveCursor": {
            "MoveCursor":           passUnchanged,
            "RemoveAnnotation":     transformMoveCursorRemoveAnnotation,
            "RemoveCursor":         transformMoveCursorRemoveCursor,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformMoveCursorRemoveText,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       transformMoveCursorSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "RemoveAnnotation": {
            "RemoveAnnotation":     transformRemoveAnnotationRemoveAnnotation,
            "RemoveCursor":         passUnchanged,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           transformRemoveAnnotationRemoveText,
            "SetParagraphStyle":    transformRemoveAnnotationSetParagraphStyle,
            "SplitParagraph":       transformRemoveAnnotationSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "RemoveCursor": {
            "RemoveCursor":         transformRemoveCursorRemoveCursor,
            "RemoveMember":         passUnchanged,
            "RemoveStyle":          passUnchanged,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       passUnchanged,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "RemoveMember": {
            "RemoveStyle":          passUnchanged,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    passUnchanged,
            "SplitParagraph":       passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "RemoveStyle": {
            "RemoveStyle":          transformRemoveStyleRemoveStyle,
            "RemoveText":           passUnchanged,
            "SetParagraphStyle":    transformRemoveStyleSetParagraphStyle,
            "SplitParagraph":       passUnchanged,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": transformRemoveStyleUpdateParagraphStyle
        },
        "RemoveText": {
            "RemoveText":           transformRemoveTextRemoveText,
            "SetParagraphStyle":    transformRemoveTextSetParagraphStyle,
            "SplitParagraph":       transformRemoveTextSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "SetParagraphStyle": {
            "SetParagraphStyle":    transformSetParagraphStyleSetParagraphStyle,
            "SplitParagraph":       transformSetParagraphStyleSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "SplitParagraph": {
            "SplitParagraph":       transformSplitParagraphSplitParagraph,
            "UpdateMember":         passUnchanged,
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "UpdateMember": {
            "UpdateMetadata":       passUnchanged,
            "UpdateParagraphStyle": passUnchanged
        },
        "UpdateMetadata": {
            "UpdateMetadata":       transformUpdateMetadataUpdateMetadata,
            "UpdateParagraphStyle": passUnchanged
        },
        "UpdateParagraphStyle": {
            "UpdateParagraphStyle": transformUpdateParagraphStyleUpdateParagraphStyle
        }
    };

    this.passUnchanged = passUnchanged;

    /**
     * @param {!Object.<!string,!Object.<!string,!Function>>}  moreTransformations
     * @return {undefined}
     */
    this.extendTransformations = function (moreTransformations) {
        Object.keys(moreTransformations).forEach(function (optypeA) {
            var moreTransformationsOptypeAMap = moreTransformations[optypeA],
                /**@type{!Object.<string,!Function>}*/
                optypeAMap,
                isExtendingOptypeAMap = transformations.hasOwnProperty(optypeA);

            runtime.log((isExtendingOptypeAMap ? "Extending" : "Adding") + " map for optypeA: " + optypeA);
            if (!isExtendingOptypeAMap) {
                transformations[optypeA] = {};
            }
            optypeAMap = transformations[optypeA];

            Object.keys(moreTransformationsOptypeAMap).forEach(function (optypeB) {
                var isOverwritingOptypeBEntry = optypeAMap.hasOwnProperty(optypeB);
                runtime.assert(optypeA <= optypeB, "Wrong order:" + optypeA + ", " + optypeB);
                runtime.log("  " + (isOverwritingOptypeBEntry ? "Overwriting" : "Adding") + " entry for optypeB: " + optypeB);
                optypeAMap[optypeB] = moreTransformationsOptypeAMap[optypeB];
            });
        });
    };

    /**
     * @param {!{optype:string}} opSpecA op with lower priority in case of tie breaking
     * @param {!{optype:string}} opSpecB op with higher priority in case of tie breaking
     * @return {?{opSpecsA:!Array.<!{optype:string}>,
     *            opSpecsB:!Array.<!{optype:string}>}}
     */
    this.transformOpspecVsOpspec = function (opSpecA, opSpecB) {
        var isOptypeAAlphaNumericSmaller = (opSpecA.optype <= opSpecB.optype),
            helper, transformationFunctionMap, transformationFunction, result;

runtime.log("Crosstransforming:");
runtime.log(runtime.toJson(opSpecA));
runtime.log(runtime.toJson(opSpecB));

        // switch order if needed, to match the mirrored part of the matrix
        if (!isOptypeAAlphaNumericSmaller) {
            helper = opSpecA;
            opSpecA = opSpecB;
            opSpecB = helper;
        }
        // look up transformation method
        transformationFunctionMap = transformations[opSpecA.optype];
        transformationFunction = transformationFunctionMap && transformationFunctionMap[opSpecB.optype];

        // transform
        if (transformationFunction) {
            result = transformationFunction(opSpecA, opSpecB, !isOptypeAAlphaNumericSmaller);
            if (!isOptypeAAlphaNumericSmaller && result !== null) {
                // switch result back
                result = {
                    opSpecsA:  result.opSpecsB,
                    opSpecsB:  result.opSpecsA
                };
            }
        } else {
            result = null;
        }
runtime.log("result:");
if (result) {
runtime.log(runtime.toJson(result.opSpecsA));
runtime.log(runtime.toJson(result.opSpecsB));
} else {
runtime.log("null");
}
        return result;
    };
};