nodejs开发starUML插件总结

一、需求分析

starUML介绍

StarUML是一种创建UML类图,生成类图和其他类型的统一建模语言(UML)图表的工具

该工具使用nodejs+electron开发。目前官方插件功能支持导出html格式的文件,由首左侧导航菜单和右侧iframe嵌套页面组成,如下图:

starUML导出的html页面

目前支持左侧多级菜单、右侧页面展示(包括标题 图片 表格等)
需要在starUML编辑界面中点击 文件 > 导出 > 导出html doc...功能,在指定目录下生成html文件包。
目前需要开发的是在starUML工具菜单中集成一个可以将UML图表导出为WORD格式的文件,尽可能还原原先的功能,包括:

  1. 左侧多级快捷导航菜单
  2. 将html页面转换为word格式页面,包括大小标题、描述、表格、图片
    目的是为了更直观的展示内容,方便阅读,另外在WORD中可以进行编辑修改的功能。

二、可行性调研

生成word文件需要用到文件流读写,starUML平台使用nodejs混合桌面应用开发,所以选择nodejs来操作生成word,由于自己写word格式文件难度较大,所以需要选择一个第三方工具包处理生成word文件,nodejs生态圈还不是很大,工具较少,最早确定使用officegen工具进行word文件的生成

三、开发starUML插件

资料整理

starUML工具下载以及插件开发文档
http://staruml.io/download 下载地址
https://github.com/staruml/staruml-dev-docs/wiki 文档
基于软件版本2.8.1进行开发 文档中有简单的示例功能 下面简单示例一下步骤

插件开发模式

首先根据操作系统打开对应的目录

  • Mac OS X: ~/Library/Application Support/StarUML/extensions/user
  • Windows: C:\Users\AppData\Roaming\StarUML\extensions\user
  • Linux: ~/.config/StarUML/extensions/user
    在此目录下创建你的插件文件夹

代码编写

进入到新创建的文件夹中,创建main.js文件
写入如下内容

/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4, maxerr: 50, browser: true */
/*global $, define, app, C2S, md5 */
// 项目基于require.js的模块机制 包括以上全局模块可以使用
define(function (require, exports, module) {
    "use strict";
    // 引入一些全局提供的功能模块
    var ExtensionUtils = app.getModule("utils/ExtensionUtils"),
        NodeDomain = app.getModule("utils/NodeDomain"),
        FileUtils = app.getModule("file/FileUtils"),
        FileSystem = app.getModule("filesystem/FileSystem"),
        Async = app.getModule("utils/Async"),
        Repository = app.getModule("core/Repository"),
        ProjectManager = app.getModule("engine/ProjectManager"),
        Commands = app.getModule("command/Commands"),
        CommandManager = app.getModule("command/CommandManager"),
        MenuManager = app.getModule("menu/MenuManager"),
        DiagramManager = app.getModule("diagrams/DiagramManager"),
        Dialogs = app.getModule("dialogs/Dialogs"),
        MetadataJson = app.getModule("metadata-json/MetadataJson");
    // Officegen   = app.getModule("officegen/Officegen");
    // console.log(Officegen);
    // 定义操作菜单路径
    var CMD_FILE_EXPORT_WORD_DOCS = 'file.export.wordDocs';
    // 注册自定义node模块
    var hopeDomain = new NodeDomain("hope", ExtensionUtils.getModulePath(module, "node/HopeDomain"));

    /**
     * 写入自定义文件 html、txtd等
     * @param filename 格式 - 文件路径/文件名.后缀名
     * @param txt 文本内容
     * @returns {RegExpExecArray}
     * @private
     */
    function _writeBinaryFile (filename, txt) {
        console.log(filename);
        console.log(txt);
        return hopeDomain.exec("writeFile", filename, txt);
    }

    // 写入word文件
    function writeDoc () {
        hopeDomain.exec("writeDoc")
            .done(function (res) {
                // 返回执行结果
                console.log('res', res);
            }).fail(function (err) {
            console.error("writeDoc-error", err);
        });
    }

    // 注册软件顶部菜单栏
    CommandManager.register("WORD Docs...", CMD_FILE_EXPORT_WORD_DOCS, writeDoc);

    // 设置菜单 给 file->Export->下面新增一个`WORD Docs...`菜单
    var menuItem = MenuManager.getMenuItem(Commands.FILE_EXPORT);
    menuItem.addMenuDivider();
    menuItem.addMenuItem(CMD_FILE_EXPORT_WORD_DOCS);
});

软件启动时候会自动加载自定义插件目录下的main.js文件
由于上面用到了自定义的nodejs模块 需要在插件目录中再创建一个node目录
目录下新建HopeDomain.js文件


插件目录结构
// HopeDomain.js
/*jslint vars: true, plusplus: true, devel: true, nomen: true, indent: 4,
maxerr: 50, node: true */
/*global */
(function () {
    "use strict";
    // 引入nodejs fs文件操作模块
    var fs = require("fs");
    // 引入html转word插件
    var HtmlDocx = require('html-docx-js');

    // 引入nodejs office文件生成库
    var officegen = require('officegen')
    // html片段
    var html = '

标题1

' /** * 写入文件方法 * @param filename * @param txt * @returns {*} */ function writeFile (filename, txt) { return fs.writeFileSync(filename, txt, { encoding: 'utf8' }); } /** * 写入word文件方法 * @returns {*} */ function writeDoc () { // 使用html片段生成word文件流 并写入到文件中保存 var docx = HtmlDocx.asBlob(html, { orientation: 'landscape', margins: { top: 720 } }); return writeFile('/Users/feifei/Desktop/html-docs/hope-' + new Date().getTime() + '.docx', docx) // officegen生成word文件方法 没有实现成功 // var docx = officegen('docx'); // var header = docx.getHeader().createP({ align: ('center') }); // header.addText('hoperun', { font_size: 8, font_face: 'SimSun' }); // header.addHorizontalLine(); // var pObj1 = docx.createP(); // pObj1.addLineBreak(); // pObj1.addLineBreak(); // pObj1.options.align = 'center'; // pObj1.addText('目录', { // font_size: 20 // }); // var out = fs.createWriteStream('/Users/feifei/Desktop/html-docs/hope-' + new Date().getTime() + '.docx'); // 创建文件 // out.on('error', function (err) { // }); // docx.generate(out, { // 'finalize': function (written) { // if (written === 0) { // console.log('恭喜处理成功!'); // } // }, // 'error': function (err) { // } // }); } // 自定义node模块初始化方法 function init (domainManager) { // 如果没有hope模块则创建自己的hope模块 if (!domainManager.hasDomain("hope")) { domainManager.registerDomain("hope", { major: 0, minor: 1 }); } // 注册nodejs与插件异步通信回调的方法 domainManager.registerCommand( "hope", // domain name "writeFile", // command name writeFile, // command handler function false, // this command is synchronous in Node "Returns the total or free memory on the user's system in bytes", [ { name: "filename", // parameters type: "string", description: "file name" }, { name: "txt", // parameters type: "string", description: "txt data" } ], [ { name: "result", // return values type: "string", description: "result" } ] ); domainManager.registerCommand( "hope", // domain name "writeDoc", // command name writeDoc, // command handler function false, // this command is synchronous in Node "Returns the total or free memory on the user's system in bytes", [], [] ); } exports.init = init; }());

开发插件目前只实现了用html转word文档的插件html-docx-js将html文档转为word文件,无法定制化,word内容根据html格式生成且样式不太美观
之前选择的officegen插件在这里不能使用 可能因为软件nodejs版本较低不支持
最终没有达到理想的效果,放弃了这种方式

最后总结一下在开发插件中遇到的一些问题

  • 工具支持插件开发的dev模式,可以刷新插件,重新加载,但是经常刷新了还是看不到修改代码后的效果,最后发现需要重启软件才有效果,因此走了不少弯路,模式比较麻烦
  • nodejs模块是异步执行回调的 插件中代码可以调试 开发nodejs模块调试比较困难 因此无法看到officegen运行出错的信息
  • 参考一些现有的插件,原生node部分开发难度比较大,还有starUML图表数据解析转化比较复杂

四、开发nodejs html转word工具

由于开发starUML插件不是很理想,所以选择了另外一种方式,在工具自带的转换html格式的文档之后再进行操作,将html文件转换为word文件,不需要处理图表数据,从现成的html文件中取数据

准备工作

  • html文档目录&文件分析
  • 按照文档结构生成word文档目录
  • 图表svg格式图片处理嵌入
  • html页面转换为word格式

所用到的第三方库简介

  1. cheerio jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方,一般用在nodejs爬虫功能的开发中。
  1. officegen 模块可以为Microsoft Office 2007及更高版本生成Office Open XML文件。此模块不依赖于任何框架,您不需要安装Microsoft Office,因此您可以将它用于任何类型的JavaScript应用程序。输出也是流而不是文件,不依赖于任何输出工具。此模块应适用于支持Node.js 0.10或更高版本的任何环境,包括Linux,OSX和Windows。
    此模块生成Excel(.xlsx),PowerPoint(.pptx)和Word(.docx)文档。 Officegen还支持带有嵌入数据的PowerPoint本机图表对象。
  1. svg2png 一个可以将svg文件转为png格式图片的小工具 使用 pn 模块中提供的文件操作功能
    pn全称node-pn,由于早期版本nodejs不支持promise,pn将nodejs常用api的方法全部转化为promise实现,方便进行异步操作。(目前node开发环境支持es6语法,svg2png插件基于这个库实现的)

图片处理

由于部分html页面中包含图片资源,处理模板时候,图片处理流程不好控制,所以单独先将图片文件夹中的svg文件转换为png格式的图片

/**
 * 处理图片文件夹的方法
 * @param dirname 文件夹名称
 * @param url
 * @returns {Promise}
 */
function parseImg (dirname) {
    return new Promise((resolve, reject) => {
        //根据文件路径读取文件,返回文件列表
        fs.readdir(dirname, function (err, files) {
            let svgArr = []; // 图片数组
            if (err) {
                console.log('\n未发现图片目录'.red);
                resolve();
            } else {
                //遍历读取到的文件列表
                files.forEach(function (filename, index) {
                    svgArr.push(filename);
                    let fileUrl = path.join(dirname, filename);
                    if (filename.split('.')[ 1 ] === 'svg') {
                        fsP.readFile(fileUrl)
                            .then(svg2png)
                            .then(buffer => {
                                fsP.writeFile(fileUrl.replace('svg', 'png'), buffer).then(() => {
                                    resolve(svgArr)
                                })
                            })
                            .catch(e => reject(e));
                    }

                });
            }
        });
    })
}

模板处理

首先创建word文件流,在文件流中添加内容,类似富文本编辑器,先获取所有html内容集合,根据标题创建目录和链接,跟下面每个页面对应上,可以从目录点击跳转对应页面,当页word内容填充完毕创建文件流,由officegen插件生成最终的word文件,其中获取所有文件(fileDisplay),目录生成(parseLink),单个html内容解析(parseHtml)使用单独方法处理,下面单独讲解。

/**
 * 开始模板处理流程
 * @param path 当前html文件夹路径
 * @returns {Promise}
 */
async function start (path) {
    // 指定生成文件为word格式
    const docx = officegen('docx');
    // 获取到所有html文件内容的集合
    const htmlMap = await fileDisplay(path + '/contents');
    // 创建页眉
    const header = docx.getHeader().createP({ align: ('center') });
    header.addText('hoperun', { font_size: 8, font_face: 'SimSun' });
    header.addHorizontalLine();
    // 创建目录页面
    let pObj1 = docx.createP();
    pObj1.addLineBreak(); // 换行
    pObj1.addLineBreak();
    pObj1.options.align = 'center';
    pObj1.addText('目录', {
        font_size: 20
    });
    pObj1.addLineBreak();
    pObj1.addLineBreak();
    
    // 创建目录每一行的标题
    htmlMap.map((item, index) => {
        let pObj = docx.createP();
        parseLink(pObj, item, index)
    })
    docx.putPageBreak();
    
    // 处理单个html页面的模板
    htmlMap.map((item, index) => {
        parseHtml(docx, item, index, process.cwd()); // process.cwd() 当前nodejs命令执行的文件夹路径
    })
    const out = fs.createWriteStream(path + '/out.docx'); // 创建文件
    out.on('error', (err) => {
        console.log(err);
    });
    // 生成word文件
    docx.generate(out, {
        // 完成
        'finalize': function (written) {
            if (written === 0) {
                console.log('');
            }
        },
        'error': function (err) {
            console.log(err);
        }
    });
}
1、获取所有html文件内容 - fileDisplay 方法实现

从导航栏html文件中,解析所有的超链接,得到有序的html页面名称,根据页面名称读取对应html文件,将页面内容,文件名,文件类型集合返回。

//文件遍历方法
function fileDisplay (filePath) {
    return new Promise((resolve, reject) => {
        // 菜单文件路径
        let menuUrl = path.join(filePath, 'navigation.html');
        // 读取菜单html内容
        let menu = fs.readFileSync(menuUrl, 'utf-8');
        // 解析html
        const $menu = cheerio.load(menu);
        // 拿到所有的超链接
        const files = $menu('#navigation-tree a');
        if(!files.length) {
            console.log('\n处理失败,未发现html文件,请检查导出目录文件是否齐全');
        }
        let contArr = [];
        //根据文件路径读取文件,返回文件列
        files.each(function (index, item) {
            let filename = $menu(this).attr("href");  // 文件名
            let type = $menu(this).siblings("span").attr('class'); // 文件类型
            //获取当前文件的绝对路径
            let filedir = path.join(filePath, filename);
            // 读取文件内容
            let content = fs.readFileSync(filedir, 'utf-8');
            const $ = cheerio.load(content);
            contArr.push({
                html: $('body').html(),
                title: $menu(this).text(),
                type: type ? type.replace('node-icon ', '') : 'tit'
            });
            resolve(contArr)
        });
    })
}
2、目录生成 - parseLink 方法实现

由于标题没有层级关系,所以加上字符缩进还有符号美化一下,另外给每个标题加上超链接标识,方便跳转至对应页面

// 根据类型添加不同符号、层级缩进空格 美化菜单
const textArr = {
    'tit': ' ★  ',
    '_icon-UMLModel': '  ▸ ',
    '_icon-UMLComponentDiagram': '   ☆  ',
    '_icon-UMLComponent': '    + ',
    '_icon-UMLOperation': '      ● ',
    '_icon-UMLDependency': '    ↑ ',
    '_icon-UMLUseCaseDiagram': '   ☆  ',
    '_icon-UMLUseCase': '    + ',
    '_icon-UMLAttribute': '      ● ',
    '_icon-UMLInteraction': '    + ',
    '_icon-UMLSequenceDiagram': '   ☆  ',
    '_icon-UMLLifeline': '      ● ',
    '_icon-UMLMessage': '      ● ',
    '_icon-UMLAssociation': '    + ',
    '_icon-UMLAssociationEnd': '      ● ',
    '_icon-UMLInclude': '      ● ',
    '_icon-UMLActor': '    + '
}
// 格式化标题内容
function getTitleText (item) {
    return textArr[ item.type ] + item.title
}

/**
 * 解析菜单
 * @param p 文本容器
 * @param item 单个页面对象
 * @param index 索引
 */
function parseLink (p, item, index) {
    p.addText(getTitleText(item), {
        color: item.type === 'tit' || item.type === '_icon-UMLModel' ? '#333' : '#050cff', // 文本颜色
        font_face: 'Arial', // 字体
        font_size: item.type === 'tit' ? 15 : 12, // 字号
        hyperlink: 'hoperun' + index // 锚点跳转标识
    });
}
3、html内容解析 - parseHtml 方法实现

解析html部分比较复杂,要分析html的dom结构,使用jquery选择器取到对应数据,包括标题,描述,表格,图片,锚点的添加。

/**
 * 解析html文件内容
 * @param docx doc对象
 * @param item 页面对象
 * @param index 索引
 * @param doc_url html文档目录
 */
function parseHtml (docx, item, index, doc_url) {
    // 解析页面html内容
    const $ = cheerio.load(item.html);
    // 创建标题
    let pObj = docx.createP();
    // 锚点开始
    pObj.startBookmark('hoperun' + index);
    pObj.options.align = 'center';
    pObj.options.pStyleDef = 'Heading1';
    let title = $('h1').eq(0).text();
    // 处理内容不恰当的标题
    if (title === '(unnamed)' || title === '/') {
        title = $('section').eq(1).find('a').last().text().replace('/:', '');
    }
    pObj.addLineBreak();
    pObj.addText(title, { font_size: 24, font_face: '', bold: true, underline: true });
    pObj.addLineBreak();
    pObj.addLineBreak();
    // 创建描述
    let pObj1 = docx.createP();
    pObj1.options.align = 'left';
    pObj1.addText('Description', { font_size: 24, font_face: '', bold: true, underline: true });
    $('h3').each(function (index, item) {
        if ($(this).text() === 'Description') {
            let pObjd = docx.createListOfDots();
            if ($(this).next().find('li').length > 0) {
                $(this).next().find('li').each(function () {
                    pObjd.addText($(this).contents().filter(function (index, content) {
                        return content.nodeType === 3;
                    }).text());
                })
            } else {
                pObjd.addText('none');
            }
        }
    })
    // 创建图片
    if ($('img').length >= 1) {
        let p = docx.createP();
        p.addText($('img').length === 1 ? 'Diagram' : 'Diagrams', {
            font_size: 24,
            font_face: '',
            bold: true,
            underline: true
        });
        // 替换图片地址
        $('img').each(function (item, index) {
            p.addLineBreak();
            let url = $(this).attr('src').replace('svg', 'png').replace('../', './')
            p.addImage(path.resolve(doc_url, url));
            p.addLineBreak();
        })
    }
    // 创建表格
    if ($('table').length > 0) {
        let table = [];
        let trs = $('table').eq(0).find('tr');
        trs.each(function (i) {
            let tds = [];
            if (i === 0) {
                $(this).children('th').each(function (j) {
                    tds.push({
                        val: $(this).text(),
                        opts: {
                            cellColWidth: 4261,
                            b: true,
                            sz: '48',
                            shd: {
                                fill: "7F7F7F",
                                themeFill: "text1",
                                "themeFillTint": "80"
                            },
                            fontFamily: "Avenir Book"
                        }
                    })
                })
            } else {
                $(this).children('td').each(function (j) {
                    tds.push($(this).text());
                })
            }
            if (tds) {
                table.push(tds);
            }
        })
        var tableStyle = {
            tableColWidth: 4261,
            tableSize: 24,
            tableColor: "ada",
            tableAlign: "left",
            tableFontFamily: "Comic Sans MS",
            borders: true
        }
        if (table[ 0 ].length > 1) {
            let pObj = docx.createP();
            pObj.addLineBreak();
            pObj.addText('Properties', { font_size: 24, font_face: '', bold: true, underline: true });
            pObj.addLineBreak();
            docx.createTable(table, tableStyle);
        }
    }
    // 锚点结束
    pObj.endBookmark();
    // 换页
    docx.putPageBreak();
}

流程整理

将图片处理跟模板解析集成到一起

/**
 * 功能入口函数
 * @param path 当前命令执行的目录(html-docs文件夹目录)
 * @returns {Promise}
 */
async function office (path) {
    // 首先处理图片
    let res = await parseImg(path + '/diagrams');
    // 在图片处理完成之后再处理html模板,影响解析html时候图片资源的嵌入
    setTimeout(() => {
        start(path);
    }, 3000)
}

五、开发nodejs命令行工具

开发过程中是将nodejs代码直接写在html-docs文件夹中的,不方便使用,需要将功能跟html文件分离,所以选择开发一个简单的nodejs全局命令行工具,只需在需要转换的目录下执行一个命令就可以处理好。

准备工作

需要用到的几个插件介绍

  1. commander node.js命令行界面的完整解决方案,受Ruby Commander启发。
  2. inquirer 一个用户与命令行交互的工具,比如npm init,脚手架工具生成项目,项目没有用到,开发命令行工具必备小工具。
  3. colors colors.js 是一个用于 node.js 终端 console.log 的颜色库 美化命令行。
  4. single-line-log nodejs命令行的小工具,可以在同一行输出不用点的内容,可以做文本动态显示,进度条。

目录分析

脚手架工具目录

bin 目录中放可执行命令文件
lib 目录中放插件包 比如html转word的工具就放到这里作为一个插件
index.js文件为入口 只需将lib目录导出

 module.exports=require('./lib')

package.json 文件为项目元数据信息,作为命令行工具需要增加bin字段,如图上hoperun为全局可执行命令的名称,对应的值为bin下面对应的执行文件

bin/office.js

#!/usr/bin/env node
const program = require('commander');
const office = require('../lib/html-doc/office')
const inquirer = require('inquirer');
// 初始化配置选择项
const initQuestions = [ {
    type: 'list',
    name: 'plattype',
    message: '请选择平台类型?',
    choices: [
        'pass',
        'sass',
        'iaas'
    ]
},
    {
        type: 'list',
        name: 'vmCounts',
        message: '请选择您包含的虚拟机数量?',
        choices: [ '100', '200', '500', '1000' ]
    }
];
// 登录命令输入项
const loginQuestions = [ {
    type: 'input',
    name: 'username',
    message: '请输入用户名',
},
    {
        type: 'password',
        name: 'password',
        message: '请输入用户密码'
    }
];

// 定义版本和参数选项
program
    .version('v' + require('../package.json').version, '-v, --version')
    .description('nodejs 命令行工具')
    .option('-s, --star', 'starUMl生成word文档功能')
    .option('-g, --generate', '生成xxx')
    .option('-l, --login', '登录');

// 必须在.parse()之前,因为node的emit()是即时的
program.on('--help', function () {
    console.log('  Examples:');
    console.log('');
    console.log('    this is an example');
    console.log('');
});

program.parse(process.argv);
// 如果输入的命令是star话执行office方法,将当前命令执行的目录地址传入工具进行处理
if (program.star) {
    office(process.cwd())
}

if (program.generate) {
    inquirer.prompt(initQuestions).then(result => {
        console.log("您选择的平台类型信息如下:");
        console.log(JSON.stringify(result));
    })
    console.log('generate something')
}

if (program.login) {
    inquirer.prompt(loginQuestions).then(result => {
        console.log("您登陆的账户信息如下:");
        console.log(JSON.stringify(result));
    })
}
// if (program.args.length === 0) {
//     program.help()
// }

六、项目优化

由于项目没有用户界面,只能在命令行操作,等待处理图片,解析html,生成word时候添加一些log日志,提示信息的比较好,所以选择了一些命令行优化工具,比如同行打印,文本颜色。

在office转换工具中,增加了一些错误处理,逻辑判断,保证在工具运行前,运行中都能将交互内容进行输出,让工具使用中更加人性化。

简单几个示例

目录检测

   let checkDir = fs.existsSync(path + '/contents');
    let checkNav = fs.existsSync(path + '/contents/navigation.html')
    if (!checkDir || !checkNav) {
        console.log((`\n当前目录${path}\n请在starUML生成的html目录下执行该命令`).red);
        return
    }

处理用时

// 生成word文件
    docx.generate(out, {
        // 完成
        'finalize': function (written) {
            if (written === 0) {
                console.log('');
                console.log('');
                console.log('-----------------------------------------');
                console.log('恭喜处理成功!');
                console.log(('用时:' + ((new Date().getTime() - start_time) / 1000).toString() + 's').green);
                console.log('-----------------------------------------');
                console.log('');
                console.log('');
            }
        },
        'error': function (err) {
            console.log(err);
        }
    });

同行打印,颜色提示

console.log('\n开始生成word文件......'.green);

slog(`正在处理第${index + 1}个文件...`);

你可能感兴趣的:(nodejs开发starUML插件总结)