使用 html2canvas + jsPDF 生成PDF 的简单示例(含文字下沉修复)

在前端项目中,尤其是后台管理系统、发票/报告导出、在线编辑器、前端页面截图导出等场景,将页面内容导出为 PDF 是非常常见的需求。现在使用 html2canvas + jsPDF 进行构建 hooks 作为示例。

一、为什么需要自定义封装?

自个实现全局 hooks 可控,想怎么来就怎么来(参考 html2pdf.js)。

直接使用 html2canvas 和 jsPDF 通常会遇到:

  • 内容被截断 / 超出容器
  • 内容生成不居中
  • 图片跨域污染导致失败
  • Tailwind/UnoCSS 的样式与 html2canvas 不兼容
  • PDF 页面分页不正确
  • 文字渲染模糊
  • 文字下沉(文本 baseline 问题)

要做到尽可能无损的渲染,需要:

  1. 克隆 DOM,在单独容器中渲染(目前使用这个方法实现内容不居中问题)
  2. 处理 position/overflow/scroll 等不兼容属性
  3. 修复不支持的 CSS 颜色函数(oklab/oklch 等)
  4. 手动分页 + 图片等比缩放
  5. 覆盖一些框架默认样式(如 img display)

二、核心导出功能实现

以下代码实现了完整的导出流程:

  • 自动加载 html2canvas / jsPDF
  • DOM 克隆渲染
  • Canvas 控制切分页
  • PDF 导出或预览
  • 进度回调

代码较长,这里展示关键核心结构:

export function useHtml2Pdf() {
  const exporting = ref(false);
  const progress = ref(0);
  const previewImages = ref<string[]>([]);

  // 加载库
  const loadLibraries = async () => { ... }

  // DOM → Canvas
  const renderToCanvas = async () => { ... }

  // Canvas → PDF
  const splitCanvasToPdf = () => { ... }

  // 导出与预览
  const exportPdf = async () => { ... }
  const previewPdf = async () => { ... }

  return { exporting, progress, previewImages, exportPdf, previewPdf };
}

完整代码已在文章结尾处附带(代码以及实现就不详细解读了)。


三、为什么会出现“文字下沉”?

这是使用 html2canvas 时 最常见也是最诡异的 bug 之一

  • 同一行文字字体不同
  • 图片与文字混合排版
  • Tailwind / UnoCSS 的 baseline 设置
  • img 默认为 display: inlineinline-block
  • html2canvas 会根据 CSS 计算内容 baseline,高度计算错误

具体表现:

  • 文字被向下“压”了一点点,与真实页面不一致
  • 某些容器中的文本垂直位置偏离

根本原因

UnoCSS / Tailwind 默认对 img 设置为:

display: inline;
vertical-align: middle;

导致 html2canvas 在计算行内盒高度时:

  • 文字基线被迫下移
  • 图片对齐方式干扰文本

html2canvas 本身对 inline-level box 的计算就比较脆弱,因此这个默认样式会破坏它的内部排版逻辑。


四、一行 CSS 解决文字下沉问题

解决方案:强制 img 不参与 inline 排版

img {
  display: inline-block !important;
}

为什么这行能解决?

  1. inline-block 会创建自己的盒模型,不再参与行内 baseline 对齐。
  2. html2canvas 计算布局时,不需要处理 inline-level baseline,从而避免错位。
  3. 避免 Tailwind/UnoCSS 默认的 vertical-align: middle 影响布局高度。

这是经过大量社区使用、实测最稳定的解决方案。

注意:这种 bug 仅在截屏(html2canvas)时出现,真实 DOM 渲染正常。


五、完整代码

import { ref, onMounted, type Ref } from 'vue';

/**
 * html2canvas 配置选项
 */
export interface Html2CanvasOptions {
  scale?: number; // 清晰度倍数,默认使用 devicePixelRatio * 1.5
  useCORS?: boolean; // 是否使用 CORS
  allowTaint?: boolean; // 是否允许跨域图片污染 canvas
  backgroundColor?: string; // 背景色
  logging?: boolean; // 是否启用日志
  width?: number; // 宽度
  height?: number; // 高度
  windowWidth?: number; // 窗口宽度
  windowHeight?: number; // 窗口高度
  x?: number; // X 偏移
  y?: number; // Y 偏移
  scrollX?: number; // X 滚动
  scrollY?: number; // Y 滚动
  onclone?: (clonedDoc: Document, clonedElement: HTMLElement) => void; // 克隆回调
}

/**
 * 图片质量配置
 */
export interface ImageOptions {
  type?: 'png' | 'jpeg' | 'webp'; // 图片类型
  quality?: number; // 图片质量 0-1,仅对 jpeg/webp 有效
}

/**
 * 页面分页配置
 */
export interface PagebreakOptions {
  mode?: ('avoid-all' | 'css' | 'legacy')[]; // 分页模式
  before?: string | string[]; // 在此元素前分页
  after?: string | string[]; // 在此元素后分页
  avoid?: string | string[]; // 避免在此元素处分页
  enabled?: boolean; // 是否启用自动分页,默认true
  avoidSinglePage?: boolean; // 是否避免单页内容强制分页,默认true
}

/**
 * PDF 页面格式类型
 */
export type PdfFormat =
  | 'a0' | 'a1' | 'a2' | 'a3' | 'a4' | 'a5' | 'a6' | 'a7' | 'a8' | 'a9' | 'a10'
  | 'b0' | 'b1' | 'b2' | 'b3' | 'b4' | 'b5' | 'b6' | 'b7' | 'b8' | 'b9' | 'b10'
  | 'c0' | 'c1' | 'c2' | 'c3' | 'c4' | 'c5' | 'c6' | 'c7' | 'c8' | 'c9' | 'c10'
  | 'dl' | 'letter' | 'government-letter' | 'legal' | 'junior-legal'
  | 'ledger' | 'tabloid' | 'credit-card'
  | [number, number]; // 自定义尺寸 [width, height] in mm

/**
 * PDF 导出配置选项
 */
export interface Html2PdfOptions {
  fileName?: string; // 文件名
  scale?: number; // 清晰度倍数,默认使用 devicePixelRatio * 1.5
  padding?: number; // 页面内边距 (mm)
  format?: PdfFormat; // PDF格式,默认为 'a4'
  orientation?: 'portrait' | 'landscape'; // 方向
  align?: 'left' | 'center' | 'right'; // 内容对齐方式
  image?: ImageOptions; // 图片配置
  html2canvas?: Partial<Html2CanvasOptions>; // html2canvas 配置
  pagebreak?: PagebreakOptions; // 分页配置
  onProgress?: (progress: number) => void; // 进度回调 0-100
}

/**
 * 返回值类型
 */
export interface UseHtml2PdfReturn {
  exporting: Ref<boolean>;
  progress: Ref<number>; // 进度 0-100
  previewImages: Ref<string[]>; // 预览图片数组
  exportPdf: (
    element: HTMLElement | string | null,
    options?: Html2PdfOptions
  ) => Promise<void>;
  previewPdf: (
    element: HTMLElement | string | null,
    options?: Html2PdfOptions
  ) => Promise<void>;
}

/**
 * 使用 html2canvas + jsPDF 生成 PDF
 * 适配 Vue 3 + Nuxt.js 3
 */
export function useHtml2Pdf(): UseHtml2PdfReturn {
  const exporting = ref(false);
  const progress = ref(0);
  const previewImages = ref<string[]>([]);
  const { $message } = useNuxtApp();

  // 库实例
  let html2canvas: any = null;
  let jsPDF: any = null;

  // 库加载状态
  let librariesLoading: Promise<void> | null = null;

  /**
   * 加载必要的库
   */
  const loadLibraries = async (): Promise<void> => {
    if (librariesLoading) {
      return librariesLoading;
    }

    librariesLoading = (async () => {
      try {
        const [html2canvasModule, jsPDFModule] = await Promise.all([
          import('html2canvas'),
          import('jspdf'),
        ]);
        html2canvas = html2canvasModule.default || html2canvasModule;
        jsPDF = (jsPDFModule as any).jsPDF;
      } catch (error) {
        console.error('PDF 库加载失败:', error);
        throw error;
      }
    })();

    return librariesLoading;
  };

  // 在客户端预加载库
  if (process.client) {
    onMounted(() => {
      loadLibraries().catch((error) => {
        console.error('预加载 PDF 库失败:', error);
      });
    });
  }

  /**
   * 更新进度
   */
  const updateProgress = (value: number, callback?: (progress: number) => void): void => {
    progress.value = Math.min(100, Math.max(0, value));
    if (callback) {
      callback(progress.value);
    }
  };

  /**
   * 获取目标元素
   */
  const getElement = (element: HTMLElement | string | null): HTMLElement | null => {
    if (!element || process.server) return null;
    if (typeof element === 'string') {
      return document.querySelector(element) as HTMLElement;
    }
    return element;
  };

  /**
   * 保存和恢复元素样式
   */
  interface StyleState {
    overflow: string;
    maxHeight: string;
    height: string;
    scrollTop: number;
    scrollLeft: number;
    bodyOverflow: string;
    bodyScrollTop: number;
  }

  const saveStyles = (element: HTMLElement): StyleState => {
    return {
      overflow: element.style.overflow,
      maxHeight: element.style.maxHeight,
      height: element.style.height,
      scrollTop: element.scrollTop,
      scrollLeft: element.scrollLeft,
      bodyOverflow: document.body.style.overflow,
      bodyScrollTop: window.scrollY,
    };
  };

  const applyCaptureStyles = (element: HTMLElement): void => {
    element.style.overflow = 'visible';
    element.style.maxHeight = 'none';
    element.style.height = 'auto';
    document.body.style.overflow = 'hidden';
    element.scrollTop = 0;
    element.scrollLeft = 0;
    window.scrollTo(0, 0);
  };

  const restoreStyles = (element: HTMLElement, state: StyleState): void => {
    element.style.overflow = state.overflow;
    element.style.maxHeight = state.maxHeight;
    element.style.height = state.height;
    element.scrollTop = state.scrollTop;
    element.scrollLeft = state.scrollLeft;
    document.body.style.overflow = state.bodyOverflow;
    window.scrollTo(0, state.bodyScrollTop);
  };

  /**
   * 修复不支持的 CSS 颜色函数
   */
  const fixUnsupportedColors = (clonedDoc: Document, originalElement: HTMLElement): void => {
    clonedDoc.body.style.backgroundColor = '#ffffff';
    clonedDoc.body.style.margin = '0';
    clonedDoc.body.style.padding = '0';

    const allElements = clonedDoc.querySelectorAll('*');
    const colorProperties = [
      'color',
      'background-color',
      'background',
      'border-color',
      'border-top-color',
      'border-right-color',
      'border-bottom-color',
      'border-left-color',
      'outline-color',
      'box-shadow',
      'text-shadow',
    ];

    allElements.forEach((el, index) => {
      if (el instanceof HTMLElement) {
        // 尝试从原始文档找到对应元素
        let originalEl: HTMLElement | null = null;
        if (originalElement) {
          const originalAll = originalElement.querySelectorAll('*');
          if (originalAll[index]) {
            originalEl = originalAll[index] as HTMLElement;
          }
        }
        const targetEl = originalEl || el;

        try {
          const computedStyle = window.getComputedStyle(targetEl, null);
          colorProperties.forEach((prop) => {
            try {
              const computedValue = computedStyle.getPropertyValue(prop);
              const styleValue = targetEl.style.getPropertyValue(prop);

              if (
                (styleValue && (
                  styleValue.includes('oklab') ||
                  styleValue.includes('oklch') ||
                  styleValue.includes('lab(') ||
                  styleValue.includes('lch(')
                )) ||
                (computedValue && (
                  computedValue.includes('oklab') ||
                  computedValue.includes('oklch')
                ))
              ) {
                if (computedValue && (computedValue.includes('rgb') || computedValue.includes('#'))) {
                  el.style.setProperty(prop, computedValue, 'important');
                } else if (prop.includes('shadow')) {
                  el.style.setProperty(prop, 'none', 'important');
                } else {
                  el.style.removeProperty(prop);
                }
              }
            } catch (e) {
              // 忽略单个属性的错误
            }
          });
        } catch (e) {
          // 如果无法获取计算样式,跳过该元素
        }
      }
    });

    if (originalElement) {
      (originalElement as HTMLElement).style.position = 'relative';
      (originalElement as HTMLElement).style.width = 'auto';
      (originalElement as HTMLElement).style.height = 'auto';
    }
  };

  /**
   * 创建渲染容器
   */
  const createRenderContainer = (sourceElement: HTMLElement): { overlay: HTMLElement; container: HTMLElement; cleanup: () => void } => {
    // 创建 overlay 容器样式
    const overlayCSS = {
      position: 'fixed',
      overflow: 'hidden',
      zIndex: 1000,
      left: 0,
      right: 0,
      bottom: 0,
      top: 0,
      backgroundColor: 'rgba(0,0,0,0.8)',
      opacity: 0
    };

    // 创建内容容器样式
    const containerCSS = {
      position: 'absolute',
      width: 'auto',
      left: 0,
      right: 0,
      top: 0,
      height: 'auto',
      margin: 'auto',
      backgroundColor: 'white'
    };

    // 创建 overlay 容器
    const overlay = document.createElement('div');
    overlay.className = 'html2pdf__overlay';
    Object.assign(overlay.style, overlayCSS);

    // 创建内容容器
    const container = document.createElement('div');
    container.className = 'html2pdf__container';
    Object.assign(container.style, containerCSS);

    // 克隆源元素并添加到容器中
    const clonedElement = sourceElement.cloneNode(true) as HTMLElement;
    container.appendChild(clonedElement);
    overlay.appendChild(container);
    document.body.appendChild(overlay);

    // 清理函数
    const cleanup = () => {
      if (document.body.contains(overlay)) {
        document.body.removeChild(overlay);
      }
    };

    return { overlay, container, cleanup };
  };

  /**
   * 渲染 DOM -> Canvas
   */
  const renderToCanvas = async (
    element: HTMLElement,
    options?: {
      scale?: number;
      html2canvas?: Partial<Html2CanvasOptions>;
      onProgress?: (progress: number) => void;
    }
  ): Promise<HTMLCanvasElement> => {
    // 确保库已加载
    await loadLibraries();

    if (!html2canvas) {
      throw new Error('html2canvas 未加载');
    }

    const {
      scale: customScale,
      html2canvas: html2canvasOptions = {},
      onProgress: progressCallback,
    } = options || {};

    const defaultScale = (window.devicePixelRatio || 1) * 1.5;
    const finalScale = customScale ?? html2canvasOptions.scale ?? defaultScale;
    const fullWidth = element.scrollWidth || html2canvasOptions.width || element.offsetWidth;
    const fullHeight = element.scrollHeight || html2canvasOptions.height || element.offsetHeight;

    // 保存样式
    const styleState = saveStyles(element);
    applyCaptureStyles(element);

    // 创建渲染容器
    const { container, cleanup } = createRenderContainer(element);
    const clonedElement = container.firstElementChild as HTMLElement;

    // 等待布局稳定
    await new Promise((resolve) => {
      requestAnimationFrame(() => {
        requestAnimationFrame(resolve);
      });
    });

    updateProgress(10, progressCallback);

    try {
      // 合并默认配置和自定义配置
      const canvasOptions = {
        scale: finalScale,
        useCORS: true,
        allowTaint: false,
        logging: false,
        backgroundColor: '#ffffff',
        width: fullWidth,
        height: fullHeight,
        windowWidth: fullWidth,
        windowHeight: fullHeight,
        x: 0,
        y: 0,
        scrollX: 0,
        scrollY: 0,
        ...html2canvasOptions,
        onclone: (clonedDoc: Document, clonedElementFromCanvas: HTMLElement) => {
          fixUnsupportedColors(clonedDoc, element);
          // 执行用户自定义的 onclone 回调
          if (html2canvasOptions.onclone) {
            html2canvasOptions.onclone(clonedDoc, clonedElementFromCanvas);
          }
        },
      };

      updateProgress(20, progressCallback);

      // 使用克隆的元素进行渲染
      const canvas = await html2canvas(clonedElement, canvasOptions);
      updateProgress(50, progressCallback);
      return canvas;
    } finally {
      // 清理容器
      cleanup();
      // 恢复样式
      restoreStyles(element, styleState);
    }
  };

  /**
   * 获取页面尺寸配置(单位:mm)
   * 参考 jsPDF 的页面尺寸定义,使用精确的 pt 到 mm 转换
   */
  const getPageSizes = (
    format: PdfFormat,
    orientation: 'portrait' | 'landscape' = 'portrait'
  ): { width: number; height: number } => {
    // 如果是自定义数组格式 [width, height]
    if (Array.isArray(format)) {
      return { width: format[0], height: format[1] };
    }

    // pt 到 mm 的转换因子:1 pt = 72/25.4 mm
    const k = 72 / 25.4;

    // 所有页面格式的尺寸(单位:pt)
    // 参考 jsPDF 的页面格式定义
    const pageFormatsPt: Record<string, [number, number]> = {
      // A 系列
      a0: [2383.94, 3370.39],
      a1: [1683.78, 2383.94],
      a2: [1190.55, 1683.78],
      a3: [841.89, 1190.55],
      a4: [595.28, 841.89],
      a5: [419.53, 595.28],
      a6: [297.64, 419.53],
      a7: [209.76, 297.64],
      a8: [147.40, 209.76],
      a9: [104.88, 147.40],
      a10: [73.70, 104.88],
      
      // B 系列
      b0: [2834.65, 4008.19],
      b1: [2004.09, 2834.65],
      b2: [1417.32, 2004.09],
      b3: [1000.63, 1417.32],
      b4: [708.66, 1000.63],
      b5: [498.90, 708.66],
      b6: [354.33, 498.90],
      b7: [249.45, 354.33],
      b8: [175.75, 249.45],
      b9: [124.72, 175.75],
      b10: [87.87, 124.72],
      
      // C 系列
      c0: [2599.37, 3676.54],
      c1: [1836.85, 2599.37],
      c2: [1298.27, 1836.85],
      c3: [918.43, 1298.27],
      c4: [649.13, 918.43],
      c5: [459.21, 649.13],
      c6: [323.15, 459.21],
      c7: [229.61, 323.15],
      c8: [161.57, 229.61],
      c9: [113.39, 161.57],
      c10: [79.37, 113.39],
      
      // 其他格式
      dl: [311.81, 623.62],
      letter: [612, 792],
      'government-letter': [576, 756],
      legal: [612, 1008],
      'junior-legal': [576, 360],
      ledger: [1224, 792],
      tabloid: [792, 1224],
      'credit-card': [153, 243],
    };

    const formatLower = format.toLowerCase();
    let pageSize: [number, number];

    if (pageFormatsPt.hasOwnProperty(formatLower)) {
      pageSize = pageFormatsPt[formatLower];
    } else {
      // 默认使用 A4
      pageSize = pageFormatsPt.a4;
      console.warn(`未识别的页面格式 "${format}",使用默认格式 A4`);
    }

    // 转换为 mm
    let width = pageSize[0] / k;
    let height = pageSize[1] / k;

    // 处理方向
    if (orientation === 'portrait') {
      // 纵向:确保宽度 < 高度
      if (width > height) {
        [width, height] = [height, width];
      }
    } else if (orientation === 'landscape') {
      // 横向:确保宽度 > 高度
      if (height > width) {
        [width, height] = [height, width];
      }
    }

    return { width, height };
  };

  /**
   * 将 Canvas 切分页、生成 PDF
   */
  const splitCanvasToPdf = (
    canvas: HTMLCanvasElement,
    options: {
      format: PdfFormat;
      orientation: 'portrait' | 'landscape';
      padding: number;
      fileName: string;
      align?: 'left' | 'center' | 'right';
      image?: ImageOptions;
      pagebreak?: PagebreakOptions;
      onProgress?: (progress: number) => void;
    },
    doDownload = false
  ): { pdf: any; images: string[] } => {
    if (!jsPDF) {
      throw new Error('jsPDF 未加载');
    }

    const {
      format,
      orientation,
      padding,
      fileName,
      align = 'center',
      image = { type: 'jpeg', quality: 0.95 },
      pagebreak = { enabled: false, avoidSinglePage: true },
      onProgress: progressCallback,
    } = options;

    // 获取页面尺寸
    const pageSize = getPageSizes(format, orientation);
    
    // 对于自定义尺寸(数组格式),需要特殊处理
    // jsPDF 构造函数格式:new jsPDF(orientation, unit, format)
    // 如果 format 是数组 [width, height],则作为自定义尺寸传递
    const pdfFormat: string | [number, number] = Array.isArray(format) 
      ? format 
      : format;
    
    const pdf = new jsPDF(orientation, 'mm', pdfFormat);
    const pageWidth = pageSize.width;
    const pageHeight = pageSize.height;
    
    // margin = [top, left, bottom, right]
    // 这里 padding 相当于左右边距(当四边相等时)
    // 支持独立设置四个方向的边距,默认只设置一个值
    const marginTop = padding;
    const marginLeft = padding;
    const marginBottom = padding;
    const marginRight = padding;
    
    // 可用内容区域(考虑边距)
    const innerWidth = pageWidth - marginLeft - marginRight;
    const innerHeight = pageHeight - marginTop - marginBottom;

    // 计算图片尺寸,保持宽高比
    // 先计算基于可用区域的宽度和高度的比例,看哪个更限制
    const widthRatio = innerWidth / canvas.width;
    const heightRatio = innerHeight / canvas.height;
    const scaleRatio = Math.min(widthRatio, heightRatio);

    // 图片在 PDF 中的尺寸
    let imgWidth: number;
    let imgHeight: number;

    // 图片尺寸基于可用区域和内容比例
    imgWidth = canvas.width * scaleRatio;
    imgHeight = canvas.height * scaleRatio;

    // 确保图片不超过可用区域
    if (imgWidth > innerWidth) {
      imgWidth = innerWidth;
      imgHeight = (canvas.height / canvas.width) * innerWidth;
    }
    if (imgHeight > innerHeight) {
      imgHeight = innerHeight;
      imgWidth = (canvas.width / canvas.height) * innerHeight;
    }

    // 计算PDF页面在canvas像素坐标系中的高度
    // 1mm = (canvas像素 / PDF尺寸mm) 的比例
    let pxPageHeight: number;
    if(pagebreak.enabled) {
      const pxPerMm = canvas.width / (pageSize.width - marginLeft - marginRight);
      pxPageHeight = Math.floor(innerHeight * pxPerMm);
    } else {
      pxPageHeight = Math.floor(canvas.width * (imgHeight / imgWidth));
    }

    // 计算水平位置
    let xPosition: number;
    switch (align) {
      case 'left':
        // 左对齐:从左边距开始
        xPosition = marginLeft;
        break;
      case 'right':
        // 右对齐:从右边距开始计算,确保图片在右边
        xPosition = pageWidth - marginRight - imgWidth;
        break;
      case 'center':
      default:
        // 居中:计算居中位置
        xPosition = marginLeft + (innerWidth - imgWidth) / 2;
        break;
    }

    // 确定图片类型和质量
    const imageType = image.type || 'jpeg';
    const imageQuality = image.quality ?? (imageType === 'png' ? undefined : 0.95);
    const pdfImageFormat = imageType === 'png' ? 'PNG' : 'JPEG';

    // 确保图片质量在有效范围内
    const finalQuality = imageQuality !== undefined 
      ? Math.max(0, Math.min(1, imageQuality)) 
      : undefined;

    const images: string[] = [];

    // 根据配置决定是否分页
    const pxFullHeight = canvas.height;
    let nPages = 1;

    if (pagebreak.enabled) {
      // 计算需要的页数
      const calculatedPages = Math.ceil(pxFullHeight / pxPageHeight);

      // 如果避免单页强制分页,且内容不超过一页,则不分页
      if (pagebreak.avoidSinglePage && calculatedPages === 1) {
        nPages = 1;
      } else {
        nPages = calculatedPages;
      }
    } else {
      nPages = Math.ceil(pxFullHeight / pxPageHeight);;
    }

    // 估算总页数用于进度计算
    const estimatedTotalPages = nPages;

    // 创建页面 canvas
    const pageCanvas = document.createElement('canvas');
    const pageCtx = pageCanvas.getContext('2d');
    if (!pageCtx) {
      throw new Error('无法创建 Canvas 上下文');
    }
    
    pageCanvas.width = canvas.width;
    pageCanvas.height = pxPageHeight;

    // 分页处理
    for (let page = 0; page < nPages; page++) {
      // 最后一页可能需要调整高度
      let currentPxPageHeight = pxPageHeight;
      let currentPageHeight = innerHeight;
      
      if (page === nPages - 1 && pxFullHeight % pxPageHeight !== 0) {
        // 最后一页:使用剩余高度
        currentPxPageHeight = pxFullHeight % pxPageHeight;
        currentPageHeight = (currentPxPageHeight / canvas.width) * innerWidth;
        pageCanvas.height = currentPxPageHeight;
      }

      // 清空并绘制当前页的内容
      pageCtx.fillStyle = 'white';
      pageCtx.fillRect(0, 0, pageCanvas.width, currentPxPageHeight);
      
      pageCtx.drawImage(
        canvas,
        0,
        page * pxPageHeight,
        pageCanvas.width,
        currentPxPageHeight,
        0,
        0,
        pageCanvas.width,
        currentPxPageHeight
      );
      const sourceHeight = (currentPageHeight / imgHeight) * canvas.height;

      // 根据配置生成图片数据
      const mimeType = `image/${imageType}`;
      const pageImgData = finalQuality !== undefined
        ? pageCanvas.toDataURL(mimeType, finalQuality)
        : pageCanvas.toDataURL(mimeType);

      // 添加新页(除了第一页)
      if (page > 0) {
        pdf.addPage();
      }

      // 添加图片到 PDF(x = marginLeft, y = marginTop)
      pdf.addImage(
        pageImgData,
        pdfImageFormat,
        xPosition,
        marginTop,
        imgWidth,
        currentPageHeight
      );

      if (!doDownload) {
        images.push(pageImgData);
      }

      // 更新进度 (50-90%)
      if (progressCallback && estimatedTotalPages > 0) {
        const pageProgress = 50 + ((page + 1) / estimatedTotalPages) * 40;
        updateProgress(pageProgress, progressCallback);
      }
    }

    updateProgress(95, progressCallback);

    if (doDownload) {
      pdf.save(fileName);
      updateProgress(100, progressCallback);
    }

    return { pdf, images };
  };

  /**
   * 导出 PDF
   * @param element 需要导出的 DOM 元素或选择器
   * @param options 配置项
   */
  const exportPdf = async (
    element: HTMLElement | string | null,
    options?: Html2PdfOptions
  ): Promise<void> => {
    // 服务端检查
    if (process.server) {
      if ($message) $message.error('PDF生成功能仅在客户端可用');
      return;
    }

    const targetElement = getElement(element);
    if (!targetElement) {
      if ($message) $message.error('未找到要导出的 DOM 元素');
      return;
    }

    const {
      fileName = 'document.pdf',
      scale,
      padding = 0,
      format = 'a4',
      orientation = 'portrait',
      align = 'center',
      image,
      html2canvas: html2canvasOptions,
      pagebreak,
      onProgress,
    } = options || {};

    try {
      exporting.value = true;
      updateProgress(0, onProgress);

      // 确保库已加载
      await loadLibraries();

      if (!jsPDF) {
        throw new Error('jsPDF 未加载');
      }

      // 渲染为 Canvas
      const canvas = await renderToCanvas(targetElement, {
        scale,
        html2canvas: html2canvasOptions,
        onProgress: (progress) => {
          // 将 Canvas 渲染进度映射到 0-50%
          updateProgress(progress * 0.5, onProgress);
        },
      });

      // 切分并生成 PDF
      splitCanvasToPdf(
        canvas,
        {
          fileName,
          padding,
          format,
          orientation,
          align,
          image,
          pagebreak,
          onProgress: (progress) => {
            // 将 PDF 生成进度映射到 50-100%
            updateProgress(50 + progress * 0.5, onProgress);
          },
        },
        true // 下载
      );

      if ($message) {
        $message.success('PDF生成成功');
      }
    } catch (error: any) {
      console.error('PDF 生成失败:', error);
      updateProgress(0);
      if ($message) {
        $message.error(error?.message || 'PDF生成失败,请稍后重试');
      }
      throw error;
    } finally {
      exporting.value = false;
    }
  };

  /**
   * 预览 PDF(在新窗口打开)
   * @param element 需要导出的 DOM 元素或选择器
   * @param options 配置项
   */
  const previewPdf = async (
    element: HTMLElement | string | null,
    options?: Html2PdfOptions
  ): Promise<void> => {
    // 服务端检查
    if (process.server) {
      if ($message) $message.error('PDF预览功能仅在客户端可用');
      return;
    }

    const targetElement = getElement(element);
    if (!targetElement) {
      if ($message) $message.error('未找到要导出的 DOM 元素');
      return;
    }

    const {
      fileName = 'preview.pdf',
      scale,
      padding = 0,
      format = 'a4',
      orientation = 'portrait',
      align = 'center',
      image,
      html2canvas: html2canvasOptions,
      pagebreak,
      onProgress,
    } = options || {};

    try {
      exporting.value = true;
      updateProgress(0, onProgress);

      // 确保库已加载
      await loadLibraries();

      if (!jsPDF) {
        throw new Error('jsPDF 未加载');
      }

      // 渲染为 Canvas
      const canvas = await renderToCanvas(targetElement, {
        scale,
        html2canvas: html2canvasOptions,
        onProgress: (progress) => {
          // 将 Canvas 渲染进度映射到 0-50%
          updateProgress(progress * 0.5, onProgress);
        },
      });

      // 切分并生成 PDF(不下载,生成预览图片)
      const { pdf, images } = splitCanvasToPdf(
        canvas,
        {
          fileName,
          padding,
          format,
          orientation,
          align,
          image,
          pagebreak,
          onProgress: (progress) => {
            // 将 PDF 生成进度映射到 50-100%
            updateProgress(50 + progress * 0.5, onProgress);
          },
        },
        false // 不下载
      );

      // 更新预览图片
      previewImages.value = images;

      // 生成 PDF Blob 并在新窗口打开
      const pdfBlob = pdf.output('blob');
      const url = URL.createObjectURL(pdfBlob);
      const previewWindow = window.open(url, '_blank');

      if (!previewWindow) {
        if ($message) $message.warning('请允许弹出窗口以预览PDF');
        // 如果无法打开新窗口,则下载
        pdf.save(fileName);
        updateProgress(100, onProgress);
        return;
      }

      // 清理URL对象
      previewWindow.addEventListener('load', () => {
        setTimeout(() => URL.revokeObjectURL(url), 1000);
      });

      updateProgress(100, onProgress);

      if ($message) {
        $message.success('PDF预览已打开');
      }
    } catch (error: any) {
      console.error('PDF 预览失败:', error);
      updateProgress(0);
      if ($message) {
        $message.error(error?.message || 'PDF预览失败,请稍后重试');
      }
      throw error;
    } finally {
      exporting.value = false;
    }
  };

  return {
    exporting,
    progress,
    previewImages,
    exportPdf,
    previewPdf,
  };
}

最后

  1. 使用 css 框架例如 unocss 等会出现 oklab 等渲染报错,目前查询是因为使用行内类样式,例如 text-[xxx] or 其他,详细大家可以看下 html2canvas 源码实现。
  2. 不使用根据 Dom 节点克隆之后先创建容器的话会导致生成的 pdf 内容不是居中的,那么应该是需要更详细的计算绘制内容(反正没成功),有大佬可以评论区贴下代码给我研究下。
  3. 文字下沉大部分是 css 框架问题,drawImage 生成的图片导致的,直接全局设置即可,三行代码搞定。
  4. 生成 pdf 只有视图看到的话需要先滚动到顶部等一系列样式操作才能正常全部绘制。
    ...

欢迎评论区进行讨论!

posted @ 2025-11-25 16:40  幼儿园技术家  阅读(66)  评论(0)    收藏  举报