返回 导航

Vue.js

hangge.com

Vue.js - 富文本编辑器Tiptap使用详解14(将内容导出为PDF)

作者:hangge | 2026-03-06 08:30
    前文我演示了如何将 Tiptap 内容导出为 Word (点击查看),本文演示如何实现将 Tiptap 内容导出为 Pdf 文件,同样有纯客户端导出,以及由服务端导出两种方案。

十四、将内容导出为 PDF(客户端导出)

1,安装依赖

(1)要实现客户端导出,我们需要先安装 html2pdf.js 这个依赖库。
提示html2pdf 其实就是 jsPDF + html2canvas 的封装。
npm install html2pdf.js

(2)使用 html2pdf.js 特别要注意如下几点:
  • 整个过程中,无论我们在页面上是文字、SVG、路径、删除线、表格,最终都被 html2canvas 渲染成一张位图,jsPDF 再把这张图放入 PDF
  • 因此这些元素都不是矢量的,文字无法复制,且放大后会模糊。
DOM → html2canvas(渲染成 bitmap) → jsPDF(insertImage) → PDF

2,样例代码

(1)下面代码我们将编辑器的 HTML 注入到一个隐藏容器,设置打印样式后调用 html2pdf 导出。
<template>
  <div class="p-4">
    <div class="mb-3 flex gap-2">
      <button @click="exportPdf" class="btn">导出为 PDF</button>
    </div>
    <div class="editor-box border rounded">
      <editor-content :editor="editor" />
    </div>
    <!-- 隐藏的渲染容器,用于让 html2pdf 渲染带样式的内容 -->
    <div ref="pdfContainer" style="display:none;"></div>
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent } from '@tiptap/vue-2'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
// 推荐:TableKit 一次引入所有 table 相关扩展
import { TableKit } from '@tiptap/extension-table'
import { TextStyleKit }   from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'

import html2pdf from "html2pdf.js";

export default {
  components: { EditorContent },
  data() { return { editor: null } },
  mounted() {
    this.editor = new Editor({
      extensions: [StarterKit, Link, Image, TableKit, TextStyleKit,
        Color.configure({
            types: ['textStyle'],
        }),
        // multicolor: true 允许自定义颜色
        Highlight.configure({
          multicolor: true,
        }),
      ],
      content: `
        <p>欢迎访问<a href="https://www.hangge.com">hangge.com</a></p>
        <p><img src="http://localhost:8080/images/logo.png" alt="logo"></p>
        <p></p>
        <p>测试<strong>加粗</strong>测试<em>斜体</em>测试<u>下划线</u>测试<s>删除线</s></p>
        <p></p>
        <p><span style="color: rgb(255, 102, 255);">修改文字颜色</span></p>
        <p><span style="font-size: 22px;">调整文字大小</span></p>
        <p></p>
        <p>下面是一个3x3表格</p>
        <table>
          <thead>
            <tr><th>ID</th><th>用户名称</th><th>用户年龄</th></tr>
          </thead>
          <tbody>
            <tr><td>1</td><td>刘小龙</td><td>88</td></tr>
            <tr><td>2</td><td>张大伟</td><td>66</td></tr>
          </tbody>
        </table>
      `,
    })
  },
  methods: {
    // 导出 PDF
    async exportPdf() {
      try {
        // 1) 获取编辑器 HTML
        const html = this.editor.getHTML();

        // 2) 准备一个容器,注入 HTML 和必要样式
        const container = this.$refs.pdfContainer;
        container.innerHTML = ''; // 清空
        const wrapper = document.createElement('div');
        wrapper.className = 'pdf-export-wrapper';
        wrapper.innerHTML = html;
        container.appendChild(wrapper);

        // 3) 注入或复制必要的 CSS(字体、印刷样式等)
        this.injectStyles(container);

        // 4) 配置 html2pdf 选项(A4、纵向、边距等)
        const opt = {
          margin:       [10, 10, 10, 10], // mm: top/right/bottom/left
          filename:     'tiptap-export.pdf',
          image:        { type: 'jpeg', quality: 0.98 },
          html2canvas:  { scale: 2, useCORS: true, allowTaint: false, logging: false },
          jsPDF:        { unit: 'mm', format: 'a4', orientation: 'portrait' },
          pagebreak: { mode: ['css', 'legacy'] } // 支持 CSS page-break
        };

        // 5) 运行导出(返回 Promise)
        await html2pdf().set(opt).from(wrapper).save();

      } catch (err) {
        console.error("导出 PDF 失败:", err);
        this.$emit('export-error', err);
      } finally {
        // 可选:清理容器
        // this.$refs.pdfContainer.innerHTML = '';
      }
    },
    // 注入打印样式:控制行高、段落间距、表格样式、分页等
    injectStyles(container) {1
      const style = document.createElement('style');
      style.innerHTML = `
        /* 基本字体与行高 */
        .pdf-export-wrapper {
          font-family: "Arial", "Helvetica", sans-serif;
          font-size: 12pt;
          line-height: 1.6; /* <- 重点:增大行高 */
          color: #222;
          box-sizing: border-box;
          -webkit-font-smoothing: antialiased;
          -moz-osx-font-smoothing: grayscale;
          word-wrap: break-word;
          overflow-wrap: break-word;
        }

        /* 段落与标题间距,避免挤在一起 */
        .pdf-export-wrapper p { margin: 0 0 10px; }
        .pdf-export-wrapper h1, .pdf-export-wrapper h2, .pdf-export-wrapper h3 {
          margin: 6px 0 8px;
          line-height: 1.3;
        }

        /* 表格样式: padding + 防止被拆行 */
        .pdf-export-wrapper table { width: 100%; border-collapse: collapse; table-layout: auto; }
        .pdf-export-wrapper th { background: #f9fafb;  }
        .pdf-export-wrapper th, .pdf-export-wrapper td {
          padding: 8px 6px; /* <- 重点:给单元格足够高度 */
          vertical-align: top; /* 避免垂直居中导致覆盖 */
          border: 1px solid #ddd;
          page-break-inside: avoid;
        }
        .pdf-export-wrapper tr { page-break-inside: avoid; break-inside: avoid; }

        /* 表头跨页重复(如果支持) */
        .pdf-export-wrapper thead { display: table-header-group; }
        .pdf-export-wrapper tfoot { display: table-footer-group; }

        /* 手动分页辅助类 */
        .page-break { break-after: page; page-break-after: always; }

        /* 图片不超过容器宽度并自动缩放 */
        .pdf-export-wrapper img { max-width: 100%; height: auto; display: block; }

        /* 防止子元素使用 transform/translate 导出错位,若有可手动覆盖 */
        .pdf-export-wrapper [style*="transform"] { transform: none !important; }
      `;
      container.appendChild(style);
    }
  },
  beforeDestroy() { this.editor?.destroy() }
}
</script>
<style scoped>
.btn { padding:6px 10px; border-radius:4px; border:1px solid #ddd; background:#fff; cursor:pointer }
.editor-box { min-height: 220px; padding: 12px; background:#fff; }

/* ====== 关键:为编辑器内 table 添加样式 ====== */
/* Vue2 scoped 下使用深度选择器确保样式能命中 ProseMirror 渲染的元素 */
/* 如果你的环境不识别 ::v-deep,请试试 >>> 或 /deep/ */
::v-deep .ProseMirror table {
  width: 100%;
  border-collapse: collapse;
  table-layout: auto;
  margin: 6px 0;
}

/* 单元格边框与内边距 */
::v-deep .ProseMirror th,
::v-deep .ProseMirror td {
  border: 1px solid #d1d5db; /* 灰色边框,可按需调整 #e5e7eb #cbd5e1 等 */
  padding: 8px 10px;
  vertical-align: middle;
}

/* 表头样式(可选) */
::v-deep .ProseMirror th {
  background: #f9fafb;
  font-weight: 600;
}

/* 表格在窄屏时不要换行太多(可选) */
::v-deep .ProseMirror td {
  word-break: break-word;
}

/* 如果使用 Tailwind 的 prose 可能会有默认的 table 样式,下面加个更高权重以覆盖 */
::v-deep .ProseMirror table,
::v-deep .ProseMirror th,
::v-deep .ProseMirror td {
  /* 提高优先级避免被 reset 覆盖 */
  border-color: #d1d5db;
}

/* Tiptap 表格单元格被选中时的高亮效果(与官网一致) */
::v-deep .ProseMirror .selectedCell {
  background-color: rgba(200, 200, 255, 0.4) !important;
  /* 防止 reset 覆盖 */
}

/** 下面是解决列宽拖拽调整时鼠标样式问题的 CSS */
/* 提升优先级:靠右侧 5~8px 时鼠标应变成 col-resize */
::v-deep .ProseMirror table td,
::v-deep .ProseMirror table th {
  position: relative;
}
::v-deep .ProseMirror table td::after,
::v-deep .ProseMirror table th::after {
  content: "";
  position: absolute;
  right: -3px;     /* 命中区域 */
  top: 0;
  width: 6px;
  height: 100%;
  cursor: col-resize !important;
}
</style>

(2)关键代码说明:
  • html2pdf.js 内部用 html2canvasDOM 渲染为画布,再由 jsPDF 打包成 PDFhtml2canvas 对外链图片有跨域问题,开启 useCORS 并确保图片允许跨域或使用 DataURL
  • html2canvas.scale 值越大清晰度越高,但文件越大。scale:2 常用。
  • pagebreak.mode: ['css', 'legacy']html2pdf 支持 CSS page-break/break-after 等属性。
  • 若样式不生效,可以把全局 CSS 复制到 container 中,或者在 wrapper 内添加必要的内联样式。

3,运行测试

(1)访问页面,点击“导出为 PDF”按钮后即可生成并下载 pdf 文件。

(2)打开下载下来的 pdf 文件,可以看到里面内容如下:

附:通过后端进行将内容导出为 PDF

1,实现原理

   前端把 editor.getHTML()(或带全套 head/styles 的完整 HTMLPOST 到后端 → 后端用 OpenHTMLToPDF 生成返回文档流 → 前端触发下载。

2,后端服务接口准备

    这里我们使用 SpringBoot 项目作为后台服务,其提供 /export-pdf 接口,接收 JSON { html: "...", filename?: "xxx.docx" },返回 application/pdf 二进制流。具体实现参考我们之前文章:

3,前端代码

<template>
  <div class="p-4">
    <div class="mb-3 flex gap-2">
      <button @click="exportViaServer" class="btn">服务端导出为 PDF</button>
    </div>
    <div class="editor-box border rounded">
      <editor-content :editor="editor" />
    </div>
  </div>
</template>

<script>
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { EditorContent } from '@tiptap/vue-2'
import Link from '@tiptap/extension-link'
import Image from '@tiptap/extension-image'
// 推荐:TableKit 一次引入所有 table 相关扩展
import { TableKit } from '@tiptap/extension-table'
import { TextStyleKit }   from '@tiptap/extension-text-style'
import { Color } from '@tiptap/extension-color'
import Highlight from '@tiptap/extension-highlight'

export default {
  components: { EditorContent },
  data() { return { editor: null } },
  mounted() {
    this.editor = new Editor({
      extensions: [StarterKit, Link, Image, TableKit, TextStyleKit,
        Color.configure({
            types: ['textStyle'],
        }),
        // multicolor: true 允许自定义颜色
        Highlight.configure({
          multicolor: true,
        }),
      ],
      content: `
        <p>欢迎访问<a href="https://www.hangge.com">hangge.com</a></p>
        <p><img src="https://www.hangge.com/blog/images/logo.png" alt="logo"></p>
        <p></p>
        <p>测试<strong>加粗</strong>测试<em>斜体</em>测试<u>下划线</u>测试<s>删除线</s></p>
        <p></p>
        <p><span style="color: rgb(255, 102, 255);">修改文字颜色</span></p>
        <p><span style="font-size: 22px;">调整文字大小</span></p>
        <p></p>
        <p>下面是一个3x3表格</p>
        <table>
          <thead>
            <tr><th>ID</th><th>用户名称</th><th>用户年龄</th></tr>
          </thead>
          <tbody>
            <tr><td>1</td><td>刘小龙</td><td>88</td></tr>
            <tr><td>2</td><td>张大伟</td><td>66</td></tr>
          </tbody>
        </table>
      `,
    })
  },
  methods: {
    async exportViaServer() {
      const html = this.editor.getHTML();
      console.log('html', html);

      // 内联一些样式,提升导出效果
      const style = `
        <style>
          body{ font-family: 'Arial', sans-serif; font-size:12pt; color:#111; }
          table { border-collapse: collapse; width:100%;}
          th { background: #F9FAFB; font-weight: 600; }
          td, th { border: 1px solid #ccc; }
        </style>
      `;

      const fullHtml = `<html><head><meta charset="utf-8">${style}</head>
                         <body>${html}</body>
                        </html>`;

      try {
        const resp = await fetch('http://localhost:8088/export-pdf', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ html: fullHtml, filename: 'tiptap-export.pdf' })
        });

        if (!resp.ok) {
          const txt = await resp.text();
          throw new Error('导出失败:' + txt);
        }
        const blob = await resp.blob();
        // 下载
        const url = window.URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = 'tiptap-export.pdf';
        document.body.appendChild(a);
        a.click();
        a.remove();
        window.URL.revokeObjectURL(url);
      } catch (err) {
        console.error(err);
        alert('导出失败:' + err.message);
      }
    }
  },
  beforeDestroy() { this.editor?.destroy() }
}
</script>
<style scoped>
.btn { padding:6px 10px; border-radius:4px; border:1px solid #ddd; background:#fff; cursor:pointer }
.editor-box { min-height: 220px; padding: 12px; background:#fff; }

/* ====== 关键:为编辑器内 table 添加样式 ====== */
/* Vue2 scoped 下使用深度选择器确保样式能命中 ProseMirror 渲染的元素 */
/* 如果你的环境不识别 ::v-deep,请试试 >>> 或 /deep/ */
::v-deep .ProseMirror table {
  width: 100%;
  border-collapse: collapse;
  table-layout: auto;
  margin: 6px 0;
}

/* 单元格边框与内边距 */
::v-deep .ProseMirror th,
::v-deep .ProseMirror td {
  border: 1px solid #d1d5db; /* 灰色边框,可按需调整 #e5e7eb #cbd5e1 等 */
  padding: 8px 10px;
  vertical-align: middle;
}

/* 表头样式(可选) */
::v-deep .ProseMirror th {
  background: #f9fafb;
  font-weight: 600;
}

/* 表格在窄屏时不要换行太多(可选) */
::v-deep .ProseMirror td {
  word-break: break-word;
}

/* 如果使用 Tailwind 的 prose 可能会有默认的 table 样式,下面加个更高权重以覆盖 */
::v-deep .ProseMirror table,
::v-deep .ProseMirror th,
::v-deep .ProseMirror td {
  /* 提高优先级避免被 reset 覆盖 */
  border-color: #d1d5db;
}

/* Tiptap 表格单元格被选中时的高亮效果(与官网一致) */
::v-deep .ProseMirror .selectedCell {
  background-color: rgba(200, 200, 255, 0.4) !important;
  /* 防止 reset 覆盖 */
}

/** 下面是解决列宽拖拽调整时鼠标样式问题的 CSS */
/* 提升优先级:靠右侧 5~8px 时鼠标应变成 col-resize */
::v-deep .ProseMirror table td,
::v-deep .ProseMirror table th {
  position: relative;
}
::v-deep .ProseMirror table td::after,
::v-deep .ProseMirror table th::after {
  content: "";
  position: absolute;
  right: -3px;     /* 命中区域 */
  top: 0;
  width: 6px;
  height: 100%;
  cursor: col-resize !important;
}
</style>

4,运行测试

(1)启动后台服务,访问前端页面,点击“服务端导出为 PDF”按钮后,浏览器会自动通过后端生成并下载 pdf 文件。

(2)打开下载下来的 docx 文件,可以看到文件内容如下:
评论

全部评论(0)

回到顶部