import { Node as ProsemirrorNode , Schema , Mark } from 'prosemirror-model' ; import { IParagraphOptions , IRunOptions , Paragraph , TextRun , ExternalHyperlink , ParagraphChild , MathRun , Math , TabStopType , TabStopPosition , SequentialIdentifier , Bookmark , ImageRun , AlignmentType , Table , TableRow , TableCell , ITableCellOptions , InternalHyperlink , SimpleField , FootnoteReferenceRun , } from 'docx' ; import sizeOf from 'buffer-image-size' ; import { createNumbering , NumberingStyles } from './numbering' ; import { createDocFromState , createShortId } from './utils' ; import { IFootnotes , INumbering , Mutable } from './types' ; export type AlignOptions = 'left' | 'center' | 'right' ; export type NodeSerializer < S extends Schema = any > = Record < string , ( state : DocxSerializerState < S > , node : ProsemirrorNode < S > , parent : ProsemirrorNode < S > , index : number ) => void > ; export type MarkSerializer < S extends Schema = any > = Record < string , ( state : DocxSerializerState < S > , node : ProsemirrorNode < S > , mark : Mark < S > ) => IRunOptions > ; export type Options = { getImageBuffer : ( src : string ) => Buffer ; } ; export type IMathOpts = { inline ? : boolean ; id ? : string | null ; numbered ? : boolean ; } ; const MAX_IMAGE_WIDTH = 600 ; function createReferenceBookmark ( id , kind , before ? , after ? ) { const textBefore = before ? [ new TextRun ( before ) ] : [ ] ; const textAfter = after ? [ new TextRun ( after ) ] : [ ] ; return new Bookmark ( { id , children : [ ... textBefore , new SequentialIdentifier ( kind ) , ... textAfter ] , } ) ; } export class DocxSerializerState < S extends Schema = any > { nodes : NodeSerializer < S > ; options : Options ; marks : MarkSerializer < S > ; children : ( Paragraph | Table ) [ ] ; numbering : INumbering [ ] ; footnotes : IFootnotes = { } ; nextRunOpts ? : IRunOptions ; current : ParagraphChild [ ] = [ ] ; currentLink ? : { link : string ; children : IRunOptions [ ] ; } ; nextParentParagraphOpts ? : IParagraphOptions ; currentNumbering ? : { reference : string ; level : number ; } ; constructor ( nodes , marks , options ) { this . nodes = nodes ; this . marks = marks ; this . options = options ?? { } ; this . children = [ ] ; this . numbering = [ ] ; } renderContent ( parent , opts ? ) { parent . forEach ( ( node , _ , i ) => { if ( opts ) this . addParagraphOptions ( opts ) ; this . render ( node , parent , i ) ; } ) ; } render ( node , parent , index ) { if ( typeof parent === 'number' ) throw new Error ( '!' ) ; if ( ! this . nodes [ node . type . name ] ) throw new Error ( `Token type \`${ node . type . name }\` not supported by Word renderer` ) ; this . nodes [ node . type . name ] ( this , node , parent , index ) ; } renderMarks ( node , marks ) { return marks . map ( ( mark ) => { return this . marks [ mark . type . name ] ?. ( this , node , mark ) ; } ) . reduce ( ( a , b ) => ( { ... a , ... b } ) , { } ) ; } renderInline ( parent ) { let currentLink ; const closeLink = ( ) => { if ( ! currentLink ) return ; const hyperlink = new ExternalHyperlink ( { link : currentLink . link , children : this . current , } ) ; this . current = [ ... currentLink . stack , hyperlink ] ; currentLink = undefined ; } ; const openLink = ( href ) => { const sameLink = href === currentLink ?. link ; this . addRunOptions ( { style : 'Hyperlink' } ) ; const oneLink = true ; if ( ! oneLink ) { closeLink ( ) ; } else { if ( currentLink && sameLink ) return ; if ( currentLink && ! sameLink ) { closeLink ( ) ; } } currentLink = { link : href , stack : this . current , } ; this . current = [ ] ; } ; const progress = ( node , offset , index ) => { const links = node . marks . filter ( ( m ) => m . type . name === 'link' ) ; const hasLink = links . length > 0 ; if ( hasLink ) { openLink ( links [ 0 ] . attrs . href ) ; } else if ( ! hasLink && currentLink ) { closeLink ( ) ; } if ( node . isText ) { this . text ( node . text , this . renderMarks ( node , node . marks ) ) ; } else { this . render ( node , parent , index ) ; } } ; parent . forEach ( progress ) ; closeLink ( ) ; } renderList ( node , style ) { if ( ! this . currentNumbering ) { const nextId = createShortId ( ) ; this . numbering . push ( createNumbering ( nextId , style ) ) ; this . currentNumbering = { reference : nextId , level : 0 } ; } else { const { reference , level } = this . currentNumbering ; this . currentNumbering = { reference , level : level + 1 } ; } this . renderContent ( node ) ; if ( this . currentNumbering . level === 0 ) { delete this . currentNumbering ; } else { const { reference , level } = this . currentNumbering ; this . currentNumbering = { reference , level : level - 1 } ; } } renderListItem ( node ) { if ( ! this . currentNumbering ) throw new Error ( 'Trying to create a list item without a list?' ) ; this . addParagraphOptions ( { numbering : this . currentNumbering } ) ; this . renderContent ( node ) ; } addParagraphOptions ( opts ) { this . nextParentParagraphOpts = { ... this . nextParentParagraphOpts , ... opts } ; } addRunOptions ( opts ) { this . nextRunOpts = { ... this . nextRunOpts , ... opts } ; } text ( text , opts ? ) { if ( ! text ) return ; this . current . push ( new TextRun ( { text , ... this . nextRunOpts , ... opts } ) ) ; delete this . nextRunOpts ; } math ( latex , opts = { inline : true } ) { if ( opts . inline || ! opts . numbered ) { this . current . push ( new Math ( { children : [ new MathRun ( latex ) ] } ) ) ; return ; } const id = opts . id ?? createShortId ( ) ; this . current = [ new TextRun ( '\t' ) , new Math ( { children : [ new MathRun ( latex ) ] , } ) , new TextRun ( '\t(' ) , createReferenceBookmark ( id , 'Equation' ) , new TextRun ( ')' ) , ] ; this . addParagraphOptions ( { tabStops : [ { type : TabStopType . CENTER , position : TabStopPosition . MAX / 2 , } , { type : TabStopType . RIGHT , position : TabStopPosition . MAX , } , ] , } ) ; } maxImageWidth = MAX_IMAGE_WIDTH ; image ( src , widthPercent = 70 , align = 'center' ) { const buffer = this . options . getImageBuffer ( src ) ; const dimensions = sizeOf ( buffer ) ; const aspect = dimensions . height / dimensions . width ; const width = this . maxImageWidth * ( widthPercent / 100 ) ; this . current . push ( new ImageRun ( { data : buffer , transformation : { width , height : width * aspect , } , } ) ) ; let alignment ; switch ( align ) { case 'right' : alignment = AlignmentType . RIGHT ; break ; case 'left' : alignment = AlignmentType . LEFT ; break ; default : alignment = AlignmentType . CENTER ; } this . addParagraphOptions ( { alignment , } ) ; } table ( node ) { const actualChildren = this . children ; const rows = [ ] ; node . content . forEach ( ( { content : rowContent } ) => { const cells = [ ] ; let tableHeader = true ; rowContent . forEach ( ( cell ) => { if ( cell . type . name !== 'table_header' ) { tableHeader = false ; } } ) ; this . maxImageWidth = MAX_IMAGE_WIDTH / rowContent . childCount ; rowContent . forEach ( ( cell ) => { this . children = [ ] ; this . renderContent ( cell ) ; const tableCellOpts = { children : this . children } ; const colspan = cell . attrs . colspan ?? 1 ; const rowspan = cell . attrs . rowspan ?? 1 ; if ( colspan > 1 ) tableCellOpts . columnSpan = colspan ; if ( rowspan > 1 ) tableCellOpts . rowSpan = rowspan ; cells . push ( new TableCell ( tableCellOpts ) ) ; } ) ; rows . push ( new TableRow ( { children : cells , tableHeader } ) ) ; } ) ; this . maxImageWidth = MAX_IMAGE_WIDTH ; const table = new Table ( { rows } ) ; actualChildren . push ( table ) ; actualChildren . push ( new Paragraph ( '' ) ) ; this . children = actualChildren ; } captionLabel ( id , kind ) { this . current . push ( ... [ createReferenceBookmark ( id , kind , `${ kind } ` ) , new TextRun ( ': ' ) ] ) ; } $footnoteCounter = 0 ; footnote ( node ) { const { current , nextRunOpts } = this ; this . current = [ ] ; delete this . nextRunOpts ; this . $footnoteCounter += 1 ; this . renderInline ( node ) ; this . footnotes [ this . $footnoteCounter ] = { children : [ new Paragraph ( { children : this . current } ) ] , } ; this . current = current ; this . nextRunOpts = nextRunOpts ; this . current . push ( new FootnoteReferenceRun ( this . $footnoteCounter ) ) ; } closeBlock ( node , props ? ) { const paragraph = new Paragraph ( { children : this . current , ... this . nextParentParagraphOpts , ... props , } ) ; this . current = [ ] ; delete this . nextParentParagraphOpts ; this . children . push ( paragraph ) ; } createReference ( id , before ? , after ? ) { const children = [ ] ; if ( before ) children . push ( new TextRun ( before ) ) ; children . push ( new SimpleField ( `REF ${ id } \\h` ) ) ; if ( after ) children . push ( new TextRun ( after ) ) ; const ref = new InternalHyperlink ( { anchor : id , children } ) ; this . current . push ( ref ) ; } } export class DocxSerializer < S extends Schema = any > { nodes : NodeSerializer < S > ; marks : MarkSerializer < S > ; constructor ( nodes , marks ) { this . nodes = nodes ; this . marks = marks ; } serialize ( content , options ) { const state = new DocxSerializerState < S > ( this . nodes , this . marks , options ) ; state . renderContent ( content ) ; const doc = createDocFromState ( state ) ; return doc ; } }