【30 - 打印】前端实现网页pdf打印的几种方式-bysking

58 阅读4分钟

核心是利用浏览器的print能力

方案一 通过指定dom节点打印

const printSpecificElement = (elementId: string) => {
  const element = document.getElementById(elementId);
  if (!element) return;

  // 创建打印窗口
  const printWindow = window.open('', '_blank');
  if (!printWindow) {
    console.error('无法打开打印窗口');
    return;
  }

  // 获取要打印的元素的HTML内容
  const printContent = element.innerHTML;

  // 获取当前页面的样式
  const styles = [...document.styleSheets]
    .map((styleSheet) => {
      try {
        return [...(styleSheet.cssRules || [])]
          .map((rule) => rule.cssText)
          .join('');
      } catch {
        // 跨域样式表无法访问,跳过
        return '';
      }
    })
    .join('');

  // 写入打印窗口内容
  printWindow.document.write(`
    <!DOCTYPE html>
    <html>
      <head>
        <title>打印</title>
        <style>${styles}</style>
        <style>
          @media print {
            body {
              margin: 0;
              padding: 20px;
              background-color: #f8f8f8;
            }
          }
        </style>
      </head>
      <body>
        ${printContent}
      </body>
    </html>
  `);

  printWindow.document.close();

  // 等待内容加载完成后打印
  printWindow.addEventListener('load', () => {
    printWindow.focus();
    printWindow.print();
    printWindow.close();
  });
};

方案二 通过渲染vue组件打印

  • report-cover.vue
<script setup lang="ts">
import { onMounted } from 'vue';

defineOptions({ name: 'CodeCard', inheritAttrs: false });

defineProps<{
  meetingId: number;
  reportId: number;
}>();

const initData = async () => {
  setTimeout(() => {
  
  // 合适的时机调用,比如接口返回数据后, 通知打印准备完毕
    window.top?.postMessage('print-done');
  }, 200);
};

onMounted(() => {
  initData();
});
</script>

<template>
  <div>test-content</div>
</template>

<style scoped lang="scss">
@media print {
  .page-break {
    page-break-after: always;
  }

  html,
  body {
    height: auto !important;
    overflow: auto !important;
  }

  .report-cover-wrap {
    min-height: 100vh !important;
    background: #fff;
    background-color: #fff;
  }
}
</style>


  • 打印逻辑
/** 打印会议报告  */
const handlePrint = async () => {
  let vueInstance: App<Element> | null = null;
  const myDocument = document;

  const PromiseProxy: Record<string, any> = {
    promise: Promise.resolve(),
    resolve: null,
    reject: null,
  };

  PromiseProxy.promise = new Promise((resolve, reject) => {
    PromiseProxy.resolve = resolve;
    PromiseProxy.reject = reject;
  });

  function setPrint(iframe: HTMLIFrameElement) {
    // 获取iframe的document对象
    const iframeDocument =
      iframe.contentDocument || iframe.contentWindow?.document;

    const contentWindow = iframe.contentWindow!;

    copyStylesToIframe(myDocument, iframe);

    const rootDiv = iframeDocument?.createElement('div') as HTMLDivElement;

    vueInstance = createApp({
      render: () =>
        h(ReportCover, {
          meetingId,
          reportId: reportId.value!,
        }),
    });
    vueInstance.mount(rootDiv);
    iframeDocument?.body.append(rootDiv);

    const listener = (event: any) => {
      if (event.data === eventPrintType) {
        // 执行某些操作,例如关闭预览或更新UI
        contentWindow.print();
        PromiseProxy.resolve();
      }
    };

    const closePrint = () => {
      window.removeEventListener('message', listener);
      iframe.remove();
      vueInstance?.unmount();
    };

    contentWindow.addEventListener('afterprint', closePrint);
    // eslint-disable-next-line unicorn/prefer-add-event-listener
    contentWindow.onbeforeunload = closePrint;
    window.addEventListener('message', listener);
  }

  const hideFrame = document.createElement('iframe');
  hideFrame.addEventListener('load', () => setPrint(hideFrame));
  hideFrame.style.display = 'none'; // 隐藏 iframe
  hideFrame.style.position = 'fixed';
  hideFrame.style.overflow = 'hidden';
  hideFrame.style.zIndex = '9999';
  document.body.append(hideFrame);

  return PromiseProxy.promise;
};

方案三 snapdom 或者其他 三方npm库,自行探索

www.npmjs.com/package/@zu… 可以方便实现图片打印下载等

  • snapdom + jspdf (图片式打印,无法二次编辑)
import { snapdom } from '@zumer/snapdom';
import jsPDF from 'jspdf';

export const downloadPDF = async (
  el: HTMLElement,
  fileName = 'report',
  resolveFn?: (data: any) => void,
) => {
  const Img = await snapdom.toImg(el, {
    noShadows: true,
  });

  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');

  // 分别设置四个方向的边距
  const margins = {
    top: 20,
    right: 20,
    bottom: 20,
    left: 20,
  };

  // 获取设备像素比
  const pixelRatio = window.devicePixelRatio || 1;

  // 按像素比调整canvas尺寸,并加上边距
  canvas.width = (Img.width + margins.left + margins.right) * pixelRatio;
  canvas.height = (Img.height + margins.top + margins.bottom) * pixelRatio;

  // 缩放canvas上下文以适应高分辨率
  if (ctx) {
    ctx.scale(pixelRatio, pixelRatio);
  }

  // 设置canvas CSS尺寸保持原始大小(包含边距)
  canvas.style.width = `${Img.width + margins.left + margins.right}px`;
  canvas.style.height = `${Img.height + margins.top + margins.bottom}px`;

  if (ctx) {
    // 先绘制背景色
    ctx.fillStyle = '#f8f8f8';
    ctx.fillRect(
      0,
      0,
      Img.width + margins.left + margins.right,
      Img.height + margins.top + margins.bottom,
    );
  }

  // 绘制图片时设置边距
  ctx?.drawImage(Img, margins.left, margins.top, Img.width, Img.height);

  const contentWidth = canvas.width;
  const contentHeight = canvas.height;

  // 一页pdf显示html页面生成的canvas高度;
  const pageHeight = (contentWidth / 592.28) * 841.89;
  // 未生成pdf的html页面高度
  let leftHeight = contentHeight;
  // 页面偏移
  let position = 0;
  // 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);

  const img = document.createElement('img');
  img.src = pageData;
  document.body.append(img);

  // eslint-disable-next-line new-cap
  const pdf = new jsPDF({
    orientation: 'p',
    unit: 'pt',
    format: 'a4',
  });

  // 有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
  // 当内容未超过pdf一页显示的范围,无需分页
  if (leftHeight < pageHeight) {
    pdf.addImage({
      imageData: pageData,
      format: 'JPEG',
      x: 0,
      y: 0,
      width: imgWidth,
      height: imgHeight,
    });
  } else {
    while (leftHeight > 0) {
      pdf.addImage({
        imageData: pageData,
        format: 'JPEG',
        x: 0,
        y: position,
        width: imgWidth,
        height: imgHeight,
      });

      leftHeight -= pageHeight;
      position = position - 841.89;
      // 避免添加空白页
      if (leftHeight > 0) {
        pdf.addPage();
        // pdf.setFillColor(255, 255, 255); // 白色背景
        // pdf.rect(0, 0, 595.28, 842.89, 'F'); // 绘制背景矩形
      }
    }
  }

  pdf.save(`${fileName}.pdf`);
  resolveFn?.({});
};


  • html2canvas + jsPdf

import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';

const optimizedHighQualityPrint = async () => {
  const el = document.querySelector('#bysking');
  const canvas = await html2canvas(el, {
    logging: false,
    useCORS: true,
    scale: 2, // 3倍分辨率
    // allowTaint: true,
    // foreignObjectRendering: true,
    // imageTimeout: 0,
    // removeContainer: true,
    // backgroundColor: '#ffffff',
  });
  const contentWidth = canvas.width;
  const contentHeight = canvas.height;

  // 一页pdf显示html页面生成的canvas高度;
  const pageHeight = (contentWidth / 592.28) * 841.89;
  // 未生成pdf的html页面高度
  let leftHeight = contentHeight;
  // 页面偏移
  let position = 0;
  // 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);

  // eslint-disable-next-line new-cap
  const pdf = new jsPDF({
    orientation: 'p',
    unit: 'pt',
    format: 'a4',
  });

  // 有两个高度需要区分,一个是html页面的实际高度,和生成pdf的页面高度(841.89)
  // 当内容未超过pdf一页显示的范围,无需分页
  if (leftHeight < pageHeight) {
    pdf.addImage(pageData, 'JPEG', 0, 0, imgWidth, imgHeight);
  } else {
    while (leftHeight > 0) {
      pdf.addImage(pageData, 'JPEG', 0, position, imgWidth, imgHeight);
      leftHeight -= pageHeight;
      position = position - 841.89;
      // 避免添加空白页
      if (leftHeight > 0) {
        pdf.addPage();
      }
    }
  }

  pdf.save('content.pdf');
};

方案四 nodejs+puppeteer 实现后端打印

  • 后端核心代码
const puppeteer = require('puppeteer');
const fs = require('fs').promises;

(async () => {
  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  // 读取HTML文件内容得到html字符串
  let htmlContent = await fs.readFile('./test.html', 'utf8');
  // 设置处理后的内容到页面
  await page.setContent(htmlContent, { waitUntil: 'domcontentloaded' });
  await page.pdf({ path: 'example.pdf', format: 'A4', preferCSSPageSize: true, printBackground: true, margin: { top: '10px', right: '10px', bottom: '10px', left: '10px' } });
  await browser.close();
  console.log('PDF generated successfully with base64 images.');
})();

  • 前端核心代码
// 将图片URL转换为base64
const convertImagesToBase64 = async (htmlString: string): Promise<string> => {
  // 创建一个临时的DOM元素来解析HTML
  const tempDiv = document.createElement('div');
  tempDiv.innerHTML = htmlString;

  // 查找所有img元素
  const images = tempDiv.querySelectorAll('img');

  // 创建所有图片转换的Promise数组
  const promises = [...images].map(async (img) => {
    const src = img.src;

    // 如果已经是base64或者没有src,则跳过
    if (!src || src.startsWith('data:')) {
      return;
    }

    try {
      // 将图片URL转换为base64
      const base64 = await urlToBase64(src);
      img.src = base64;
    } catch (error) {
      console.warn(`转换图片失败: ${src}`, error);
      // 转换失败时保留原始src
    }
  });

  // 等待所有图片转换完成
  await Promise.all(promises);

  // 返回转换后的HTML字符串
  return tempDiv.innerHTML;
};

// 将URL转换为base64
const urlToBase64 = (url: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    // 创建一个新的图片对象
    const img = new Image();

    // 处理跨域问题
    img.crossOrigin = 'anonymous';

    img.addEventListener('load', () => {
      try {
        // 创建canvas
        const canvas = document.createElement('canvas');
        const ctx = canvas.getContext('2d');

        if (!ctx) {
          reject(new Error('无法获取canvas上下文'));
          return;
        }

        // 设置canvas尺寸
        canvas.width = img.width;
        canvas.height = img.height;

        // 绘制图片到canvas
        ctx.drawImage(img, 0, 0);

        // 获取图片的MIME类型
        const mimeType = getMimeTypeFromUrl(url);

        // 转换为base64
        const dataURL = canvas.toDataURL(mimeType);
        resolve(dataURL);
      } catch (error) {
        reject(error);
      }
    });

    img.onerror = (error) => {
      reject(new Error(`图片加载失败: ${url}`));
    };

    // 开始加载图片
    img.src = url;
  });
};

// 根据URL获取MIME类型
const getMimeTypeFromUrl = (url: string): string => {
  // 从URL中提取文件扩展名
  const match = url.match(/\.([^.?#]+)(?:[?#]|$)/);
  const extension = match ? match[1].toLowerCase() : '';

  const mimeTypes: Record<string, string> = {
    png: 'image/png',
    jpg: 'image/jpeg',
    jpeg: 'image/jpeg',
    gif: 'image/gif',
    webp: 'image/webp',
    svg: 'image/svg+xml',
    bmp: 'image/bmp',
  };

  return mimeTypes[extension] || 'image/png';
};

// 移除包含指定类名的元素(完整版本)
const removeIgnoreElement = (htmlString: string, className: string): string => {
  try {
    // 创建一个临时的DOM元素来解析HTML
    const tempDiv = document.createElement('div');
    tempDiv.innerHTML = htmlString;

    // 使用多种方式查找包含指定类名的元素
    // 1. 直接匹配类名
    let ignoreElements = tempDiv.querySelectorAll(`.${className}`);

    // 2. 处理可能有多个类名的情况
    const allElements = tempDiv.querySelectorAll('*');
    allElements.forEach((el) => {
      if (
        el.classList &&
        el.classList.contains(className) && // 避免重复添加到ignoreElements中
        ![...ignoreElements].includes(el)
      ) {
        ignoreElements = [
          ...ignoreElements,
          el,
        ] as unknown as NodeListOf<Element>;
      }
    });

    // 从后往前移除元素,避免在遍历时影响DOM结构
    const elementsArray = [...ignoreElements];
    for (let i = elementsArray.length - 1; i >= 0; i--) {
      elementsArray[i].remove();
    }

    // 返回处理后的HTML字符串
    return tempDiv.innerHTML;
  } catch (error) {
    console.error('移除忽略元素时出错:', error);
    // 出错时返回原始HTML字符串
    return htmlString;
  }
};

const printSpecificElement = async (elementId: string) => {
  const element = document.getElementById(elementId);
  if (!element) return;

  // 获取要打印的元素的HTML内容
  let printContent = element.innerHTML;
  // 获取当前页面的样式
  const styles = [...document.styleSheets]
    .map((styleSheet) => {
      try {
        return [...(styleSheet.cssRules || [])]
          .map((rule) => rule.cssText)
          .join('');
      } catch {
        // 跨域样式表无法访问,跳过
        return '';
      }
    })
    .join('');

  // 转换所有图片链接为base64
  printContent = await convertImagesToBase64(printContent);
  printContent = removeIgnoreElement(printContent, 'print-ignore');

  return `
    <!DOCTYPE html>
    <html>
      <head>
        <title>打印</title>
        <style>
          body {
            font-family: Arial, sans-serif;
          }

          .page {
            page-break-after: always;
          }

          .page:last-child {
            page-break-after: avoid;
          }

          .section {
            margin-bottom: 20px;
          }

          .no-break {
            page-break-inside: avoid;
          }

          @media print {
            .page-break {
              page-break-after: always;
            }

            .avoid-break {
              page-break-inside: avoid;
            }
          }

          ${styles}
        </style>
        <style>
          @media print {
            body {
              margin: 0;
              padding: 20px;
              background-color: #f8f8f8;
            }
          }
        </style>
      </head>
      <body>
        ${printContent}
      </body>
    </html>
  `;
};
  • 前端调用代码
const testPrint = async () => {
  const htmlStr = await printSpecificElement('bysking');
  console.log(htmlStr, 'htmlStr'); // 前端获取需要打印的节点的html字符串,包含图片远程src资源地址转化
  // 然后用这个字符串请求后端服务进行打印
  // 后端生成的pdf以二进制返回
};

有帮助,点个关注,来个赞!!