前端JavaScript【导出PDF】 之 表格如何自动换页

465 阅读3分钟

前言

项目中需要实现超出A4高度自动新增一页,并且每页都需增加页头页脚的导出pdf功能。 但在网上找的插件大多不能满足该需求,有些写法又很复杂繁琐,也有可能一些好的组件没有找到,因此干脆就自己写了一个。
该组件只用于开发过程中所涉及的功能,因此需求兼容性暂未考虑,仅做个工作小结。当中的错误与不足希望各位大佬指点... -QAQ-

  • 项目使用react+antd

先上效果图

1.1.png 2.png 3.png


01.根据A4规格初始化pdf模块样式

因为我们最终需要打印在A4纸上,因此需要根据其规格来定义dom样式。
据查A4宽高为592.28/841.89,在这我们设pdf宽为960px,那么高度为960 / (592.28 / 841.89)px;

export default props=>{
    ...
    let pdfHeight = 960 / (592.28 / 841.89); // pdf高度;
    ...
    
    return <div className="container">
        <Button type="primary">导出pdf</Button>
        <div id="pdfCon"></div>
    </div>
}

02.定义渲染函数 - createPdf

封装创建函数,用于后续新增页时调用;其中需记录每页实际内容元素高度,用于自动换页判断条件;

 /**
   * 创建pdf页 
   * @param {*} inx 页数索引,初始:0
   */
  const createPdf = (inx)=>{
    const _i = inx || 0;
    
    pdfHeights[_i] = 0; // 初始化高度
    setPdfHeights(pdfHeights)
    
    const parent = document.getElementById('pdfCon'); // 顶级父元素
    const pdfDom = document.createElement('div'); // 新增子元素
    pdfDom.className = 'pdf modal';
    pdfDom.style = `height: ${pdfHeight}px`; // 将pdf高度撑开
    
    parent.appendChild(pdfDom);

    createHeader(pdfDom, _i); // 创建页眉
    
    createFooter(pdfDom, _i); // 创建页脚
    
    // 03.pdf表格内部渲染处理
    ...  
  }
  ...
  
  useEffect(()=>{
    createPdf(0); // 初始创建一页
  },[])

02.1 createHeader函数 (根据需求进行自定义)


 /**
   * 创建页眉
   * @param {*} pdfDom 当前pdf元素
   * @param {*} i       页数索引
   */
  const createHeader = (pdfDom, i)=>{    
    // 添加元素 ----------start
    const h = document.createElement('div');
    h.className = 'header';
    ...
    h.appendChild(...);
    ...

    pdfDom.appendChild(h);
    
    // 添加元素 ----------end
    
     pdfHeights[i] += h.offsetHeight;
    setPdfHeights(pdfHeights); // 记录高度
  }

02.2 createFooter函数

 /**
   * 创建页脚
   * @param {*} pdfDom 当前pdf元素
   * @param {*} i       页数索引
   */
  const createFooter = (pdfDom, i)=>{
    // 添加元素 ----------start
    const f = document.createElement('div');
    f.className = 'footer';
    f.innerHTML = `<span>第${i+1}页,共${i+1}页</span>`;
    pdfDom.appendChild(f);
    
    // 添加元素 ----------end

    pdfHeights[i] += f.offsetHeight;
    setPdfHeights(pdfHeights);  // 记录高度
  }

此时执行导出pdf后效果如下图(导出函数在03.3列出)

1660729507481.png

接下该处理重点的表格了!!!~(●'◡'●)`

03.动态生成表格并自动换页

实现步骤如下:

    1. 定义通用cofing
    1. 根据不同布局定义渲染函数

03.1 根据项目需求设计通用配置变量tableConfig

1660725114339.png

由上图可以看到,目前项目遇到的需求中大模块布局分为5个;

  1. 两列左右文案布局:需考虑value超高;

  2. 两列左右列表布局:由于左右高度不相等,故需考虑左/右列表内标题或内容超高;

  3. 行内布局: 该布局有几种布局;
    3.1. 整行:行超高;
    3.2. 左右:左/右超高;
    3.3. 内嵌表格:行超高;

    1660725482296.png

  4. 四列左右布局: 行超高;

我们根据以上布局规定以下变量

布局类型
   type:
        'lr':     布局1
        'flex':   布局2
        'con':    布局3
        'rLR':    布局4
        
布局3中dom类型
   type:
       '1':     单行文字
       '2':     左右文字
       '3':     嵌套表格
        
   具体如何定义,根据需求或者个人理解...~

综上所述,大致设一个config

let tableConfig = [
  {
    type: 'lr',
    title: 'label',
    value: 'value',
  },
  {
    type: 'rLR',
    list:[
      { title: 'label', value: 'value' },
      { title: 'label', value: 'value' },
    ]
  },
  {
    type: 'flex',
    list: [
      {
        title: '我是大标题',
        list: [{title: '(一)我是标题我是标题:', value: 'value'}]
      },
      {
        title: '我是大标题',
        list: [{title: '(一)我是标题我是标题:', value: 'value'}]
      }
    ]
  },
  {
    type: 'con',
    list:[
      {
        type: '1',
        title: 'label',
        value: 'value'
      },
      {
        type: '2',
        title: 'label',
        value: 'value'
      },
      {
        type: '3',
        title: 'label',
        list: []
      },
    ]
  }
]

03.2 布局渲染函数

  • table渲染函数 - createTable

  /**
   * 创建table
   * @param {*} pdfDom // 当前操作pdf
   * @param {*} i       // 当前pdf索引
   * @param {*} rInx    // 当前渲染tableConfig索引
   */
  const createTable = (pdfDom, i, rInx)=>{
    const t = document.createElement('div');
    t.className = 'table';
    pdfDom.appendChild(t);
    createDom(pdfDom, t, i, rInx || 0); // table内部dom渲染函数
  };
  • table内部dom元素渲染函数 - createDom
  /**
   * 创建table内部dom元素
   * @param {*} pdfDom   // 当前操作pdf
   * @param {*} t        // 当前table
   * @param {*} i        // 当前pdf索引
   * @param {*} rInx     // 当前渲染tableConfig索引
   * @returns 
   */
  const createDom = async (pdfDom, t, i, rInx)=>{
  
    const config = configs[rInx];
    const {type, list} = config;

    if(type == 'lr') {
      createLR({ pdfDom, t, i, rInx, config });
    } else if(type == 'rLR'){
      createRlr({ pdfDom, t, i, rInx })
    } else if(type == 'flex'){
      list.map((v, index)=>{
        createFlexDom(pdfDom, t, d1, v, i, index, rInx)
      })
    } else{
      const con = document.createElement('div');
      con.className = 'tCon';
      for (let index = 0; index < list?.length; index++) {
        const v = list[index];
        if(!v.hide){
          const res = await createConDom(pdfDom, t, con, v, i, index, rInx );
          if(res == 1){
            createPdf(i+1, rInx);
            break;
          }
        }
        if(index == list.length -1){
          createDom(pdfDom, t, i, rInx+1)
        }
      }
    }
  }
  • type == 'lr' 渲染函数 —— createLR
/**
   * 渲染'lr'布局dom元素
   * @param {*} pdfDom    当前pdf元素
   * @param {*} t         当前table
   * @param {*} i         当前pdf索引
   * @param {*} rInx      当前渲染tableConfig索引
   * @param {*} config    当前配置对象
   * @returns 
   */
  const createLR = async ({pdfDom, t, i, rInx, config})=>{
    // 添加元素 ----------start
    const th = document.createElement('div');
    th.className = 'tH';

    const l = document.createElement('div');
    l.className = 'tD tL';
    l.innerHTML = config.title;

    const r = document.createElement('div');
    r.className = 'tD';
    r.innerHTML = config.render ? config.render(data) : config.key && config.title ? data[config.key] : config.value;

    th.appendChild(l)
    th.appendChild(r)
    t.appendChild(th)
    
    // 添加元素 ----------end

    const height = pdfHeights[i] + th.offsetHeight;

    // 超出pdf最大高度
    if(height > pdfHeight){
      if(config.row == 1){
        t.removeChild(th);
        return createPdf(i+1, rInx)
      }
      const res = await setMaxText({dom:r, i, rInx, layoutType: 'lr'}); // 处理文字跨页
      return res ? createPdf(i+1, res) : null;
    }
    
    pdfHeights[i] = height;
    setPdfHeights(pdfHeights)
    
    createDom(pdfDom, t, i, rInx+1); // 继续渲染下一个dom
  }

  • type == 'rLR'渲染函数 —— createRlr
/**
   * 渲染'rLR'布局dom元素
   * @param {*} pdfDom    当前pdf元素
   * @param {*} t         当前table
   * @param {*} i         当前pdf索引
   * @param {*} rInx      当前渲染tableConfig索引
   * @returns 
   */
  const createRlr = ({pdfDom, t, i, rInx})=>{
    // 添加元素 ----------start
    const th = document.createElement('div');
    th.className = 'tH';
    
    const rLR = document.createElement('div');
    rLR.className = 'rLR';
    list.map(v=>{
      const th = document.createElement('div');
      th.className = 'tH';
      const l = document.createElement('div');
      l.className = 'tD tL';
      l.innerHTML = v.title;
      const r = document.createElement('div');
      r.className = 'tD';
      r.innerHTML = v.key ? data[v.key] : v.value;

      th.appendChild(l);
      th.appendChild(r);
      rLR.appendChild(th);
    })
    t.appendChild(rLR);

    pdfHeights[i] += rLR.offsetHeight;
    setPdfHeights(pdfHeights)
    
    // 添加元素 ----------end
    createDom(pdfDom, t, i, rInx+1)
  }
  • type == 'flex'渲染函数 —— createFlexDom
/**
   * 
   * @param {*} pdfDom   当前pdfDom
   * @param {*} t        当前table
   * @param {*} th       当前th元素
   * @param {*} item     当前config
   * @param {*} i        当前pdf索引
   * @param {*} fInx     flex索引
   * @param {*} rInx    当前渲染tableConfig索引
   * @returns 
   */
  const createFlexDom = async(pdfDom, t, th, item, i, fInx, rInx)=>{
    let nowTableH = pdfHeights[i]; // 当前pdf高度

    // 添加元素 ----------start
    const d1 = document.createElement('div');
    d1.className = 'con';
    const d2 = document.createElement('div');
    d2.className = 'tRowFlex';
    const d3 = document.createElement('div');
    d3.className = 'flexT';
    const d4 = document.createElement('div');
    d4.className = 'flexH';

    // 判断是否显示大标题
    if(!item?.hideT){
      d3.innerHTML = item.title;
      d2.appendChild(d3);
    }
    // 判断是否显示flex布局的子标题
    if(!item?.hideFlexT){
      d4.innerHTML = item.msg;
      d2.appendChild(d4);
    }

    d1.appendChild(d2);
    th.appendChild(d1);
    t.appendChild(th);
    
    // 添加元素 ----------end

    const dH1 = nowTableH + d3.offsetHeight;
    const dH2 = nowTableH + d4.offsetHeight;
    if(dH1 > pdfHeight){
      // 大标题超高
      d3.remove();
      d4.remove();
      createPdf(i+1, rInx); // 创建新页
      return;
    }else if(dH2 > pdfHeight){
      // 子标题超高
      d4.remove();
      item.hideT = true; // 设为true,即已渲染大标题,下次不渲染
      createPdf(i+1, rInx);
      return;
    }

    item.hideT = item.hideFlexT = true;
    nowTableH += d2.offsetHeight;
    
    // 大小标题都已渲染且不超高,以下绘制文案
    createFlexCText({pdfDom, t, d2, item, i, fInx, rInx, nowTableH})
  }
  • createFlexCText函数
const createFlexCText =async ({pdfDom, t,  d2, item, i, fInx, rInx, nowTableH})=>{
    for (let cInx = 0; cInx < item.list.length; cInx++) {
      const cItem = item.list[cInx];
      // 添加元素 ----------start
      if(!cItem.hide) {
        const d5 = document.createElement('div');
        d5.className = 'itemH';
        const title = cItem.title;
        !cItem.hideT && (d5.innerHTML = title);

        const d6 = document.createElement('div');
        d6.innerHTML =  cItem.value || data.safetyMeasures[cInx].value || '无';

        d2.appendChild(d5);
        d2.appendChild(d6);

        // 添加元素 ----------end

        const height1 = nowTableH + d5.offsetHeight;
        const height2 = nowTableH + d6.offsetHeight;
        if(height1 > pdfHeight){
          const res = await setMaxText({type:1, nowTableH, dom: d5, i, rInx, fInx, cInx})
          res == 2 && d5.remove();
          d6.remove();
          // // 创建新table
          if(fInx == configs[rInx].list.length -1){
            createPdf(i+1, rInx);
          }
          return;
        }else if(height2 > pdfHeight){
          cItem.hideT = true;
          nowTableH += d5.offsetHeight;
          
          await setMaxText({type:2, nowTableH, dom: d6, i, rInx, fInx, cInx})
          if(fInx == configs[rInx].list.length -1){
            createPdf(i+1, rInx);
          }
          return;
        }else{
          cItem.hide = true;
          nowTableH += d5.offsetHeight;
          nowTableH += d6.offsetHeight;
          
          if(fInx == configs[rInx].list.length -1 && cInx == item.list.length -1){
            pdfHeights[i] = nowTableH;
            setPdfHeights([...pdfHeights])
            createDom(pdfDom, t, i, rInx+1)
          }
        }
      }

    }
  }
  • type == 'con'渲染函数 —— createConDom
/**
   * 
   * @param {*} t         当前table
   * @param {*} con       当前操作con 元素
   * @param {*} item      当前config
   * @param {*} i         当前pdf索引
   * @param {*} fInx      flex索引
   * @param {*} rInx      当前渲染tableConfig索引
   * @returns 
   */
  const createConDom = async (t, con, item, i, fInx, rInx)=>{
    return new Promise(async (resolve, reject)=>{
      configs[rInx].list.map((sItem, sInx)=>{
        sInx < fInx && (sItem.hide = true)
      })
      const d1 = document.createElement('div');
      d1.className = 'trow';
      
      //  con布局中的dom类型判断
      if(item.type == '1'){
        item.list.map(v=>{
          const s = document.createElement('span');
          s.innerHTML = `${v.title}${v.value}`;
          d1.appendChild(s);
        })
        con.appendChild(d1);
        t.appendChild(con);

        const height = pdfHeights[i] + d1.offsetHeight;
        if(height > pdfHeight){
          d1.remove()
          resolve(1)
          return;
        }else{
          pdfHeights[i] = height;
          setPdfHeights([...pdfHeights])
          resolve()
        }

      }else if(item.type == '2'){
        const l = document.createElement('div');
        l.className = 'trow_l';
        const r = document.createElement('div');
        r.className = 'trow_r';
        l.innerHTML = item.title;
        r.innerHTML = item.value;
        d1.appendChild(l);
        d1.appendChild(r);
        con.appendChild(d1);
        t.appendChild(con);

        const height1 = pdfHeights[i] + l.offsetHeight;
        const height2 = pdfHeights[i] + r.offsetHeight;
        
        // 标题是否超高
        if(height1 > pdfHeight){
          let params = {
            nowTableH: pdfHeights[i],
            i,
            rInx,
            fInx,
            layoutType: 'con'
          }
          // 标题及文案处理
          height2 > pdfHeight && await setMaxText({ type: 2, dom:r, ...params });
          await setMaxText({ type: 1, dom:l, ...params });

          return resolve(1);
        }else{
          pdfHeights[i] += d1.offsetHeight;
          setPdfHeights(pdfHeights);
          resolve()
        }

      }else{
        const l = document.createElement('div');
        l.className = 'trow_l';

        if(!item.hideT){
          l.innerHTML = item.title;
        }

        d1.appendChild(l);
        con.appendChild(d1);
        t.appendChild(con);

        const height = pdfHeights[i] + l.offsetHeight;
        if(height > pdfHeight){
          l.remove()
          return resolve(1)
        }
        item.hideT = true;
        pdfHeights[i] = height;
        setPdfHeights(pdfHeights)

        // 处理内嵌table
        const tm = document.createElement('div');
        tm.className = 'table miniT';
        const h1 = document.createElement('div');
        h1.className = 'h';
        // 是否渲染表头
        if(!item.hideTH){
          item.list.map(v=>{
            const hD = document.createElement('div');
            hD.innerHTML = v.title;
            h1.appendChild(hD);
            tm.appendChild(h1);
          })
        }
        const h1H = height + h1.offsetHeight;
        console.log(h1H, 'h1H');
        // table头超高了
        if(h1H > pdfHeight){
          console.log('table头超高了');
          h1.remove();
          return resolve(1);
        }

        // 重复渲染子th
        for (let tdI = (item.startTd || 0); tdI < 3; tdI++) {
          console.log('渲染td');
          const h2 = document.createElement('div');
          h2.className = 'h';
          item.list.map(v=>{
            const tD = document.createElement('div');
            tD.innerHTML = v.value || '';
            h2.appendChild(tD);
          })
          tm.appendChild(h2);
          con.appendChild(tm);
          t.appendChild(con);

          const totalH = pdfHeights[i] + tm.offsetHeight;
          if(totalH > pdfHeight){
            console.log('table- item超高'+tdI);
            h2.remove();
            item.hideT = true;  // 是否隐藏标题
            item.hideTH = true; // 是否隐藏表头
            item.startTd = tdI; // 记录下一个渲染索引

            pdfHeights[i] = pdfHeight;
            setPdfHeights(pdfHeights);
            return resolve(1);
          }
          if(tdI == 2){
            pdfHeights[i] = totalH;
            setPdfHeights(pdfHeights);
            resolve();
          }
            
        }
      }
    })
  }
  • setMaxText函数
/**
   
  /**
   * 
   * @param {*} type            1:标题  2: 值
   * @param {*} nowTableH       当前table高度
   * @param {*} dom             操作dom
   * @param {*} i               当前pdf索引
   * @param {*} rInx            当前渲染tableConfig索引
   * @param {*} fInx            flex索引
   * @param {*} cInx            当前config索引
   * @param {*} layoutType      布局类型
   * @returns 
   */
   const setMaxText = async ({type, nowTableH, dom, i, rInx, fInx, cInx, layoutType})=>{
    return new Promise((resolve, reject)=>{
      const str = dom.innerText;
      for (let j = 1; j < str.length; j++) {
        let _txt = str.substring(0,j);
        dom.innerText = _txt;
        const amount = nowTableH + dom.offsetHeight;

        if(amount > pdfHeight){
          let text = str.substring(0,j-1);
          dom.innerText = text;

          let _sitem = {}
          if(layoutType == 'lr'){
            _sitem = {
              title: '',
              type: layoutType,
              value: str.substring(j-1).replace(/\n/g, '<br/>')
            }
          }else if(layoutType == 'con'){
            _sitem = configs[rInx].list[fInx];
          }else{
            nowTableH += dom.offsetHeight;
            _sitem = configs[rInx].list[fInx].list[cInx];
          }
          
          if(layoutType != 'lr'){
            type == 2 && (_sitem.hideT = true);
            _sitem[type == 2 ? 'value' : 'title'] = str.substring(j-1);
          }
          
          setConfigs({...configs})
          resolve(1)
          break;
        }

        if(j == str.length  - 1) {
          pdfHeights[i] = pdfHeight;
          setPdfHeights([...pdfHeights])
          resolve()
        }
      }
    })
  }

03.3 导出

cdn引入jsPDF库

<script src="https://cdn.bootcss.com/jspdf/1.3.4/jspdf.debug.js"></script>

安装html2canvas插件

yarn add html2canvas

导出函数 —— onSavePdf

  const onSavePdf = async ()=>{
    var targetList = document.getElementsByClassName("pdf");

    console.log(window);
    var pdf = new window.jsPDF('', 'pt', 'a4');
    for (let i = 0; i < targetList.length; i++) {
      let target = targetList[i]
      target.style.background = "#FFFFFF";

      await setImg(target, pdf)
    }

    pdf.save("导出的.pdf");
  }
  
  // 根据dom生成图片
  const setImg = (target, pdf)=>{
    return new Promise((resolve, reject)=>{
      html2canvas(target, {})
        .then(canvas=>{
          const contentWidth = canvas.width;
          const contentHeight = canvas.height;

          //a4纸的尺寸[595.28,841.89],html页面生成的canvas在pdf中图片的宽高
          const imgWidth = 595.28;
          const imgHeight = 592.28 / contentWidth * contentHeight;
          const pageData = canvas.toDataURL('image/jpeg', 1.0);

          pdf.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight);
          pdf.addPage();
          resolve()
        })
    })
  }

如开头所说,这些仅满足当前项目需求,还有很多情况没有考虑;比如所有布局的标题超高、'rLR'布局值超高等等...