一、需求分析
starUML介绍
StarUML是一种创建UML类图,生成类图和其他类型的统一建模语言(UML)图表的工具
该工具使用nodejs+electron开发。目前官方插件功能支持导出html格式的文件,由首左侧导航菜单和右侧iframe嵌套页面组成,如下图:
目前支持左侧多级菜单、右侧页面展示(包括标题 图片 表格等)
需要在starUML编辑界面中点击 文件 > 导出 > 导出html doc...功能,在指定目录下生成html文件包。
目前需要开发的是在starUML工具菜单中集成一个可以将UML图表导出为WORD格式的文件,尽可能还原原先的功能,包括:
- 左侧多级快捷导航菜单
- 将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格式
所用到的第三方库简介
- cheerio jquery核心功能的一个快速灵活而又简洁的实现,主要是为了用在服务器端需要对DOM进行操作的地方,一般用在nodejs爬虫功能的开发中。
- 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本机图表对象。
- 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全局命令行工具,只需在需要转换的目录下执行一个命令就可以处理好。
准备工作
需要用到的几个插件介绍
- commander node.js命令行界面的完整解决方案,受Ruby Commander启发。
- inquirer 一个用户与命令行交互的工具,比如npm init,脚手架工具生成项目,项目没有用到,开发命令行工具必备小工具。
- colors colors.js 是一个用于 node.js 终端 console.log 的颜色库 美化命令行。
- 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}个文件...`);