曾经花了很大的精力做了一个在线的方案制作工具,类似“稿定设计”。当然直接使用已经成熟的工具也可以解决问题但是考虑到后续定制化的需求,以及对于自己定制化资源的整合还是决定自己来实现一套。目前这套系统已经稳定运行了1年多了,产出了很多优质的方案也提升了整个公司的效率。
这套系统在制作过程中遇到了很多的技术难点,其中一个就是对于PDF文件的解析,因为有很多的已经完成的线下PDF方案,为了能把这些方案导入系统就会涉及到对于PDF文件的解析和结构转换。思路大致如此:
读取PDF文件,解析文件结构,解析每页数据,提取每页文件中的组件,并把组件结构转换为自己系统可用结构,生成页面,并添加新组建生成方案。
这里面有两个技术点需要解决:
1、PDF文件结构解析
对于PDF文件的结构,有一篇文章PDF文件解析与PDF恶代分析中的一些坑说的很清楚。如果按照这个思路走,当然也可以,但是单独就解析这块就可以做一个庞大的系统了,另寻他法。考虑到系统是基于nodejs搭建的,找到两个可以使用的方案:
- pdf2json
可以提取文件中的文本信息,图形和图形提取不出来,依赖于nodejs环境 - pdfjs
可以提取所有信息,依赖于浏览器环境
看起来pdfjs更合适一点,就是文档资源少一点,看起来有点费劲。
研究下来发现pdfjs有3点可以利用
1、page.getTextContent,提前每页中的文本信息
2、PDFJS.SVGGraphics,页面渲染为SVG
3、page.render,通过canvas渲染为图片
如果把页面直接渲染为图片是最简单办法,当然转化之后所有的组件和文字都不能单独编辑了,目前看来唯一可行的就是通过pdfjs吧PDF文件每页解析为svg,然后再把svg文件拆分,提起所有可用组件,文字部分通过getTextContent提取,独立解析。思路如下:
生成SVG文件
let document = await PDFJS.getDocument(new Uint8Array(await sourceFile.arrayBuffer()));
let page = await document.getPage(0);
var viewport = page.getViewport({ scale: 1 });
let scale = Math.min(viewBox.width / viewport.width, viewBox.height / viewport.height);
let opList = await page.getOperatorList();
var svgGfx = new PDFJS.SVGGraphics(page.commonObjs, page.objs);
let svg = null;
try {
svg = await svgGfx.getSVG(opList, page.getViewport({ scale: scale }));
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
} catch (error) {
svg = this.createTag('svg');
svg.setAttribute('viewbox', "0 0 "+ viewBox.width +" " + viewBox.height);
}
提取页面文本信息
let textContent = await page.getTextContent({});
let texts = textContent.items.map(text => {
let fontFamily = textContent.styles[text.fontName].fontFamily;
text.fontFamily = fontFamily;
if(fontFamily.toLowerCase().indexOf('bold') != -1) {
text.bold = true;
}else {
text.bold = false;
}
return text;
})
拆解SVG页面元素为平行结构
async makeNodesOfSVG(svg, svgNodes) {
let tags = new Set(['tspan', 'circle', 'ellipse', 'image', 'line', 'mesh', 'path', 'polygon', 'polyline', 'rect', 'use']);
let withoutTags = new Set(['clipPath', 'defs', 'hatch', 'linearGradient', 'marker', 'mask', 'meshgradient', 'metadata', 'pattern', 'radialGradient', 'script', 'style', 'symbol', 'title']);
for (let i = 0; i < svg.childNodes.length; i++) {
const node = svg.childNodes[i];
let tagName = node.tagName || '';
tagName = tagName.replace('svg:', '');
if (withoutTags.has(tagName)) {
continue;
}
if (tags.has(tagName)) {
let fill = (node.attributes['fill'] || {})['nodeValue'];
if (fill == 'none') {
continue;
}
if(tagName == 'tspan' && !fill) {
continue;
}
let nodes = [node.cloneNode(true)];
while (node.parentNode) {
if (node.parentNode.tagName == 'svg') {
break;
}
nodes.splice(0, 0, node.parentNode.cloneNode(false))
node = node.parentNode;
}
for (let i = 0; i < nodes.length - 1; i++) {
const node = nodes[i];
node.appendChild(nodes[i + 1]);
}
svgNodes.push(nodes[0]);
} else {
await this.makeNodesOfSVG(node, svgNodes);
}
}
}
获取最内层需要渲染的元素
把最内层元素拆解为独立元素
之前的操作,所有需要渲染的元素外层都包裹着几层结构,这几层结构都是元素的transform,我们需要把这几层结构合并为一个transform,并把元素独立出来。
把拆解的元素渲染到网页。
红色框标记的位置是我们真正需要提取的元素。
// 提取需要的元素
getNodeOfSVG(svg) {
let tags = new Set(['tspan', 'circle', 'ellipse', 'image', 'line', 'mesh', 'path', 'polygon', 'polyline', 'rect', 'use']);
let noTags = new Set(['clipPath', 'defs', 'hatch', 'linearGradient', 'marker', 'mask', 'meshgradient', 'metadata', 'pattern', 'radialGradient', 'script', 'style', 'symbol', 'title']);
let tagName = svg.tagName || '';
tagName = tagName.replace('svg:', '');
if (tags.has(tagName)) {
return svg;
}
for (let i = 0; i < svg.childNodes.length; i++) {
const node = svg.childNodes[i];
tagName = node.tagName || '';
tagName = tagName.replace('svg:', '');
if (noTags.has(tagName)) {
continue;
}
if (tags.has(tagName)) {
return node;
} else {
return this.getNodeOfSVG(node);
}
}
}
// 获取元素的transform
for (let j = 0; j < nodes.length; j++) {
const node = nodes[j];
let bound = node.getBoundingClientRect();
// 换算转换矩阵
let point = svg.createSVGPoint();
point.x = bound.x;
point.y = bound.y;
let inode = pptgen.getItemOfSVG(node);
let transform = inode.getCTM();
let rotate = pptgen.decomposeMatrix(transform).rotateZ;
transform = (new DOMMatrix([1, 0, 0, 1, -bound.x, -bound.y])).multiply(transform);
let cnode = inode.cloneNode(true);
page.items.push({
node: cnode,
bound: bound,
transform: transform,
rotate: rotate
});
// 位置标注
let markDiv = document.createElement('div');
markDiv.style.position = 'absolute';
markDiv.style.left = bound.x + 'px';
markDiv.style.top = bound.y + 'px';
markDiv.style.width = bound.width + 'px';
markDiv.style.height = bound.height + 'px';
markDiv.style.border = '1px solid #ff0000';
svgContent.appendChild(markDiv);
}
到这里我们已经提取到我们需要的基本元素,接下来就是把这些元素转换成需要的结构化数据。
2、组件结构转换
文本框
if (onode.tagName == 'tspan') {
let fontSize = page.scale * page.texts[txtIndex].transform[0];
let fontFamily = page.texts[txtIndex].fontFamily;
let bold = page.texts[txtIndex].bold;
let color = '';
if (onode.attributes['fill']) {
let rgb = onode.attributes['fill']['nodeValue'];
if(onode.attributes['stroke-width']) {
bold = true;
}
color = this._rgb2hex(rgb);
}
let text = '';
let str = page.texts[txtIndex].str;
text = text + str;
txtIndex++;
svgs.push({
type: 'text',
text: text,
x: bound.x,
y: bound.y,
w: bound.width,
h: bound.height,
bold: bold,
fontSize: fontSize,
fontFamily: fontFamily,
color: color
})
}
图片
if (onode.tagName == 'image') {
let url = onode.attributes['xlink:href']['nodeValue'];
let blob = await this._url2blob(url);
let ext = blob.type == 'image/png' ? 'png' : 'jpg';
var file = new File([blob], "image." + ext, { type: blob.type });
let hash = md5(new Uint8Array(await file.arrayBuffer()))
if (imageCache[hash]) {
// 缓存
url = imageCache[hash];
} else {
// 存储
let uploadParams = await this._tokenInfo(ext);
url = await this._upload(file, uploadParams.key, uploadParams.token);
imageCache[hash] = url;
}
svgs.push({
type: 'image',
x: bound.x,
y: bound.y,
w: bound.width,
h: bound.height,
url: url
})
}
其他形状元素
else {
let svgNode = this.createTag('svg', { 'width': bound.width + 'px', 'height': bound.height + 'px', 'viewbox': '0 0 ' + bound.width + ' ' + bound.height });
let matrixItems = [item.transform.a, item.transform.b, item.transform.c, item.transform.d, item.transform.e, item.transform.f];
item.node.setAttribute('transform', 'matrix(' + matrixItems.join(' ') + ')');
svgNode.appendChild(item.node);
let svgString = svgNode.outerHTML;
if(svgString.length > 2000) {
// 超规格文件
let blob = new Blob([svgString]);
let ext = 'svg';
var file = new File([blob], "image." + ext, { type: 'image/svg+xml' });
let url = '';
let hash = md5(new Uint8Array(await file.arrayBuffer()))
if (imageCache[hash]) {
// 缓存
url = imageCache[hash];
} else {
// 存储
let uploadParams = await this._tokenInfo(ext);
url = await this._upload(file, uploadParams.key, uploadParams.token);
imageCache[hash] = url;
}
svgs.push({
type: 'svg',
x: bound.x,
y: bound.y,
w: bound.width,
h: bound.height,
url: url
})
}else {
svgs.push({
type: 'svg',
x: bound.x,
y: bound.y,
w: bound.width,
h: bound.height,
url: "data:image/svg+xml;base64," + base64Encode(svgString)
})
}
}
到这里核心的部分基本就完成了,当然为了让解析出来的结构更清晰一点还需要涉及到文本元素的合并,行文本合并、列文本合并,把结构相同位置相近的文本框合并为一个;形状的合并,比如表格、组合图形;图片的形变,比如旋转、切变、裁剪等。