在网页中显示 PDF (使用 AngularJS 和 PdfJS)
如何在基于 AngularJS 的应用程序中使用 PDF.js 显示 PDF 内容
引言
最近,我决定学习如何在基于 AngularJS 的 Web 应用程序中加载和显示 PDF。我这样做没有特别的原因,只是想了解如何实现。不过,曾经有一个机会可以在一个实际项目中进行这样的集成,但有人把它从我手中“抢走”了。无论如何,像我这样勤奋的人永远不会放过学习的机会。如果这样的机会引起我的兴趣,我会花时间去学习。在我成功地完成集成后,我发现它非常简单,但又极其强大。本教程将向您展示如何设置基于 Spring Boot 的 Web 应用程序,将 PDF.js 集成到简单的基于 AngularJS 的应用程序中,然后加载 PDF 文件并在页面上显示它。
本教程将首先概述架构,例如后端代码的结构和前端 AngularJS 应用程序的设计。然后,教程将讨论如何构建此示例应用程序并用于测试运行。最后,我将解释如何将其与安全性集成,或者可以使用这种集成设计哪些潜在应用程序。
整体架构
与过去的教程相比,这一个相当简单。它有一个后端服务器应用程序,用于向前端应用程序提供 PDF 文件。服务器应用程序还提供运行前端应用程序的页面。它使用 Spring Boot 编写。我使用了最新版本(我编写示例应用程序时是 3.0.5)。除了提供静态网页,此应用程序还可以通过 REST API 调用来提供 PDF 文件内容。我将向您展示如何实现这一点。这部分很简单。难点在于将 PDF.js 集成到 AngularJS 应用程序中。
有很多关于如何将 PDF.js 添加到基于 JavaScript 的 Web 应用程序的好教程。它们都使用相同的方法。其中一些是高级的,展示了内容缩放和翻页的方法。本教程将不展示内容缩放,但会展示如何进行翻页。它还将解释如何初始化 PDF.js 组件。这部分是最难理解的,也是最值得解释的。
我将从 Maven POM 文件开始。它使用 Java 17 和最新的 Spring Boot。接下来,是 RESTFul 控制器和提供 PDF 文件内容的那个方法。废话不多说,让我们开始教程。
Maven POM 文件
我将从 Maven POM 文件开始讨论。我之所以这样做,是因为我已将示例应用程序从 2.7.x 升级到了新版本 3.0.5。
首先,我为 Maven 执行过程定义了以下属性
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
前两行将源代码和目标 Java 运行时版本设置为 Java 17。第三行将源文件编码设置为 UTF-8。Maven 构建将相应地处理文件。
接下来,我将 Spring Boot 父级依赖项定义为版本 3.0.5
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.5</version>
</parent>
对于实际的项目依赖项,我包含最新版本的 commons-io
。这个库可以非常轻松地将一个数据流复制到另一个数据流。这是我经常使用的东西
<dependencies>
...
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
我还必须将 Spring Boot 应用程序的打包插件也更改为 3.0.5
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>3.0.5</version>
</plugin>
</plugins>
</build>
与我过去做的教程相比,这些是我对这个新项目的 POM 文件所做的所有新更改。
发送 PDF 数据的服务器代码
我们快到最有趣的部分了,请再耐心等待一会儿。在本节中,我想展示当客户端发出请求时,PDF 文件是如何发送到客户端的。这很简单
package org.hanbo.boot.rest.controllers;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import org.apache.commons.io.IOUtils;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletResponse;
@RestController
public class PdfLoadController
{
@RequestMapping(value = "/loadPdf", method = RequestMethod.GET,
produces = MediaType.APPLICATION_PDF_VALUE)
public void loadPdf(HttpServletResponse response) throws IOException
{
File pdfFile = new File("<Project base folder full path>/test1.pdf");
if (pdfFile.exists())
{
long fileSize = pdfFile.length();
response.setContentLengthLong(fileSize);
response.setContentType("application/pdf");
FileInputStream fs = null;
try
{
fs = new FileInputStream(pdfFile);
IOUtils.copy(fs, response.getOutputStream());
response.flushBuffer();
}
finally
{
if (fs != null)
{
fs.close();
}
}
}
}
}
上面的代码片段并不难。首先,我创建一个 File
对象。参数值指定文件的完整路径。然后代码检查文件是否存在。如果文件存在,它会将整个文件加载到 response
对象中,并设置响应内容长度和内容类型。当方法完成其操作后,响应将被发送到客户端。我使用了 IOUtils.copy()
来简化加载文件输入流的操作,然后将数据转储到响应输出流。看,一行代码就搞定了,这就是为什么我需要 commons-io
库。它让我的生活更轻松。
现在我们准备好进入本教程最有趣的部分了,将 PDF.js 组件集成到基于 AngularJS 的 Web 应用程序中。准备好了吗?快,去下一节。
与 PDF.js 集成
将 PDF.js 集成到我的示例应用程序就像制作披萨一样。这是您将看到美味披萨如何制作的部分。如我们所知,制作披萨是一个已知过程。披萨是如何制作的决定了披萨的好坏。在开始处理示例项目之前,我做了一些研究。我使用的链接是以下链接
Mozilla org 提供了本教程。它非常全面且易于理解。我在此处包含它供您参考。您应该阅读它,它非常有帮助。我采用了本教程中的示例代码,并进行了修改,以便可以轻松地将其添加到我的示例应用程序中。它可以轻松地(我希望)移动到任何类似的 AngularJS 应用程序中。
让我向您展示加载和显示 PDF 内容的服务类的设计。这是它
export class PdfLoadService {
constructor () {
console.log("initialize PDF JS");
this._pdfjslib = window['pdfjs-dist/build/pdf'];
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://mozilla.github.io/pdf.js/build/pdf.worker.js';
}
loadPdf(url) {
return this._pdfjslib.getDocument(url).promise;
}
getPdfPage(pdf, pageNum) {
if (pdf) {
return pdf.getPage(pageNum);
} else {
return null;
}
}
renderPage(pdfPage, canvas) {
if (pdfPage && canvas) {
pdfPage.then(function (page) {
let scale = 1.0;
let viewport = page.getViewport({ scale: scale });
let context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
let renderContext = {
canvasContext: context,
viewport: viewport
};
page.render(renderContext).promise.then(function () {
console.log("Rendering complete...");
});
});
}
}
}
这不像我过去写过的 JavaScript 类。有几个地方很难理解。第一个是构造函数
constructor () {
console.log("initialize PDF JS");
this._pdfjslib = window['pdfjs-dist/build/pdf'];
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://mozilla.github.io/pdf.js/build/pdf.worker.js';
}
粗体和带下划线的行是我认为最难理解的,当然是我的观点。当我第一次看到这一行时,我花了一段时间才弄清楚它的作用。要理解它,请想象单词 window
是一个对象(实际上它就是一个对象)。对于 JavaScript 对象,您可以通过两种方式访问其属性。第一种是使用点运算符访问属性。另一种是将对象视为哈希映射,并使用键来访问与之关联的属性值。高亮显示的行使用第二种方法来获取值。键是 'pdfjs-dist/build/pdf'
。这是 JavaScript 调试器中该属性的截图
高亮显示的行将属性 'pdfjs-dist/build/pdf' 的值赋给 PdfLoadService
类的实例属性 this._pdfjslib
。该类的所有方法都可以使用它。下一行分配了 worker 的源代码。这个我也从教程中复制的。我猜由于 PDF.js 库广泛使用 promises
,worker 源可能是创建单独线程、进行加载/渲染,然后通知主线程工作已完成的代码。这是我的猜测,官方教程并没有说明它的作用。我猜开发这个库的团队并不关心是否有人真的想知道这一行是做什么的。只要一个人能正确地从他们的教程中复制这一行并使库正常工作,就可以了。
下一个方法接受 PDF 的 URL,使用 _pdfjslib
对象加载它,并向调用者返回一个 promise
。当其他对象使用此 PdfLoadService
对象加载和渲染 PDF 文件时,这将是第一个被调用的方法。
loadPdf(url) {
return this._pdfjslib.getDocument(url).promise;
}
下一个方法可以从 PDF 内容中提取特定页面,它接受两个参数,一个是由前一个方法加载的 PDF 数据对象。另一个参数是代表页码的数字值。注意,PDF 内容页从 1 开始,而不是 0。最后一个页面索引将与总页数相同。
getPdfPage(pdf, pageNum) {
if (pdf) {
return pdf.getPage(pageNum);
} else {
return null;
}
}
此方法也返回一个 promise
。promise
包含页面信息和内容数据。一旦它们可用,就可以通过最后一个方法渲染页面。
最后一个方法比其他两个更复杂。它接受两个参数。第一个是 PDF 页面信息和内容数据,第二个是对页面上 canvas
对象的引用。整个方法看起来像这样
renderPage(pdfPage, canvas) {
if (pdfPage && canvas) {
pdfPage.then(function (page) {
let scale = 1.0;
let viewport = page.getViewport({ scale: scale });
let context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
let renderContext = {
canvasContext: context,
viewport: viewport
};
page.render(renderContext).promise.then(function () {
console.log("Rendering complete...");
});
});
}
}
这个服务类只是整个故事的一半。它提供了加载和渲染 PDF 内容的功能。但它不是自己完成的。必须有其他东西使用这个服务类来行使这些功能。而这个东西就是 AppController
。示例应用程序是一个单页应用程序。它显示一个 Bootstrap 面板,其中包含 PDF 内容。这是 AppController
的完整源代码
export class AppController {
constructor($rootScope, $scope, $timeout, pdfLoadService) {
this._rootScope = $rootScope;
this._scope = $scope;
this._pdfLoadService = pdfLoadService;
this._errorMsg = "";
this._pdfData = null;
this._pagesCount = 0;
this._currPageIdx = 0;
this._scope.$watch("vm.pdfData", this.pdfDataReady);
this.initializePdfFile();
}
get pdfData() {
return this._pdfData;
}
set pdfData(val) {
this._pdfData = val;
}
get currPageIndex () {
return this._currPageIdx;
}
set currPageIndex (val) {
this._currPageIdx = val;
}
get pagesCount () {
return this._pagesCount;
}
set pagesCount (val) {
this._pagesCount = val;
}
pdfDataReady = function(newVal, oldVal, scope) {
if (oldVal == null && newVal != null) {
// a new pdf document is available;
console.log("A new pdf document is available;");
if (scope && scope.vm) {
let origin = scope.vm;
origin._currPageIdx = 1;
origin.displayPdfPage(origin._currPageIdx);
}
} else if (oldVal != null && newVal == null) {
// unloading an existing pdf file.
console.log("Unloading an existing pdf file.");
if (scope && scope.vm) {
scope.vm._currPageIdx = 0;
scope.vm._pagesCount = 0;
}
}
}
initializePdfFile() {
let self = this;
self._pdfLoadService.loadPdf("/loadPdf")
.then (function (pdf) {
if (pdf && pdf._pdfInfo) {
self._pdfData = pdf;
self._pagesCount = pdf._pdfInfo.numPages;
self._scope.$apply();
}
}, function (error) {
if (error) {
console.log(error);
}
self.errorMsg = "Unable to load PDF file for display.";
self._scope.$apply();
});
}
prevousPage = function() {
if (this._pagesCount > 0) {
if (this._currPageIdx > 1) {
this._currPageIdx--;
} else {
this._currPageIdx = 1;
}
this.displayPdfPage(this._currPageIdx);
}
}
nextPage = function () {
if (this._pagesCount > 0) {
if (this._currPageIdx < this._pagesCount) {
this._currPageIdx++;
} else {
this._currPageIdx = this._pagesCount;
}
this.displayPdfPage(this._currPageIdx);
}
}
displayPdfPage = function(pageIdx) {
let pdfPage = this._pdfLoadService.getPdfPage(this.pdfData, pageIdx);
if (pdfPage) {
let canvas = angular.element("#pdfViewArea");
if (canvas && canvas.length > 0) {
let canvasElem = canvas[0];
this._pdfLoadService.renderPage(pdfPage, canvasElem);
}
}
}
}
这个类比我上面展示的服务类要复杂一些。在我深入研究这个类的细节之前,让我解释一下页面是如何工作的。页面加载后,它将尝试通过调用后端 API 来加载 PDF。一旦 PDF 数据成功加载,第一页将在面板中显示。页面底部有两个带有箭头符号的按钮,可用于翻阅 PDF 内容的页面。按钮之间显示页码和总页数。现在,是时候深入研究上面的代码了。首先,我们从 constructor
开始
constructor($rootScope, $scope, $timeout, pdfLoadService) {
this._rootScope = $rootScope;
this._scope = $scope;
this._pdfLoadService = pdfLoadService;
this._errorMsg = "";
this._pdfData = null;
this._pagesCount = 0;
this._currPageIdx = 0;
this._scope.$watch("vm.pdfData", this.pdfDataReady);
this.initializePdfFile();
}
顶部是实例属性的初始化。一旦这些属性被赋值了初始值,它们就可以在同一对象中的任何地方被引用。最后两行是设置一个观察者(watcher),以及加载 PDF 内容。
我为什么需要一个观察者?这是一个很好的问题。我使用我的服务类的方式与 Mozilla org 的教程不同。一旦我加载了 PDF 内容,我希望将 PDF 数据对象存储起来,这样我就可以按需加载页面并进行渲染。让我们看一下 initializePdfFile()
方法。此方法负责加载 PDF 内容
initializePdfFile() {
let self = this;
self._pdfLoadService.loadPdf("/loadPdf")
.then (function (pdf) {
if (pdf && pdf._pdfInfo) {
self._pdfData = pdf;
self._pagesCount = pdf._pdfInfo.numPages;
self._scope.$apply();
}
}, function (error) {
if (error) {
console.log(error);
}
self.errorMsg = "Unable to load PDF file for display.";
self._scope.$apply();
});
}
如您所见,我使用了我的 _pdfLoadService
来加载 PDF 内容数据。一旦 promise
返回,我将 PDF 数据传递给实例属性 self._pdfData
。这样,它就可以在同一对象中的任何地方使用。您可能还注意到我使用了 self._scope.$apply();
。这个调用通常不是必需的,因为当当前作用域中的 AngularJS 模型值发生更改时,框架会自动调用它。然而,加载是通过 PDF.js 代码完成的,并且“promise
”会将执行从当前的 AngularJS 作用域中移出,因此 $apply()
不会自动调用。
一旦 PDF 数据成功加载,我就需要显示它。如您在 initializePdfFile()
方法中看到的,没有代码来渲染 PDF 内容的第一页。这是我的意图。如果网页想要清除当前的 PDF 数据并加载新的数据怎么办?当这种情况发生时,我可以使用类似的方法加载新的 PDF 内容,然后通知控制器数据已准备好。这就是我需要一个观察者的原因。这是观察者订阅该方法的实现
pdfDataReady = function(newVal, oldVal, scope) {
if (oldVal == null && newVal != null) {
// a new pdf document is available;
console.log("A new pdf document is available;");
if (scope && scope.vm) {
let origin = scope.vm;
origin._currPageIdx = 1;
origin.displayPdfPage(origin._currPageIdx);
}
} else if (oldVal != null && newVal == null) {
// unloading an existing pdf file.
console.log("Unloading an existing pdf file.");
if (scope && scope.vm) {prevousPage = function() {
if (this._pagesCount > 0) {
if (this._currPageIdx > 1) {
this._currPageIdx--;
} else {
this._currPageIdx = 1;
}
this.displayPdfPage(this._currPageIdx);
}
}
scope.vm._currPageIdx = 0;
scope.vm._pagesCount = 0;
}
}
}
这个方法有点难理解。它接受三个参数:新值、旧值和值发生变化的作用域。我需要所有三个。我使用前两个来比较,看看 this._pdfData
的值是否从无值变为有效的 PDF 内容,或者反之亦然,然后执行相应的操作。如果新值是有效的 PDF 内容,我需要将页码设置为 1(从 1 开始,而不是 0),并渲染第一页。如果新值变为 null
,这意味着以前有一些 PDF 内容,现在没有了。在这种情况下,我需要将页码重置为 0,总页数重置为 0
。这是我将观察者订阅到此方法的方式
this._scope.$watch("vm.pdfData", this.pdfDataReady);
这是 AppController
中渲染 PDF 内容到页面的方法
displayPdfPage = function(pageIdx) {
let pdfPage = this._pdfLoadService.getPdfPage(this.pdfData, pageIdx);
if (pdfPage) {
let canvas = angular.element("#pdfViewArea");
if (canvas && canvas.length > 0) {
let canvasElem = canvas[0];
this._pdfLoadService.renderPage(pdfPage, canvasElem);
}
}
}
此方法使用 service
对象获取由参数 pageIdx
指定的页面。调用将返回一个 PDF 页面数据对象(不是 promise
)。一旦代码确定 PDF 页面数据对象有效,它将获取 HTML canvas
元素的引用,并使用 service
对象以及 PDF 页面数据对象来渲染页面以供显示。
如您所见,我已将 PDF 的加载、PDF 页面的提取和页面的渲染操作分解为三个松散耦合的操作。我可以在同一控制器中的任何地方使用它们。我已经通过观察者配置进行了演示。一旦 PDF 数据可用,观察者就会收到通知并进行渲染。整个设计非常整洁。通过这种方式,我可以轻松实现翻页功能。这是实现上一页翻页的方法
prevousPage = function() {
if (this._pagesCount > 0) {
if (this._currPageIdx > 1) {
this._currPageIdx--;
} else {
this._currPageIdx = 1;
}
this.displayPdfPage(this._currPageIdx);
}
}
正如您所见,操作非常简单,当前页码由 AppController
的对象跟踪。加载 PDF 内容数据后,控制器也知道总页数。导航实际上是当前页码的减法,并进行一些检查以确保新值是否有效。翻到下一页也是如此。区别在于增加页码而不是减少页码
nextPage = function () {
if (this._pagesCount > 0) {
if (this._currPageIdx < this._pagesCount) {
this._currPageIdx++;
} else {
this._currPageIdx = this._pagesCount;
}
this.displayPdfPage(this._currPageIdx);
}
}
就是这样!这个示例应用程序所有最重要的部分。其余的源代码,您可以自己阅读。它在教程左侧的导航部分提供。HTML 页面标记与我之前许多教程中的内容相似。它们不是新的。我不想花费时间重复自己。在过去两年里,我已经做了很多次了。让我们转到下一节,我将在其中解释如何构建和运行示例应用程序。
如何构建和运行示例应用程序
下载源代码后,请将所有 *.sj 文件重命名为 *.js 文件。我重命名它们是为了方便将我的教程内容通过电子邮件发送到 codeproject.com。如果您想让示例应用程序正常工作,则需要重命名这些文件。
我准备了一个示例 PDF 文件用于在示例应用程序中进行演示。但是,此 PDF 文件的文件路径是一个虚拟值,请提供一个可访问的完整文件路径,否则您将无法加载 PDF 文件。演示也将失败。这可以在 Java 文件 PdfLoadController.java 中完成。
要构建项目,请打开终端或 cmd.exe 窗口,然后 cd 到看到 pom.xml 文件的文件夹。然后运行以下命令
mvn clean install
项目成功构建后,在同一文件夹中运行以下命令来启动应用程序
java -jar target/hanbo-angularjs-pdf-display-sample-1.0.1.jar
如果之前的准备工作正确完成,示例应用程序应该可以成功启动。您可以打开浏览器,然后导航到以下 URL
https://:8080/
如果一切正常,您应该会看到以下页面
底部应该有两个按钮,分别位于两侧。如果您单击它们,您应该能够翻到上一页或下一页。如果已无更多页面,则当前页面将再次渲染。
如果您右键单击 canvas,上下文菜单中有一个选项可以下载它。它保存为 PNG 图像文件(至少在我的 Linux 桌面上的行为是这样的)。这意味着渲染的 canvas
对象实际上是一个图像。
就是这样。在下一节中,我将讨论如何将集成应用于更复杂的 Web 应用程序。我还会透露我可以使用 PDF.js 库的潜在应用程序。
摘要
这是一个有趣的教程。之所以有趣,是因为我在工作中学习了一些东西。我意识到 PDF.js 是一个非常有用的工具。它可以给我带来一些新的机会。在进入之前,我想讨论一些 PDF.js 的高级用法。想象一下这种情况:后端提供的 PDF 内容是一个受保护的资源,要正确处理,获取 PDF 内容的 HTTP 请求必须包含授权信息。否则,请求将遇到 401 错误。如您所见,PDF.js 库本身处理对后端服务器的请求。可能无法将与安全相关的数据注入请求。此外,PDF.js 可能使用 HTTP GET 从后端获取 PDF 内容,但有时您可能希望通过其他 HTTP 方法提供内容。那么 PDF.js 将无法获取 PDF 内容。如果可以将 PDF 内容作为二进制数据流提供并将其编码为 BASE64 字符串,将其发送到前端,则可以避免所有这些问题,PDF.js 可以轻松使用它。这还可以防止心怀不轨的人轻易地将 PDF 内容作为文件获取。即使一个人获取了数据流,他们也必须解码并将其保存为 PDF 文件,这需要额外的两个步骤。实施这样的机制可能会阻止一些人轻易获取 PDF 内容。但话说回来,这种机制并非 100% 万无一失。它不会阻止所有心怀不轨的人利用您的内容数据。
那么我从学习中获得了什么?PDF.js 可以轻松地集成到 Web 应用程序中,用于显示收据、平面图蓝图、精美的报告和演示文稿等内容。这些对我来说都不重要。有一个我最感兴趣的特定用例。有很多大型中文网站发布长篇连载小说。这些网站使用大量机制来阻止用户将小说内容复制粘贴到其他网站。即便如此,我发现使用开发者工具提取这些网站的内容相当容易。通过本教程,我可以设计一个采用新方法编写的此类网站,作者可以提交 PDF 文件格式的内容。当它被提供时,它会显示为图像文件,并且我可以禁用 canvas
、页面的右键单击功能。这将使版权侵权者难以得逞。他们必须打开开发者工具,找到图像,下载它,然后使用某种扫描应用程序来提取文本。也许有一天,我会开始着手开发这个想法的 MVP 项目。总之,我希望本教程对您来说有趣且有益。感谢您的阅读,祝您好运。
历史
- 2023 年 4 月 16 日 - 初稿