PDF 生成(5)— 内容页支持由多页面组成

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

回顾

在本篇开始之前,我们先来回顾一下上篇 PDF 生成(4)— 目录页 的内容:

  • 开头,我们通过 page.evaluate 方法为浏览器注入 JS 代码,通过这段 JS 在 PDF 内容页的开始位置(body 的第一个子元素)插入由 a 标签和对应的样式组成的目录页 DOM,从而通过 HTML 锚点实现目录项的页面跳转能力
  • 接下来,我们通过为目录页的容器元素设置break-after: page样式实现目录页自成一页的效果(和内容页分别两页)
  • 然后剩下的所有篇幅都是在讲如何生成带有准确页码的目录项
    • 首先,页码是按照锚点元素在页面中的高度 / PDF 一页的高度来计算的
    • 后来,我们通过下面三步来保证目录页中目录项对应页码的准确性
      • 规范化设计稿尺寸(按照 A4 纸对应的 2 倍图尺寸设计)
      • 通过页面缩放解决设计稿 DPI 和实际生成 PDF 时 DPI 的差异问题(彻底统一计算时 PDF 一页的像素高度)
      • 通过页面高度补充的方案解决章节标题换页引起目录项页码计算错误的问题

上篇结束之后,PDF 文件的整体框架已完全成型,包括封面、目录页、内容页和尾页四部分。但系列还没结束,接下来我们会通过本文来提升接入方的使用体验和前端代码可维护性。

开始之前,上篇给大家留了一个问题:回顾一下现在 PDF 文件内容页的生成,站在接入方的角度看,是否存在问题?假设一个场景,接入方的 PDF 文件呈现的内容量非常大,比如拥有几十甚至几百页的内容,那接入方的这个前端页面的代码该怎么维护?页面性能又该怎么保证呢?

简介

本系列的 PDF 生成(1)— 开篇、PDF 生成(2)— 生成 PDF 文件、PDF 生成(3)— 封面、尾页、PDF 生成(4)— 目录页 都是在一步步完善 PDF 文件的整体框架,包括封面、目录页、内容页和尾页四部分,截止上篇,PDF 文件的整体框架已完全成型。

本篇是站在用户角度(接入方)来进行的一次技术迭代。目的是为了解决用户前端代码的可维护性问题。

问题

问题 1:到目前为止,我们的 PDF 文件内容页是怎么生成的?

:关键代码之一 await page.goto('https://content.cn', { waitUntil: ['load', 'networkidle0'] }),也就是说,PDF 文件的内部部分都是由该链接背后的前端页面提供的。


问题 2:如果一份 PDF 文件由几十、几百页组成,其中包含几十个模块,这份 PDF 背后的前端页面的代码该怎么维护?页面性能怎么保障?

:首先,这么庞大的一个页面的前端代码,基本上是非常难维护的;至于性能问题,可以通过滚动懒加载的方案来解决,但这个优化本来是没必要的,完全是由于现有的 PDF 生成服务能力不足导致的。

分析

我们在做架构设计时,不论是一个系统,还是一个项目,亦或是一个页面甚至一个组件一个方法,都会尽量去避免模块过于复杂,导致难以维护,所以为了更好的可维护性,会尽量将内容进行拆分,比如微服务、组件化。

一个包含几十个模块的页面,不论你怎么去组件化,都避免不了这个页面的庞大,做的再极致,一个由几十个组件组成的页面都是难以维护的,而且如果不做滚动懒加载,这个页面首屏的性能会非常差。当下我们的用户就面临这样的问题,因为我们的 PDF 内容页必须是由一个前端页面构成。

所以,就在想,怎么才能让我们的用户不这么难受呢?开发 PDF 需求,就像开发普通的 Web 项目一样(这句话我们 PDF 生成(1)— 开篇 的技术选型中就提过),代码可以按照业务逻辑进行合理的划分,而不是全部模块堆叠在一个页面上。

其实,经过上面的问题和分析之后,解决方向很明确:PDF 生成服务不应该限制用户对于项目的设计和编码,所以,PDF 的内容页应该支持多页面。但怎么支持呢?

方案限制

  • puppeteer 生成 PDF 文件,只能是一个页面对应一份 PDF 文件,这是最底层的限制。page.gotopage.pdf都是针对当前页面的(浏览器的打印功能,只能打印当前渲染的页面)
  • 目录页方案的限制
    • 页面跳转能力是基于 HTML 锚点实现的,意味着相关 DOM 必须在一个页面中
    • 目录项对应的页码是通过 DOM 节点在页面中的位置(高度)来计算的,所以如果 DOM 位于不同的页面就意味着没办法计算了

这两个既是限制,也是进一步迭代的大前提。也就是说,我们现有的能力(大框架)不能动,也没办法动。PDF 内容页必须只能对应一个前端页面,至少在 puppeteer 层面是这样的

怎么做?

PDF 生成服务是基于 puppeteer 来实现的,也就是说 puppeteer 和用户之间还隔着一个 PDF 生成服务。那如果在 PDF 生成服务上增加一个胶水层呢?即 PDF 生成服务将用户提供的众多内容页合并成一个,然后将合并后的页面提供给 puppeteer。这是在现有技术架构上做加法,完全不影响现有技术方案和效果。

简单来讲就是:

  • 首先,通过 page.goto 方法依次打开用户提供的众多内容页,并拿到这些内容页的 HTML 信息
  • 然后,通过 page.gogo 打开 PDF 生成服务提供的容器页面,将上一步拿到的所有 HTML 信息都填充到该容器页中
  • 最后,通过 page.pdf 方法打印填充后的容器页得到 PDF 内容页

这方案可行,但有问题,这就遇到了整套方案中第二个难点了。

难点(问题)

问题:我们将用户提供的所有页面的 HTML 都塞到了一个页面中渲染,怎么解决可能会出现的样式和 JS 冲突?

:首先,冲突问题很有可能会出现,用户有义务保证自己的页面内部不出现冲突,但她没有义务确保不同的页面不出现冲突。解决问题的关键是沙箱,PDF 生成服务需要提供一套沙箱来确保容器页中各个页面的隔离性。

沙箱

浏览器中的沙箱包括样式沙箱和 JS 沙箱,实现沙箱方式一般有以下几种:

  • JS 沙箱
    • iframe
    • 代理,比如微前端框架 qiankun 的 JS 沙箱实现方案之一就是 Proxy
  • 样式沙箱
    • iframe
    • Web Component,通过 shadow dom 将不同页面的 HTML 和 CSS 包裹起来,以实现和外部环境的隔离
    • scoped,比如 Vue 组件中的 scoped 属性,qiankun 的样式沙箱方案之一

JS 沙箱

首先,我们不需要 JS 沙箱,因为我们获取的是已经渲染好的 HTML 页面,所以会剔除掉 script 标签(打印成 PDF 文件也用不上 JS),JS 的存在反而会带来不确定性和复杂性。

样式沙箱

iframe 最简单,但浏览器的 Web 安全策略会导致我们计算页码时存在问题,因为,跨域场景下没办法操作 iframe 中的 DOM。

Web Component,其整体实现思路是:

  • 利用 Web Component 的隔离特性作为各个页面的容器,来实现页面的样式隔离
  • 通过 JS 给目录项增加点击事件,借用 JS 的能力取到 Web Component 内的目标节点,通过 scrollIntoView 滚动到对应的位置
  • 最后,在容器页面中,拼接目录、各个页面对应的 Web Component 组件。

这套方案在浏览器场景中没有任何问题,而且也比较简单,但生成 PDF 就有问题了,因为生成 PDF 文件后,JS 的能力就丢了,之前的目录跳转是依靠原生的 HTML 锚点能力,现在有了 Web Component 的隔离,a 标签的 href 就取不到 Web Component 内部的元素了。但是,Web Component 实在是一个不错的样式沙箱方案,其实现思路如下,以后有机会可以在浏览器中使用:

/**
 * 生成 PDF 内容页
 * @param { Array<htmlElStr> } htmlElList 
 */
function generatePdfContent(htmlElList) {
  // 定义 Web Component,用来承载 PDF 内容
  class PDFContent extends HTMLElement {
    constructor() {
      super()
      this.shadow = this.attachShadow({ mode: 'open' })
    }
    connectedCallback() {
      const htmlStr = this.getAttribute('html-content')
      this.shadow.innerHTML = htmlStr
    }
  }

  customElements.define('pdf-content', PDFContent)

  // 向 页面内 添加 pdf-content 组件
  const fragment = document.createDocumentFragment()
  for (let i = 0; i < htmlElList.length; i++) {
    const pdfContentEl = document.createElement('pdf-content')
    pdfContentEl.setAttribute('html-content', htmlElList[i])
    fragment.appendChild(pdfContentEl)
  }
  document.body.appendChild(fragment)
} Ï

/**
 * 为目录设置锚点。这里的锚点跳转是通过 JS 的 scrollIntoView 来实现的
 */
function setAnchorPointForDir() {
  // 获取目录页所有的 a 标签
  const links = document.querySelectorAll('.pdf-directory__wrapper a')
  links.forEach(link => {
    // 为每个目录项添加点击事件
    link.addEventListener('click', function (e) {
      // 阻止元素的默认行为 —— a 标签的链接跳转行为
      e.preventDefault()
      // 获取被点击目录项的 href 属性,是一个 id 选择器,比如: #xx
      const targetId = link.getAttribute('href')
      // 找到页面上所有的 pdf-content 元素,这些元素是 web component
      const pdfContentComps = document.querySelectorAll('pdf-content')
      // 遍历这些 web component,从 web component 里查找对应的元素(目录上的 id 选择器),找到后将目标元素滚动到屏幕中间
      for (let i = 0, len = pdfContentComps.length; i < len; i++) {
        const targetElement = pdfContentComps[i].shadowRoot.querySelector(targetId)
        if (targetElement) {
          targetElement.scrollIntoView({ behavior: 'smooth' })
          break;
        }
      }
    })
  })
}

所以,样式沙箱,就只剩方案三 —— Scoped,这里我们借鉴 qiankun 的实验性样式隔离方案,以页面为维度,为页面中的所有样式规则增加一个特殊的选择器来限定其影响范围,因此改写后的样式会变成如下结构:

/* 原始样式 */
.app-main {
  font-size: 14px;
  color: #EFEFEF;
}

/* 改写后的样式 */
.sandbox-cae17ae7-ad3a-7269-b9a0-07da189346a7 .app-main {
  font-size: 14px;
  color: #EFEFEF;
}

到这里,整个方案分析就结束了,接下来就进入实操阶段。

实战

  • 新建第二个内容页 /fe/second-content-page.html,并制造和第一个内容页的样式冲突(body 选择器)
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>第二个内容页</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    body {
      width: 100%;
      height: 1684px;
      /* 和 exact-page-num.html 的背景色不一样,但都是样式选择器都是 body */
      background-color: green;
    }
    .anchor-wrapper1 {
      width: 100%;
      height: 1400px;
    }

    #second-content-page-anchor1 {
      color: red;
      break-before: page;
    }
    #second-content-page-anchor2 {
      color: blue;
      break-before: page;
    }
  </style>
</head>
<body>
  <div class="anchor-wrapper1">
    <h1 id="second-content-page-anchor1">第二个内容页 —— 锚点 1</h1>
  </div>
  <div class="anchor-wrapper2">
    <h1 id="second-content-page-anchor2">第二个内容页 —— 锚点 2</h1>
  </div>
</body>
</html>
  • 新建 PDF 内容页的容器页面 /fe/pdf-content.html,来承载目录和众多 PDF 内容页的 HTML + CSS
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>PDF 生成服务</title>
  <meta name="description" content="PDF 内容页的容器页面,内容全部由 PDF 生成服务中的 JS 动态添加,由 目录 和 众多 PDF 内容页组成">
</head>
<body>
</body>
</html>
  • 改动 /server/index.mjs,由于代码量太大,就不像之前一样贴详细的改动逻辑了,以主流程为主,另外为了方便演示,相关代码都放在了一个文件中,没有进一步模块化,详细代码大家可以通过 github 访问,顺便 Star 一下呗

image.png
image.png
image-20240308131357531
image.png

PDF 内容页生成过程如下,特别是最后多页面合并后的效果(目录、页面 1 和 页面 2)

Mar-03-2024 19-44-16.gif

最终的 PDF 效果如下:

image.png
image.png
image.png
image.png
image.png
image.png
image.png

总结

我们再来回顾一下本文:

  • 首先,PDF 内容页只能由一个前端页面构成,这样的限制在复杂 PDF 文件中会给接入方的前端项目带来代码可维护性问题
  • 接着,我们通过在 PDF 服务中引入胶水层,支持将多个页面黏合成一个页面,然后交给 puppeteer 来打印
  • 然后,讲了在浏览器中沙箱的实现方案,并通过样式沙箱来解决多页面黏合后出现的样式冲突问题

到目前为止,整套 PDF 生成方案基本完成了:

  • 我们通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分
  • 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑

至此,PDF 生成的能力齐了,但怎么给用户使用呢?接下来我们会再用一篇来讲 PDF 生成的服务化和配置化,这样整个方案就彻底完善了。

链接

  • PDF 生成(1)— 开篇 中讲解了 PDF 生成的技术背景、方案选型和决策,以及整个方案的技术架构图,所以后面的几篇一直都是在实现整套技术架构
  • PDF 生成(2)— 生成 PDF 文件 中我们通过 puppeteer 来生成 PDF 文件,并讲了自定义页眉、页脚的使用和其中的。本文结束之后 puppeteer 在 PDF 文件生成场景下的能力也基本到头了,所以,接下来的内容就全是基于 puppeteer 的增量开发了,也是整套架构的核心难点
  • PDF 生成(3)— 封面、尾页 通过 PDF 文件合并技术让一份 PDF 文件包含封面、内容页和尾页三部分。
  • PDF 生成(4)— 目录页 通过在内容页的开始位置动态插入 HTML 锚点、页面缩放、锚点元素高度计算、换页高度补偿等技术让 PDF 文件拥有了包含准确页码 + 页面跳转能力的目录页
  • PDF 生成(5)— 内容页支持由多页面组成 通过多页面合并技术 + 样式沙箱解决了用户在复杂 PDF 场景下前端代码维护问题,让用户的开发更自由、更符合业务逻辑
  • PDF 生成(6)— 服务化、配置化 就是本文了,本系列的最后一篇,以服务化的方式对外提供 PDF 生成能力,通过配置服务来维护接入方的信息,通过队列来做并发控制和任务分类
  • 代码仓库 欢迎 Star

当学习成为了习惯,知识也就变成了常识。 感谢各位的 关注点赞收藏评论

新视频和文章会第一时间在微信公众号发送,欢迎关注:李永宁lyn

文章已收录到 github 仓库 liyongning/blog,欢迎 Watch 和 Star。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/761533.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【echarts】如何关闭dataZoom-silder 组件中数据阴影(缩略图、数据走势图)

echarts开启 “滑动条型数据区域缩放组件&#xff08;dataZoomInside&#xff09;”后&#xff0c;默认会显示数据的走势图。 但有时候我们并不需要。 如何关闭呢&#xff1f; 官方有提供一个属性&#xff1a;showDataShadow https://echarts.apache.org/zh/option.html#da…

C++初学者指南-2.输入和输出---从输入流错误中恢复

C初学者指南-2.输入和输出—从输入流错误中恢复 文章目录 C初学者指南-2.输入和输出---从输入流错误中恢复怎么了&#xff1f;解决方案&#xff1a;出错后重置输入流 怎么了&#xff1f; 示例&#xff1a;连续输入 int main () {cout << "i? ";int i 0;cin…

vue项目创建+eslint+Prettier+git提交规范(commitizen+hooks+husk)

# 步骤 1、使用 vue-cli 创建项目 这一小节我们需要创建一个 vue3 的项目&#xff0c;而创建项目的方式依然是通过 vue-cli 进行创建。 不过这里有一点大家需要注意&#xff0c;因为我们需要使用最新的模板&#xff0c;所以请保证你的 vue-cli 的版本在 4.5.13 以上&#xff…

基于X86+FPGA+AI的芯片缺陷检测方案

应用场景 随着半导体技术的发展&#xff0c;对芯片的良率要求越来越高。然而集成电路芯片制造工艺复杂&#xff0c;其制造过程中往往产生很多缺陷&#xff0c;因此缺陷检测是集成电路制造过程中的必备工艺。 客户需求 小体积&#xff0c;低功耗 2 x USB,1 x LAN Core-i平台无…

YOLOv8 目标检测程序,依赖的库最少,使用onnxruntime推理

YOLOv8 目标检测程序&#xff0c;依赖的库最少&#xff0c;使用onnxruntime推理 flyfish 为了方便理解&#xff0c;加入了注释 """ YOLOv8 目标检测程序 Author: flyfish Date: Description: 该程序使用ONNX运行时进行YOLOv8模型的目标检测。它对输入图像进行…

AQS同步队列、条件队列源码解析

AQS详解 前言AQS几个重要的内部属性字段内部类 Node同步队列 | 阻塞队列等待队列 | 条件队列 重要方法执行链同步队列的获取、阻塞、唤醒加锁代码流程解锁 条件队列的获取、阻塞、唤醒大体流程 调用await()方法1. 将节点加入到条件队列2. 完全释放独占锁3. 等待进入阻塞队列4. …

【刷题汇总--数字统计、两个数组的交集、点击消除(栈)】

C日常刷题积累 今日刷题汇总 - day0011、数字统计1.1、题目1.2、思路1.3、程序实现 2、两个数组的交集2.1、题目2.2、思路2.3、程序实现 3、点击消除(栈)3.1、题目3.2、思路3.3、程序实现 今日刷题汇总 - day001 1、数字统计 1.1、题目 请统计某个给定范围[L, R]的所有整数中…

reactjs18 中使用@reduxjs/toolkit同步异步数据的使用

react18 中使用reduxjs/toolkit 1.安装依赖包 yarn add reduxjs/toolkit react-redux2.创建 store 根目录下面创建 store 文件夹&#xff0c;然后创建 index.js 文件。 import { configureStore } from "reduxjs/toolkit"; import { counterReducer } from "…

【机器学习】语音转文字 - FunASR 的应用与实践(speech to text)

本文将介绍 FunASR&#xff0c;一个多功能语音识别模型&#xff0c;包括其特点、使用方法以及在实际应用中的表现。我们将通过一个简单的示例来展示如何使用 FunASR 将语音转换为文字&#xff0c;并探讨其在语音识别领域的应用前景。 一、引言 随着人工智能技术的不断发展&am…

达梦数据库系列—19. 动态增加实时备库

目录 动态增加实时备库 1、数据准备 2 、配置新备库 2.1配置 dm.ini 2.2配置 dmmal.ini 2.3 配置 dmarch.ini 2.4 配置 dmwatcher.ini 2.5 启动备库 2.6 设置 OGUID 2.7 修改数据库模式 3、 动态添加 MAL 配置 4、 动态添加归档配置 5、 修改监视器 dmmonitor.ini…

软考初级网络管理员__网站单选题

1.以下关于服务器端脚本的说法中&#xff0c;正确的是()。 Script 编写 只能采用VBScript 编写 浏览器不能解释执行 由服务器发送到客户端&#xff0c;客户端负责运行 2.站点首页最常用的文件名是()。 index.html homepage.html resource.html mainfrm.html 3.在HTML…

Vatee万腾平台:引领行业变革,创新未来

在当今这个快速变化的时代&#xff0c;科技的力量正在以前所未有的速度推动着行业的变革。Vatee万腾平台&#xff0c;以其独特的视角和前瞻性的布局&#xff0c;正引领着行业变革的浪潮&#xff0c;创新着未来的发展方向。 Vatee万腾平台是一家专注于科技研发和创新应用的领军企…

面试突击:ConcurrentHashMap 源码详解

本文已收录于&#xff1a;https://github.com/danmuking/all-in-one&#xff08;持续更新&#xff09; 前言 哈喽&#xff0c;大家好&#xff0c;我是 DanMu。这篇文章想和大家聊聊 ConcurrentHashMap 相关的知识点。严格来说&#xff0c;ConcurrentHashMap 属于java.lang.cur…

【电源拓扑】PFC

为什么开关电源中都有PFC电路 PFC电路就是功率矫正电路&#xff0c;目的是为了防止杂波对电网产生冲击 AC220V通过整流桥之后电压和电流的波形分析 PFC电路为什么选择是Boost升压电路 PFC电路为什么要把电压升高到400V 为了解决输入电压低于滤波电容电压这个矛盾&#xff0…

LDM-XRNY-102溜槽堵塞开关 JOSEF约瑟 接点容量:5A/380V

工作原理 当物料在溜槽中造成堵塞时&#xff0c;堆积的物料会给溜槽侧壁一个压力&#xff0c;从而推动LDM-XRNY-102溜槽堵塞开关的活动门向外或向内推移&#xff08;根据具体设计而定&#xff09;。 当活动门偏转一个设定的角度时&#xff0c;其控制开关会动作&#xff0c;发出…

基于Python的自动化测试框架-Pytest总结-第一弹基础

Pytest总结第一弹基础 入门知识点安装pytest运行pytest测试用例发现规则执行方式命令行执行参数 配置发现规则 如何编写测试Case基础案例断言语句的使用pytest.fail() 和 Exceptions自定义断言函数异常测试测试类形式 pytest的Fixture使用Fixture入门案例使用fixture的Setup、T…

[A133]全志u-boot中的I2C驱动分析

[A133]全志u-boot中的I2C驱动分析 hongxi.zhu 2024-6-27 一、IIC标准读写时序 IIC是高位(MSB)先传输 二、代码流程 2.1主机写数据 brandy/brandy-2.0/u-boot-2018/drivers/i2c/sunxi_i2c.c static int sunxi_i2c_write(struct i2c_adapter *adap, uint8_t chip,uint32_t addr…

深入解析 androidx.databinding.BaseObservable

在现代 Android 开发中&#xff0c;数据绑定 (Data Binding) 是一个重要的技术&#xff0c;它简化了 UI 和数据之间的交互。在数据绑定框架中&#xff0c;androidx.databinding.BaseObservable 是一个关键类&#xff0c;用于实现可观察的数据模型。本文将详细介绍 BaseObservab…

Centos7安装Minio笔记

一、Minio概述 Minio是一款开源的对象存储服务器&#xff0c;可以运行在多种操作系统上&#xff0c;包括Linux、Windows和MacOS等。提供一种简单、可扩展、高可用的对象存储解决方案&#xff0c;支持多种数据格式&#xff0c;包括对象、块和文件等。Minio是一款强大、灵活、可…

基于若依(ruoyi-vue)的周报管理系统

喂wangyinlon 填报人页面 审批人 审批不通过,填报人需要重新填写.