ckeditor/ckeditor5-engine

View on GitHub
src/conversion/upcastdispatcher.js

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * @license Copyright (c) 2003-2020, CKSource - Frederico Knabben. All rights reserved.
 * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license
 */

/**
 * @module engine/conversion/upcastdispatcher
 */

import ViewConsumable from './viewconsumable';
import ModelRange from '../model/range';
import ModelPosition from '../model/position';
import { SchemaContext } from '../model/schema';

import CKEditorError from '@ckeditor/ckeditor5-utils/src/ckeditorerror';
import EmitterMixin from '@ckeditor/ckeditor5-utils/src/emittermixin';
import mix from '@ckeditor/ckeditor5-utils/src/mix';

/**
 * `UpcastDispatcher` is a central point of {@link module:engine/view/view view} conversion, which is a process of
 * converting given {@link module:engine/view/documentfragment~DocumentFragment view document fragment} or
 * {@link module:engine/view/element~Element} into another structure.
 * In default application, {@link module:engine/view/view view} is converted to {@link module:engine/model/model}.
 *
 * During conversion process, for all {@link module:engine/view/node~Node view nodes} from the converted view document fragment,
 * `UpcastDispatcher` fires corresponding events. Special callbacks called "converters" should listen to
 * `UpcastDispatcher` for those events.
 *
 * Each callback, as the second argument, is passed a special object `data` that has `viewItem`, `modelCursor` and
 * `modelRange` properties. `viewItem` property contains {@link module:engine/view/node~Node view node} or
 * {@link module:engine/view/documentfragment~DocumentFragment view document fragment}
 * that is converted at the moment and might be handled by the callback. `modelRange` property should be used to save the result
 * of conversion and is always a {@link module:engine/model/range~Range} when conversion result is correct.
 * `modelCursor` property is a {@link module:engine/model/position~Position position} on which conversion result will be inserted
 * and is a context according to {@link module:engine/model/schema~Schema schema} will be checked before the conversion.
 * See also {@link ~UpcastDispatcher#convert}. It is also shared by reference by all callbacks listening to given event.
 *
 * The third parameter passed to a callback is an instance of {@link ~UpcastDispatcher}
 * which provides additional tools for converters.
 *
 * Examples of providing callbacks for `UpcastDispatcher`:
 *
 *        // Converter for links (<a>).
 *        editor.data.upcastDispatcher.on( 'element:a', ( evt, data, conversionApi ) => {
 *            if ( conversionApi.consumable.consume( data.viewItem, { name: true, attributes: [ 'href' ] } ) ) {
 *                // <a> element is inline and is represented by an attribute in the model.
 *                // This is why we need to convert only children.
 *                const { modelRange } = conversionApi.convertChildren( data.viewItem, data.modelCursor );
 *
 *                for ( let item of modelRange.getItems() ) {
 *                    if ( conversionApi.schema.checkAttribute( item, 'linkHref' ) ) {
 *                        conversionApi.writer.setAttribute( 'linkHref', data.viewItem.getAttribute( 'href' ), item );
 *                    }
 *                }
 *            }
 *        } );
 *
 *        // Convert <p>'s font-size style.
 *        // Note: You should use a low-priority observer in order to ensure that
 *        // it's executed after the element-to-element converter.
 *        editor.data.upcastDispatcher.on( 'element:p', ( evt, data, conversionApi ) => {
 *            const { consumable, schema, writer } = conversionApi;
 *
 *            if ( !consumable.consume( data.viewItem, { style: 'font-size' } ) ) {
 *                return;
 *            }
 *
 *            const fontSize = data.viewItem.getStyle( 'font-size' );
 *
 *            // Don't go for the model element after data.modelCursor because it might happen
 *            // that a single view element was converted to multiple model elements. Get all of them.
 *            for ( const item of data.modelRange.getItems( { shallow: true } ) ) {
 *                if ( schema.checkAttribute( item, 'fontSize' ) ) {
 *                    writer.setAttribute( 'fontSize', fontSize, item );
 *                }
 *            }
 *        }, { priority: 'low' } );
 *
 *        // Convert all elements which have no custom converter into paragraph (autoparagraphing).
 *      editor.data.upcastDispatcher.on( 'element', ( evt, data, conversionApi ) => {
 *           // When element is already consumed by higher priority converters then do nothing.
 *           if ( conversionApi.consumable.test( data.viewItem, { name: data.viewItem.name } ) ) {
 *                   const paragraph = conversionApi.writer.createElement( 'paragraph' );
 *
 *                   // Find allowed parent for paragraph that we are going to insert. If current parent does not allow
 *                   // to insert paragraph but one of the ancestors does then split nodes to allowed parent.
 *                   const splitResult = conversionApi.splitToAllowedParent( paragraph, data.modelCursor );
 *
 *                   // When there is no split result it means that we can't insert paragraph in this position.
 *                   if ( splitResult ) {
 *                       // Insert paragraph in allowed position.
 *                       conversionApi.writer.insert( paragraph, splitResult.position );
 *
 *                       // Convert children to paragraph.
 *                       const { modelRange } = conversionApi.convertChildren(
 *                           data.viewItem,
 *                           conversionApi.writer.createPositionAt( paragraph, 0 )
 *                       );
 *
 *                         // Set as conversion result, attribute converters may use this property.
 *                       data.modelRange = conversionApi.writer.createRange(
 *                           conversionApi.writer.createPositionBefore( paragraph ),
 *                           modelRange.end
 *                       );
 *
 *                       // Continue conversion inside paragraph.
 *                       data.modelCursor = data.modelRange.end;
 *                   }
 *               }
 *           }
 *       }, { priority: 'low' } );
 *
 * Before each conversion process, `UpcastDispatcher` fires {@link ~UpcastDispatcher#event:viewCleanup}
 * event which can be used to prepare tree view for conversion.
 *
 * @mixes module:utils/emittermixin~EmitterMixin
 * @fires viewCleanup
 * @fires element
 * @fires text
 * @fires documentFragment
 */
export default class UpcastDispatcher {
    /**
     * Creates a `UpcastDispatcher` that operates using passed API.
     *
     * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi
     * @param {Object} [conversionApi] Additional properties for interface that will be passed to events fired
     * by `UpcastDispatcher`.
     */
    constructor( conversionApi = {} ) {
        /**
         * List of the elements that were created during splitting.
         *
         * After conversion process the list is cleared.
         *
         * @private
         * @type {Map.<module:engine/model/element~Element,Array.<module:engine/model/element~Element>>}
         */
        this._splitParts = new Map();

        /**
         * Position in the temporary structure where the converted content is inserted. The structure reflect the context of
         * the target position where the content will be inserted. This property is build based on the context parameter of the
         * convert method.
         *
         * @private
         * @type {module:engine/model/position~Position|null}
         */
        this._modelCursor = null;

        /**
         * Interface passed by dispatcher to the events callbacks.
         *
         * @member {module:engine/conversion/upcastdispatcher~UpcastConversionApi}
         */
        this.conversionApi = Object.assign( {}, conversionApi );

        // `convertItem`, `convertChildren` and `splitToAllowedParent` are bound to this `UpcastDispatcher`
        // instance and set on `conversionApi`. This way only a part of `UpcastDispatcher` API is exposed.
        this.conversionApi.convertItem = this._convertItem.bind( this );
        this.conversionApi.convertChildren = this._convertChildren.bind( this );
        this.conversionApi.splitToAllowedParent = this._splitToAllowedParent.bind( this );
        this.conversionApi.getSplitParts = this._getSplitParts.bind( this );
    }

    /**
     * Starts the conversion process. The entry point for the conversion.
     *
     * @fires element
     * @fires text
     * @fires documentFragment
     * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element} viewItem
     * Part of the view to be converted.
     * @param {module:engine/model/writer~Writer} writer Instance of model writer.
     * @param {module:engine/model/schema~SchemaContextDefinition} [context=['$root']] Elements will be converted according to this context.
     * @returns {module:engine/model/documentfragment~DocumentFragment} Model data that is a result of the conversion process
     * wrapped in `DocumentFragment`. Converted marker elements will be set as that document fragment's
     * {@link module:engine/model/documentfragment~DocumentFragment#markers static markers map}.
     */
    convert( viewItem, writer, context = [ '$root' ] ) {
        this.fire( 'viewCleanup', viewItem );

        // Create context tree and set position in the top element.
        // Items will be converted according to this position.
        this._modelCursor = createContextTree( context, writer );

        // Store writer in conversion as a conversion API
        // to be sure that conversion process will use the same batch.
        this.conversionApi.writer = writer;

        // Create consumable values list for conversion process.
        this.conversionApi.consumable = ViewConsumable.createFrom( viewItem );

        // Custom data stored by converter for conversion process.
        this.conversionApi.store = {};

        // Do the conversion.
        const { modelRange } = this._convertItem( viewItem, this._modelCursor );

        // Conversion result is always a document fragment so let's create it.
        const documentFragment = writer.createDocumentFragment();

        // When there is a conversion result.
        if ( modelRange ) {
            // Remove all empty elements that were create while splitting.
            this._removeEmptyElements();

            // Move all items that were converted in context tree to the document fragment.
            for ( const item of Array.from( this._modelCursor.parent.getChildren() ) ) {
                writer.append( item, documentFragment );
            }

            // Extract temporary markers elements from model and set as static markers collection.
            documentFragment.markers = extractMarkersFromModelFragment( documentFragment, writer );
        }

        // Clear context position.
        this._modelCursor = null;

        // Clear split elements lists.
        this._splitParts.clear();

        // Clear conversion API.
        this.conversionApi.writer = null;
        this.conversionApi.store = null;

        // Return fragment as conversion result.
        return documentFragment;
    }

    /**
     * @private
     * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertItem
     */
    _convertItem( viewItem, modelCursor ) {
        const data = Object.assign( { viewItem, modelCursor, modelRange: null } );

        if ( viewItem.is( 'element' ) ) {
            this.fire( 'element:' + viewItem.name, data, this.conversionApi );
        } else if ( viewItem.is( 'text' ) ) {
            this.fire( 'text', data, this.conversionApi );
        } else {
            this.fire( 'documentFragment', data, this.conversionApi );
        }

        // Handle incorrect conversion result.
        if ( data.modelRange && !( data.modelRange instanceof ModelRange ) ) {
            /**
             * Incorrect conversion result was dropped.
             *
             * {@link module:engine/model/range~Range Model range} should be a conversion result.
             *
             * @error view-conversion-dispatcher-incorrect-result
             */
            throw new CKEditorError( 'view-conversion-dispatcher-incorrect-result: Incorrect conversion result was dropped.', this );
        }

        return { modelRange: data.modelRange, modelCursor: data.modelCursor };
    }

    /**
     * @private
     * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#convertChildren
     */
    _convertChildren( viewItem, modelCursor ) {
        const modelRange = new ModelRange( modelCursor );
        let nextModelCursor = modelCursor;

        for ( const viewChild of Array.from( viewItem.getChildren() ) ) {
            const result = this._convertItem( viewChild, nextModelCursor );

            if ( result.modelRange instanceof ModelRange ) {
                modelRange.end = result.modelRange.end;
                nextModelCursor = result.modelCursor;
            }
        }

        return { modelRange, modelCursor: nextModelCursor };
    }

    /**
     * @private
     * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#splitToAllowedParent
     */
    _splitToAllowedParent( node, modelCursor ) {
        // Try to find allowed parent.
        const allowedParent = this.conversionApi.schema.findAllowedParent( modelCursor, node );

        // When there is no parent that allows to insert node then return `null`.
        if ( !allowedParent ) {
            return null;
        }

        // When current position parent allows to insert node then return this position.
        if ( allowedParent === modelCursor.parent ) {
            return { position: modelCursor };
        }

        // When allowed parent is in context tree.
        if ( this._modelCursor.parent.getAncestors().includes( allowedParent ) ) {
            return null;
        }

        // Split element to allowed parent.
        const splitResult = this.conversionApi.writer.split( modelCursor, allowedParent );

        // Using the range returned by `model.Writer#split`, we will pair original elements with their split parts.
        //
        // The range returned from the writer spans "over the split" or, precisely saying, from the end of the original element (the one
        // that got split) to the beginning of the other part of that element:
        //
        // <limit><a><b><c>X[]Y</c></b><a></limit> ->
        // <limit><a><b><c>X[</c></b></a><a><b><c>]Y</c></b></a>
        //
        // After the split there cannot be any full node between the positions in `splitRange`. The positions are touching.
        // Also, because of how splitting works, it is easy to notice, that "closing tags" are in the reverse order than "opening tags".
        // Also, since we split all those elements, each of them has to have the other part.
        //
        // With those observations in mind, we will pair the original elements with their split parts by saving "closing tags" and matching
        // them with "opening tags" in the reverse order. For that we can use a stack.
        const stack = [];

        for ( const treeWalkerValue of splitResult.range.getWalker() ) {
            if ( treeWalkerValue.type == 'elementEnd' ) {
                stack.push( treeWalkerValue.item );
            } else {
                // There should not be any text nodes after the element is split, so the only other value is `elementStart`.
                const originalPart = stack.pop();
                const splitPart = treeWalkerValue.item;

                this._registerSplitPair( originalPart, splitPart );
            }
        }

        return {
            position: splitResult.position,
            cursorParent: splitResult.range.end.parent
        };
    }

    /**
     * Registers that `splitPart` element is a split part of the `originalPart` element.
     *
     * Data set by this method is used by {@link #_getSplitParts} and {@link #_removeEmptyElements}.
     *
     * @private
     * @param {module:engine/model/element~Element} originalPart
     * @param {module:engine/model/element~Element} splitPart
     */
    _registerSplitPair( originalPart, splitPart ) {
        if ( !this._splitParts.has( originalPart ) ) {
            this._splitParts.set( originalPart, [ originalPart ] );
        }

        const list = this._splitParts.get( originalPart );

        this._splitParts.set( splitPart, list );
        list.push( splitPart );
    }

    /**
     * @private
     * @see module:engine/conversion/upcastdispatcher~UpcastConversionApi#getSplitParts
     */
    _getSplitParts( element ) {
        let parts;

        if ( !this._splitParts.has( element ) ) {
            parts = [ element ];
        } else {
            parts = this._splitParts.get( element );
        }

        return parts;
    }

    /**
     * Checks if there are any empty elements created while splitting and removes them.
     *
     * This method works recursively to re-check empty elements again after at least one element was removed in the initial call,
     * as some elements might have become empty after other empty elements were removed from them.
     *
     * @private
     */
    _removeEmptyElements() {
        let anyRemoved = false;

        for ( const element of this._splitParts.keys() ) {
            if ( element.isEmpty ) {
                this.conversionApi.writer.remove( element );
                this._splitParts.delete( element );

                anyRemoved = true;
            }
        }

        if ( anyRemoved ) {
            this._removeEmptyElements();
        }
    }

    /**
     * Fired before the first conversion event, at the beginning of upcast (view to model conversion) process.
     *
     * @event viewCleanup
     * @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/element~Element}
     * viewItem Part of the view to be converted.
     */

    /**
     * Fired when {@link module:engine/view/element~Element} is converted.
     *
     * `element` is a namespace event for a class of events. Names of actually called events follow this pattern:
     * `element:<elementName>` where `elementName` is the name of converted element. This way listeners may listen to
     * all elements conversion or to conversion of specific elements.
     *
     * @event element
     * @param {Object} data Conversion data. Keep in mind that this object is shared by reference between all
     * callbacks that will be called. This means that callbacks can override values if needed, and those values will
     * be available in other callbacks.
     * @param {module:engine/view/item~Item} data.viewItem Converted item.
     * @param {module:engine/model/position~Position} data.modelCursor Position where a converter should start changes.
     * Change this value for the next converter to tell where the conversion should continue.
     * @param {module:engine/model/range~Range} data.modelRange The current state of conversion result. Every change to
     * converted element should be reflected by setting or modifying this property.
     * @param {module:engine/conversion/upcastdispatcher~UpcastConversionApi} conversionApi Conversion utilities to be used by callback.
     */

    /**
     * Fired when {@link module:engine/view/text~Text} is converted.
     *
     * @event text
     * @see #event:element
     */

    /**
     * Fired when {@link module:engine/view/documentfragment~DocumentFragment} is converted.
     *
     * @event documentFragment
     * @see #event:element
     */
}

mix( UpcastDispatcher, EmitterMixin );

// Traverses given model item and searches elements which marks marker range. Found element is removed from
// DocumentFragment but path of this element is stored in a Map which is then returned.
//
// @param {module:engine/view/documentfragment~DocumentFragment|module:engine/view/node~Node} modelItem Fragment of model.
// @returns {Map<String, module:engine/model/range~Range>} List of static markers.
function extractMarkersFromModelFragment( modelItem, writer ) {
    const markerElements = new Set();
    const markers = new Map();

    // Create ModelTreeWalker.
    const range = ModelRange._createIn( modelItem ).getItems();

    // Walk through DocumentFragment and collect marker elements.
    for ( const item of range ) {
        // Check if current element is a marker.
        if ( item.name == '$marker' ) {
            markerElements.add( item );
        }
    }

    // Walk through collected marker elements store its path and remove its from the DocumentFragment.
    for ( const markerElement of markerElements ) {
        const markerName = markerElement.getAttribute( 'data-name' );
        const currentPosition = writer.createPositionBefore( markerElement );

        // When marker of given name is not stored it means that we have found the beginning of the range.
        if ( !markers.has( markerName ) ) {
            markers.set( markerName, new ModelRange( currentPosition.clone() ) );
        // Otherwise is means that we have found end of the marker range.
        } else {
            markers.get( markerName ).end = currentPosition.clone();
        }

        // Remove marker element from DocumentFragment.
        writer.remove( markerElement );
    }

    return markers;
}

// Creates model fragment according to given context and returns position in the bottom (the deepest) element.
function createContextTree( contextDefinition, writer ) {
    let position;

    for ( const item of new SchemaContext( contextDefinition ) ) {
        const attributes = {};

        for ( const key of item.getAttributeKeys() ) {
            attributes[ key ] = item.getAttribute( key );
        }

        const current = writer.createElement( item.name, attributes );

        if ( position ) {
            writer.append( current, position );
        }

        position = ModelPosition._createAt( current, 0 );
    }

    return position;
}

/**
 * Conversion interface that is registered for given {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher}
 * and is passed as one of parameters when {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher dispatcher}
 * fires it's events.
 *
 * @interface module:engine/conversion/upcastdispatcher~UpcastConversionApi
 */

/**
 * Starts conversion of given item by firing an appropriate event.
 *
 * Every fired event is passed (as first parameter) an object with `modelRange` property. Every event may set and/or
 * modify that property. When all callbacks are done, the final value of `modelRange` property is returned by this method.
 * The `modelRange` must be {@link module:engine/model/range~Range model range} or `null` (as set by default).
 *
 * @method #convertItem
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment
 * @param {module:engine/view/item~Item} viewItem Item to convert.
 * @param {module:engine/model/position~Position} modelCursor Position of conversion.
 * @returns {Object} result Conversion result.
 * @returns {module:engine/model/range~Range|null} result.modelRange Model range containing result of item conversion,
 * created and modified by callbacks attached to fired event, or `null` if the conversion result was incorrect.
 * @returns {module:engine/model/position~Position} result.modelCursor Position where conversion should be continued.
 */

/**
 * Starts conversion of all children of given item by firing appropriate events for all those children.
 *
 * @method #convertChildren
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:text
 * @fires module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:documentFragment
 * @param {module:engine/view/item~Item} viewItem Element which children should be converted.
 * @param {module:engine/model/position~Position} modelCursor Position of conversion.
 * @returns {Object} result Conversion result.
 * @returns {module:engine/model/range~Range} result.modelRange Model range containing results of conversion of all children of given item.
 * When no children was converted then range is collapsed.
 * @returns {module:engine/model/position~Position} result.modelCursor Position where conversion should be continued.
 */

/**
 * Checks {@link module:engine/model/schema~Schema schema} to find allowed parent for element that we are going to insert
 * starting from given position. If current parent does not allow to insert element but one of the ancestors does then
 * split nodes to allowed parent.
 *
 * If schema allows to insert node in given position, nothing is split and object with that position is returned.
 *
 * If it was not possible to find allowed parent, `null` is returned, nothing is split.
 *
 * Otherwise, ancestors are split and object with position and the copy of the split element is returned.
 *
 * For instance, if `<image>` is not allowed in `<paragraph>` but is allowed in `$root`:
 *
 *        <paragraph>foo[]bar</paragraph>
 *
 *      -> split for `<image>` ->
 *
 *      <paragraph>foo</paragraph>[]<paragraph>bar</paragraph>
 *
 * In the sample above position between `<paragraph>` elements will be returned as `position` and the second `paragraph`
 * as `cursorParent`.
 *
 * @method #splitToAllowedParent
 * @param {module:engine/model/position~Position} position Position on which element is going to be inserted.
 * @param {module:engine/model/node~Node} node Node to insert.
 * @returns {Object|null} Split result. If it was not possible to find allowed position `null` is returned.
 * @returns {module:engine/model/position~Position} position between split elements.
 * @returns {module:engine/model/element~Element} [cursorParent] Element inside which cursor should be placed to
 * continue conversion. When element is not defined it means that there was no split.
 */

/**
 * Returns all the split parts of given `element` that were created during upcasting through using {@link #splitToAllowedParent}.
 * It enables you to easily track those elements and continue processing them after they are split during their children conversion.
 *
 *        <paragraph>Foo<image />bar<image />baz</paragraph> ->
 *        <paragraph>Foo</paragraph><image /><paragraph>bar</paragraph><image /><paragraph>baz</paragraph>
 *
 * For a reference to any of above paragraphs, the function will return all three paragraphs (the original element included),
 * sorted in the order of their creation (the original element is the first one).
 *
 * If given `element` was not split, an array with single element is returned.
 *
 * Example of a usage in a converter code:
 *
 *        const myElement = conversionApi.writer.createElement( 'myElement' );
 *
 *        // Children conversion may split `myElement`.
 *        conversionApi.convertChildren( myElement, modelCursor );
 *
 *        const splitParts = conversionApi.getSplitParts( myElement );
 *        const lastSplitPart = splitParts[ splitParts.length - 1 ];
 *
 *        // Setting `data.modelRange` basing on split parts:
 *        data.modelRange = conversionApi.writer.createRange(
 *            conversionApi.writer.createPositionBefore( myElement ),
 *            conversionApi.writer.createPositionAfter( lastSplitPart )
 *        );
 *
 *        // Setting `data.modelCursor` to continue after the last split element:
 *        data.modelCursor = conversionApi.writer.createPositionAfter( lastSplitPart );
 *
 * **Tip:** if you are unable to get a reference to the original element (for example because the code is split into multiple converters
 * or even classes) but it was already converted, you might want to check first element in `data.modelRange`. This is a common situation
 * if an attribute converter is separated from an element converter.
 *
 * @method #getSplitParts
 * @param {module:engine/model/element~Element} element
 * @returns {Array.<module:engine/model/element~Element>}
 */

/**
 * Stores information about what parts of processed view item are still waiting to be handled. After a piece of view item
 * was converted, appropriate consumable value should be {@link module:engine/conversion/viewconsumable~ViewConsumable#consume consumed}.
 *
 * @member {module:engine/conversion/viewconsumable~ViewConsumable} #consumable
 */

/**
 * Custom data stored by converters for conversion process. Custom properties of this object can be defined and use to
 * pass parameters between converters.
 *
 * The difference between this property and `data` parameter of
 * {@link module:engine/conversion/upcastdispatcher~UpcastDispatcher#event:element} is that `data` parameters allows you
 * to pass parameters within a single event and `store` within the whole conversion.
 *
 * @member {Object} #store
 */

/**
 * The model's schema instance.
 *
 * @member {module:engine/model/schema~Schema} #schema
 */

/**
 * The {@link module:engine/model/writer~Writer} instance used to manipulate data during conversion.
 *
 * @member {module:engine/model/writer~Writer} #writer
 */