PDF文件解析

曾经花了很大的精力做了一个在线的方案制作工具,类似“稿定设计”。当然直接使用已经成熟的工具也可以解决问题但是考虑到后续定制化的需求,以及对于自己定制化资源的整合还是决定自己来实现一套。目前这套系统已经稳定运行了1年多了,产出了很多优质的方案也提升了整个公司的效率。
这套系统在制作过程中遇到了很多的技术难点,其中一个就是对于PDF文件的解析,因为有很多的已经完成的线下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文件拆解

生成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)
        })
    }
}

到这里核心的部分基本就完成了,当然为了让解析出来的结构更清晰一点还需要涉及到文本元素的合并,行文本合并、列文本合并,把结构相同位置相近的文本框合并为一个;形状的合并,比如表格、组合图形;图片的形变,比如旋转、切变、裁剪等。


最终呈现
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容