如何使用JavaScript构建PDF故事板生成工具
PDF文件常被用来共享演示文稿、脚本、报告、教学材料、漫画页以及其他视觉文档。但若要逐页查看长度较长的PDF文件,将会耗费大量时间,尤其是当人们需要了解所有场景、幻灯片或视觉内容的整体顺序时。 故事板提供了一种更为有条理的方式来查看这些信息。用户无需逐一打开每一页,而是可以将选中的PDF页面整理成一张包含多个面板的单页。每个面板可以显示页码、标题以及原始页面的可视化预览图。 在本教程中,我们将学习如何使用JavaScript、PDF.js和PDF-lib来构建一个基于浏览器的PDF故事板生成工具。 该应用程序会从现有的PDF文件中提取页面内容,并将它们整理成可供打印的故事板页面。用户可以选择不
PDF文件常被用来共享演示文稿、脚本、报告、教学材料、漫画页以及其他视觉文档。但若要逐页查看长度较长的PDF文件,将会耗费大量时间,尤其是当人们需要了解所有场景、幻灯片或视觉内容的整体顺序时。
故事板提供了一种更为有条理的方式来查看这些信息。用户无需逐一打开每一页,而是可以将选中的PDF页面整理成一张包含多个面板的单页。每个面板可以显示页码、标题以及原始页面的可视化预览图。
在本教程中,我们将学习如何使用JavaScript、PDF.js和PDF-lib来构建一个基于浏览器的PDF故事板生成工具。
该应用程序会从现有的PDF文件中提取页面内容,并将它们整理成可供打印的故事板页面。用户可以选择不同的布局方式,挑选想要包含的页面,添加标题,自定义外观,预览结果,然后下载完成后的故事板文件。
这个应用程序直接在浏览器中运行,上传的PDF文件会在本地进行处理,而不会被上传到网站的服务器上。
我们将逐步探讨整个实现过程,包括PDF文件的渲染、页面的选择、标题的处理、布局的计算、预览图的生成以及文件的导出等环节。
我们将会涵盖的内容:
- 什么是PDF故事板生成工具?
- 该应用程序的主要功能
- 了解基于浏览器的操作流程
- 项目设置
- 所使用的库
- 准备HTML工作区
- 选择主要的DOM元素
- 定义重要的配置参数
- 了解应用程序的状态
- 上传PDF文件
- 使用PDF.js加载PDF文件
- 测量PDF页面的尺寸
- 在画布上渲染PDF页面
- 重复使用已渲染的页面预览图
- 从PDF页面中读取文本
- 自动填充空标题
- 更新预览图
- 选择故事板的布局方式
- 控制行数、列数和纸张大小
- 选择PDF页面及其排列顺序
- 为面板添加标签
- 绘制故事板页面
- 更新实时预览图
- 调整标题的外观
- 在故事板页面之间切换
- 使用OCR技术读取扫描后的页面内容
- 生成最终的故事板PDF文件
- 下载PNG格式的故事板页面
- 下载单页图片
- 性能与需要注意的事项
- 演示:PDF故事板生成工具
- 总结
什么是PDF故事板生成器?
PDF故事板生成器可以将现有PDF文件中选定的页面转换成结构化的可视化视图。
例如,假设你有一份包含20张演示幻灯片的PDF文件。你可以不必分别分享这20张幻灯片,而是将它们整理成一个包含三列两行的故事板。
每个面板都会显示原始PDF页面的缩略版本。你还可以为这些面板添加编号或标签,并在下方撰写简短的描述。
这种方法在以下场景中也非常有用:
- 审阅演示幻灯片。
- 整理漫画页码。
- 规划视频片段的结构。
- 编写视觉脚本。
- 查看扫描后的文档。
- 制作可打印的教学材料。
- 为创意项目安排页面顺序。
该工具并不会创建新的画面或绘制插图,它只是将现有的PDF页面按照故事板的形式进行排列。
该应用程序的主要功能
在开始编写代码之前,我们先来了解一下即将实现的功能吧。
1. PDF文件上传
用户可以从自己的设备中选择PDF文件,或者将其拖放到上传区域。
2. 多种故事板布局方式
该应用程序支持三种布局方式:
- 网格布局:将页面按行列排列。
- 带注释的面板布局:在页面面板旁边提供空间用于书写注释。
- 电影胶片式布局:以类似电影胶片的样式排列页面。
3. 页面选择与排序
用户可以自行决定哪些PDF页面应该出现在故事板中,也可以自定义页面的排列顺序,而不必遵循原始PDF文件中的顺序。
4. 添加字幕
用户可以手动输入字幕内容,或者利用从PDF页面中提取的文本来填充空白字幕区域;此外,还可以使用OCR技术从扫描后的文档中读取文字并添加为字幕。
5. 自定义设置
用户可以自定义纸张大小、方向、列数与行数、面板标签的颜色、页面背景色、字幕字体、文本大小以及面板边框的样式。
6>实时预览功能
该应用程序能够生成故事板页面的预览效果,用户可以在下载最终结果之前先查看排版效果。
7>多种导出格式
用户可以将自己的作品导出为以下几种格式:
- PDF格式的故事板文件。
- PNG格式的单页图片文件。
- 包含所有页面图片的ZIP压缩文件。
在可能的情况下,PDF格式的导出会保留原始页面内容的矢量格式,从而确保文字和图形的清晰度不受影响。
了解基于浏览器的使用流程
该应用程序的操作流程非常简单明了。
- 用户将PDF文件选中或放入上传区域。
- PDF.js会加载该文档并显示其页面内容。
- 应用程序会展示页面预览界面以及标题输入框。
- 用户可以选择特定页面、调整页面顺序,并为这些页面添加标题。
- 用户还可以配置故事板的布局与外观样式。
- JavaScript会计算出每个面板的精确位置和尺寸。
- 预览界面会根据用户的设置进行实时更新。
- PDF-lib库以及浏览器的画布功能会被用来生成最终的输出结果。
- 浏览器会下载完成后的文件。
PDF上传工作区
面板与标题
设置与预览界面
最终效果预览
已下载的PDF文件
项目设置
在编写JavaScript代码之前,我们首先需要了解这个应用程序的整体结构。
PDF故事板生成工具结合了HTML、CSS和JavaScript三种技术。HTML负责构建用户界面,CSS用于控制工作区及故事板页面的外观样式,而JavaScript则负责处理PDF文件的读取、页面预览功能的实现、标题的添加、布局的计算以及文件的下载操作。
对于一个独立的开发项目来说,文件的组织结构可以如下所示:
pdf-storyboard-generator/
├── index.html
├── style.css
├── script.js
└── assets/
index.html文件中包含了整个应用程序的结构,其中包含了上传区域、页面面板、标题输入框、设置选项以及预览功能等相关内容。
style.css文件用于控制应用程序的外观。它定义了工作区、面板布局、表单控件、按钮、预览区域以及响应式布局。
script.js文件包含了应用程序的主要逻辑功能。它负责加载PDF文档、渲染页面内容、管理字幕信息、处理OCR识别请求、计算故事板的布局,并生成可下载的文件。
assets目录可用于存放项目中所需的任何其他本地资源。
在现有的AllInOneTools实现中,这些组件被集成到了网站的工具页面中,而不是作为四个独立的文件来部署。不过,职责划分的原则仍然适用。
所使用的库
该应用程序在其PDF处理及图像处理流程中使用了三个主要的JavaScript库或浏览器技术。
1. PDF.js
我们将使用PDF.js来打开PDF文档,并在浏览器中渲染其页面内容。
PDF.js允许JavaScript访问单个页面,获取它们的尺寸信息,创建视图窗口,并将页面内容绘制到HTML画布上。
对于这个项目而言,PDF.js负责生成原始PDF页面的预览效果。
应用程序通过PDF.js的文档API来获取页面内容,并按照所需的尺寸进行渲染。这一点非常重要,因为用户在下载最终成果之前,需要先看到每页内容的可视化展示。
2. PDF-lib
我们在生成最终的PDF文档时会使用PDF-lib。
PDF.js和PDF-lib各自承担不同的职责:PDF.js负责读取并渲染源文档,而PDF-lib则用于创建新的故事板PDF文件。
应用程序利用PDF-lib来生成输出页面,在可能的情况下嵌入原始PDF页面的内容,并将最终完成的故事板元素放置到这些输出页面上。
保留原始PDF页面的矢量格式有助于在用户放大查看或打印文档时,保证文本和线条的质量。对于那些无法通过常规方式嵌入的受密码保护的PDF文件,该实现还提供了基于光栅技术的备用方案。
3. Tesseract.js
这个库提供了可选的光学字符识别功能。
并不是所有的PDF文件都包含可选择的文本内容。例如,一份扫描文档中可能只含有打印页面的图片,而并没有实际的文字数据。
当用户选择使用OCR功能时,应用程序会加载Tesseract.js及其英文语言数据,然后对符合条件的扫描页面进行文字提取处理,以便生成字幕。
OCR功能是可选的,因此如果用户只是想手动排列PDF页面并编写字幕,就不必加载OCR引擎。
以下示例展示了如何通过CDN脚本将这些库纳入到一个独立的项目中:
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.4.168/pdf.min.js"></script>
<script src="https://unpkg.com/pdf-lib/dist/pdf-lib.min.js"></script>
<script src="script.js"></script>
为什么这些库能够协同工作
准备HTML工作空间
<section id="aiot-sb">
<div id="aiot-sb-drop">
<input
type="file"
id="aiot-sb-file-input"
accept="application/pdf,.pdf"
>
<button type="button" data-act="browse">
选择PDF文件
</button>
</div>
<div id="aiot-sb-grid"></div>
以上只是一个示例结构,实际的应用程序还会包含更多的控制元素、无障碍访问功能以及用于不同操作的组件。
aiot-sb这个容器为整个应用程序提供了一个统一的根节点。JavaScript可以通过这个根节点来直接找到所需的界面元素,而无需在整个网站中进行搜索。
选择主要的DOM元素
在能够响应用户操作之前,JavaScript必须先获取到相应的界面元素的引用。
应用程序首先会找到自身的根容器,然后检查初始化是否已经完成:
var root = document.getElementById("aiot-sb");
if (!root || root.getAttribute("data-aiot-sb-ready")) {
return;
}
root.setAttribute("data-aiot-sb-ready", "1");
这样就可以防止脚本被多次执行时导致应用程序重复初始化。
在实际开发中,会使用一个小辅助函数来帮助查找应用程序内部的元素:
function q(selector) {
return root.querySelector-selector);
}
例如,q('[data-act="browse"]')这个代码就可以找到用于打开PDF文件选择器的按钮。使用针对特定平台的辅助工具,也有助于保持应用程序代码的整洁性,尤其是当网站还包含其他工具或界面组件时。
定义重要的配置参数
应用程序需要一些通用的配置参数,以便用于布局设置和输出格式的调整。
例如,这些参数用于定义支持的纸张尺寸和字体系列:
var MM = 72 / 25.4;
var PAPERS = {
a4: [595.28, 841.89],
letter: [612, 792],
a3: [841.89, 1190.55],
slide: [405, 720]
};
var PAPER NAMES = {
a4: "A4",
letter: "Letter",
a3: "A3",
slide: "16:9 slide"
};
PDF页面的尺寸通常以“点”为单位表示,而某些故事板布局设置用毫米来表达更为直观。MM这个转换值有助于在这两种单位之间进行换算。
PAPERS对象存储了所有可用的纸张尺寸。应用程序在计算每张故事板页面的大小时可以使用这些数值。
PAPER NAMES对象为相应的配置参数提供了易于理解的标签。
应用程序还定义了用于标题及其他文本的字体系列:
var FONTS = {
sans: "Arial, 'Helvetica Neue', Helvetica, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
mono: "'Courier New', Courier, monospace",
hand: "'Segoe Print', 'Bradley Hand', cursive"
};
这些配置使得故事板渲染器能够使用不同的文本样式,而无需在代码中重复定义相同的字体。
理解应用程序的状态
应用程序需要记录当前加载的PDF文件的信息,包括其页面内容、标题文字、所选的布局方式以及导出进程的相关信息。
实现方案并没有将这些信息分散存储在各个变量中,而是将它们集中保存在一个共享的状态对象中。
例如,应用程序会跟踪以下这些信息:
- 当前加载的PDF文档。
- PDF文件的原始字节数据。
- 当前的文件名。
- 页面上的标题文字。
- 页面预览所需的数据。
- 文件的加载与导出状态。
- 当OCR功能处于激活状态时,相关进程的信息。
- 用于取消操作或继续加载的标识符。
实际开发中,这些数值会被存储在应用程序的状态对象中,并在整个渲染和导出流程中被反复使用。
当用户更改了标题文字或布局设置时,这一点尤为重要。应用程序需要知道哪些参数发生了变化,这样才能及时更新故事板预览界面,而无需重新打开原始文档。
上传PDF文件
第一步是让用户能够从设备中选择PDF文件。PDF Storyboard Generator支持两种上传方式:通过文件选择器选取文件,或者将文件拖放到上传区域。
文件输入部分使用了以下HTML代码:
<input
type="file"
id="aiot-sb-file-input"
accept="application/pdf,.pdf"
>
让我们来了解一下accept属性的含义:
application/pdf表示PDF文件的MIME类型。
.pdf则是PDF文件的扩展名。
MIME类型用于标识文件的格式。浏览器会利用MIME类型来判断文件所包含的内容类型。例如,application/pdf就表示该文件是PDF文档。
应用程序会同时检查文件的MIME类型和扩展名。这样做很有必要,因为有些浏览器或操作系统可能会为选中的文件提供错误的MIME类型信息。
实际开发中使用了以下辅助函数:
function isPdf(file) {
return file.type === 'application/pdf' ||
/\.pdf$/i.test(file.name || '');
}
第一个条件用于检查文件的MIME类型;第二个条件则判断文件名是否以.pdf结尾。i标志使得扩展名的匹配不区分大小写,因此像document.PDF这样的文件名也能被识别。
经过验证后,两种上传方式都会使用同一个handleFiles()函数。该函数的初始步骤如下:
function handleFiles(list, password) {
var all = Array.prototype.slice.call(list || []);
ui.input.value =('');
if (!all.length) {
return Promise.resolve();
}
var file = all.filter(isPdf)[0];
if (!file) {
toast("这不是PDF文件。请选择一个PDF文件!", 'error');
return Promise.resolve();
}
var note = all.length > 1
? ' 只打开了' + file.name + ';故事板需要使用一个PDF文件。'
: '';
var token = ++state.loadToken;
var hadDoc = !!state.doc;
var task, bytes;
if (hadDoc) {
ui.fileMeta.textContent = '正在打开' + file.name + '…';
}
setStatus('正在打开' + file.name + '…', true);
// 接下来,该函数会加载并处理选中的PDF文件。
}
这个函数会将传入的文件列表转换成数组,然后从中选取第一个有效的PDF文件。如果没有找到有效的PDF文件,它就会显示错误信息并停止操作。
loadToken的值有助于应用程序区分当前正在进行的加载操作与之前的操作。当用户在选择新的文件时,如果之前的文件仍在处理中,这个值就显得非常重要。
接下来,应用程序会等待PDF相关库准备好,然后读取选中的文件:
return engine().then(function () {
return file.arrayBuffer();
}).then(function (buf) {
bytes = buf;
var opts = {
data: new Uint8Array(buf.slice(0))
};
if (password) {
opts.password = password;
}
task = window.pdfjsLib.getDocument(pdfjsOptions(opts));
return task.promise;
});
file.arrayBuffer()方法会将PDF文件读取到浏览器内存中。应用程序会将这些数据转换为Uint8Array类型,然后通过getDocument()方法将其传递给PDF.js。
如果用户提供了密码,那么该密码会在文档被打开之前被添加到PDF.js的配置选项中。
后续的处理流程包括测量PDF文件的各页尺寸、判断这些页面是否可以以矢量格式进行显示、更新应用程序的状态,以及最终展示处理结果。
这意味着所选定的PDF文件是在浏览器内部被处理的,并不会被上传到网站的服务器上。
使用PDF.js加载PDF文件
一旦用户选择了某个文件,应用程序就会读取该文件的内容,并将其数据传递给PDF.js。
PDF.js在加载完成后会返回一个文档对象,这个对象允许我们访问文档的页面内容及元数据。
return engine().then(function () {
return file.arrayBuffer();
}).then(function (buf) {
bytes = buf;
var opts = {
data: new Uint8Array(buf.slice(0))
};
if (password) {
opts.password = password;
}
task = window.pdfjsLib.getDocument(pdfjsOptions(opts));
return task.promise;
});
engine()函数会确保在开始处理之前所需的PDF相关库已经成功加载。之后,应用程序会使用file.arrayBuffer()方法将选定的PDF文件读取到浏览器内存中。
Uint8Array构造函数会将文件数据转换为PDF.js能够处理的格式。如果用户提供了密码,那么这个密码也会被添加到PDF.js的配置选项中。
getDocument()方法会启动PDF文件的加载过程,而task.promise会在PDF.js成功读取文档内容后得到解决。
最终得到的文档对象允许我们访问PDF文件的页面内容及元数据。应用程序会保存这个文档,以便后续进行诸如测量页面尺寸、提取文本、生成预览图或制作故事板等操作。
测量PDF文件的页面尺寸
在将PDF文件的页面排列到故事板中之前,应用程序首先需要知道这些页面的具体尺寸。
PDF文件中可能包含大小和方向各不相同的页面,因此测量每页的尺寸对于确保页面能够正确地显示在故事板中是非常重要的。
async function pageFacts(doc, token) {
var sizes = [];
for (var n = 1; n <= doc.numPages; n++) {
if (token !== state.loadToken) {
return null;
}
if (n % 25 === 0) {
-status(
'正在测量第‘ + n + ‘页,共‘ + doc.numPages + ‘页…',
true
);
await nextFrame();
}
var p = await doc.getPage(n);
var vp = p.getViewport({ scale: 1 });
sizes.push([vp.width, vp.height]);
}
return sizes;
}
该函数首先创建一个空的sizes数组,然后通过doc.numPages遍历所有页面,并使用doc.getPage(n)获取每一页的内容。
getViewport({ scale: 1 })方法会以原始比例测量页面的尺寸,该函数会将得到的宽度和高度存储在sizes数组中。
token参数有助于防止过时的加载操作继续进行。如果当前的token与state.loadToken不再匹配,函数就会停止执行并返回null。
对于较大的PDF文件,应用程序会每读取25页后更新加载状态,并调用nextFrame(),以便浏览器有机会更新界面显示内容。
最后,该函数会返回一个包含所有页面尺寸信息的数组,这些数据后续会被用于故事板布局的计算中。
在画布上渲染PDF页面
接下来的任务是在工作区中显示这些PDF页面。
PDF.js可以将单个PDF页面渲染到HTML画布上,这样就可以在页面卡片或故事板预览中看到这些页面的可视化效果。
核心的渲染函数如下所示:
async function renderPage(n, w, h) {
var page = await state.doc.getPage(n);
var b = page.getViewport({ scale: 1 });
var vp = page.getViewport({
scale: Math.min(w / b.width, h / b.height)
});
var c = document.createElement('canvas');
c.width = Math.max(1, Math.round(vp.width));
c.height = Math.max(1, Math.round(vp.height));
var g = c.getContext('2d');
g.fillStyle = '#FFFFFF';
g.fillRect(0, 0, c.width, c.height);
await page.render({
canvas: c,
canvasContext: g,
viewport: vp
}).promise;
return c;
}
该函数接受三个参数:n表示PDF页面的编号,w和h则分别代表预览时可用的宽度和高度。
首先,state.doc.getPage(n)会获取请求的PDF页面内容,然后创建一个比例为1的视口,以获得该页面的原始尺寸信息。
接下来,通过计算Math.min(w / b.width, h / b.height)来确定一个合适的比例值,使得页面能够在给定的空间内保持原有的比例关系。
函数会创建一个HTML画布,并根据计算得到的视口尺寸设置其宽度和高度,然后在画布上填充白色背景,最后再渲染PDF页面的内容。
最后,page.render()>方法会将页面内容绘制到画布上,等待渲染完成后,返回处理完成的画布对象,供后续的页面预览或故事板展示使用。

重用已渲染的页面预览
重复渲染相同的PDF页面会消耗不必要的处理时间。例如,某页可能首先被用于生成卡片预览图,然后在故事板预览更新时再次被渲染。
该应用程序使用“页面图像缓存”机制来重用之前已经渲染好的结果。
var picCache = new Map();
// 在进行预览时:如果已经存在尺寸至少与所需相同的缓存图像,就直接使用它。
async function cachedPic(n, w, h) {
var got = picCache.get(n);
if (got && got.w >= w * 0.92) {
picCache.delete(n);
picCache.set(n, got);
return got.c;
}
var c = await renderPage(
n,
Math.ceil(w * 1.15),
Math.ceil(h * 1.15)
);
picCache.set(n, { c: c, w: c.width });
while (picCache.size > 60) {
var old = picCache.keys().next().value;
picCache.get(old).c.width = 1;
picCache.delete(old);
}
return c;
}
该应用程序将已渲染的页面预览结果存储在一个名为`picCache`的JavaScript `Map`对象中。每个条目中都包含了渲染后的图像以及其宽度信息。
当调用`cachedPic()`函数时,它会首先检查是否已经存在所需页面的缓存图像。如果缓存的图像宽度至少达到了请求宽度的92%,应用程序就会直接使用该缓存图像,而不会重新渲染该页面。
此外,这个函数还会将被重用的缓存条目移动到`Map`对象的开头位置,这样就可以确保最近使用过的预览结果能够被快速访问。
如果找不到合适的缓存图像,该函数就会调用`renderPage()`函数来创建新的渲染结果。此时,页面会以请求宽度与高度的1.15倍来进行渲染,从而为预览提供更高的分辨率。
为了控制内存使用量,这个缓存系统最多只能存储60个条目。当存储数量超过这个限制时,系统会删除最旧的条目,并将其对应的图像宽度设置为1,以此释放相关资源。
这种缓存机制能够有效避免不必要的PDF页面渲染操作,同时还能有效控制内存占用量,尤其是在用户处理较长文档时更为有用。
从PDF页面中读取文本
故事板生成器可以通过三种方式为页面添加字幕:
- 用户可以手动输入字幕内容。
- 应用程序可以直接从PDF文件中提取文本。
- 如果文档是扫描生成的,OCR技术也可以用来识别文字。
对于那些包含可选中文本的PDF文件,PDF.js提供了`getTextContent()`方法来帮助提取文本。
在实际应用中,我们会使用`readTexts()`函数来从加载到的PDF文件的每一页中提取并存储文本。
async function readTexts(token) {
var doc = state.doc, out = [];
for (var n = 1; n <= doc.numPages; n++) {
if (token !== state.loadToken) {
return;
}
try {
var tc = await (await doc.getPage(n)).getTextContent();
out.push(tc.items.map(function (it) {
return (it.str || '') + (it.hasEOL ? '\n' : '');
}).join(''));
} catch (e) {
out.push('');
}
if (n % 20 === 0) {
await nextFrame();
}
}
if (token !== state.loadToken) {
return;
}
state.texts = out;
refreshTags(board());
}
该函数首先通过state.doc访问已加载的PDF文档,然后创建一个名为out
接下来,它会遍历每一页,并调用getTextContent()来获取其中的文本内容。通过map()方法,这些文本会被合并成一条字符串;当PDF.js中的某个元素通过hasEOL指示某行结束时,该函数还会在字符串末尾添加换行符。
如果某页的文本提取失败,函数会向结果数组中添加一个空字符串,而不会停止整个处理过程。
通过token检查机制,可以防止之前进行的PDF加载操作影响当前的应用程序状态。此外,每处理20页后,该函数会调用nextFrame(),让浏览器有机会更新界面显示内容。
在处理完所有页面后,提取出的文本会被存储在state.texts中。随后,应用程序会调用refreshTags(board())来更新工作区中显示的页面信息。
这些存储下来的文本会在用户选择自动填充空白标题时被使用。
自动填充空白标题
一旦应用程序从PDF页面中提取出文本,就可以利用这些文本来生成标题。
该程序的重要特点是:它只会填充那些为空的标题字段,而不会覆盖用户已经输入的标题内容。
应用程序会使用captionFromText()辅助函数对提取出的文本进行清洗和简化处理:
function captionFromText(t) {
var s = String(t || '')
.replace(/\s+/g, ' ')
.trim();
if (letters(s) < 3) {
return '';
}
if (s.length <= 200) {
return s;
}
var cut = s.slice(0, 200);
var end = Math.max(
cut.lastIndexOf('. '),
cut.lastIndexOf('? '),
cut.lastIndexOf('! '),
cut.lastIndexOf('। ')
);
return end > 60
? cut.slice(0, end + 1)
: cut.replace(/\s+\S*$/, '') + '…';
}
这个函数的具体功能如下:
- 将传入的值转换为字符串。
- 将重复的空白字符替换为单个空格,并删除文本开头和结尾的空白字符。
- 检查该文本是否包含足够的字母,以便将其用作标题。
- 如果文本长度不超过200个字符,就直接返回整个文本;
- 对于较长的文本,会尝试在句子结尾处截断标题。如果没有合适的截断点,就会删除未完成的最后一个单词,并添加省略号。
之后,应用程序会使用fillFromText()函数将生成的标题应用到选中的故事板页面上。function fillFromText() {
var bd = board(), list = bd.error ? [] : unique(bd.list), filled = 0, scans = 0;
list.forEach(function (n) {
if (captionFor(n)) { return; }
var c = captionFromText(state.texts[n - 1]);
if (c) {
state.captions[n] = c;
filled++;
var ta = ui.grid.querySelector('[data-cap="' + n + '"]');
if (ta) { ta.value = c; }
} else if (isScan(n)) {
scans++;
}
});
if (!state.opts.captions && filled) {
state opts.captions = true;
savePrefs();
}
redrawSoon(0);
if (!list.length) {
toast(bd.error || '棋盘上没有页面内容。,' 'error');
return;
}
toast(
filled
? plural(filled, '标题', '标题们') +
+ ' 已从页面文本中提取出来。请在下方的卡片中对其进行编辑。' +
(scans
? ' ' + plural(scans, '已扫描的页面有', '已扫描的页面们有') +
+ ' 没有文本内容;请使用OCR技术进行识别。' +
: '')
: scans
? '这些空白页面都是没有文本内容的已扫描页面。请使用带有OCR功能的阅读工具进行处理。'
: '所有的面板都已经配备了标题。'
);
}
fillFromText()函数首先调用board()来获取当前选中的故事板页面。它使用unique()方法来确保不会重复处理相同的页面。
对于每一个被选中的页面,captionFor(n)会检查该页面是否已经存在标题。如果存在,则函数会跳过这个页面,从而避免覆盖用户已有的标题。
如果页面没有标题,函数会从state.texts中提取相关文本,并将其传递给captionFromText()进行处理。当提取到有用的文本后,函数会将生成的标题保存到state.captions中,并更新页面卡片中对应的文本区域。
如果某个页面没有可提取的文本,且被识别为需要扫描的页面,函数会增加扫描页面的计数器。用户可以使用OCR技术对这些页面进行单独处理。
最后,应用程序会根据需要为这些页面添加标题,保存更新后的设置,并安排预览内容的刷新。同时,它还会显示一条消息,说明有多少标题已经生成完成,或者是否还需要使用OCR技术。
这样,用户就可以自动生成标题,同时保留自己手动输入的描述内容。
更新预览内容
每当标题或相关设置发生变化时,都需要刷新故事板预览。
应用程序会使用一个定时器来避免在每次设置变更后立即重新绘制预览:
function redrawSoon(ms) {
clearTimeout(drawTimer);
drawTimer = setTimeout(
drawPreview,
ms === undefined ? 120 : ms
);
}
redrawSoon()函数首先会使用clearTimeout()取消之前安排的预览更新任务,然后再使用setTimeout()重新安排drawPreview()的执行。
默认情况下,应用程序会在120毫秒后刷新预览内容。如果在这段时间内又有新的设置变更发生,之前的刷新计划就会被取消,系统会重新安排新的更新时间。
这种机制有助于避免用户在输入标题或调整设置时导致不必要的重复渲染。
绘制更新后的预览
一旦重新绘制的任务被安排好,drawPreview()函数就会根据当前的故事板配置来更新可视化的预览界面。
以下是应用程序中实际的实现代码:
async function drawPreview() {
if (!state.doc) { return; }
var my = ++drawToken, bd = board();
refreshTags(bd);
syncPanel();
if (bd.error) {
ui.boardMsg.textContent = bd.error;
ui.sheetPos.textContent = '没有表格';
uisheetCount.textContent = '';
root.querySelectorAll('[data-needs-board]').forEach(function (b) {
b.disabled = true;
});
return;
}
root.querySelectorAll('[data-needs-board']).forEach(function (b) {
b.disabled = state.busy;
});
var L = bd.L, o = state.opts;
state.sheet = clamp(state-sheet, 0, L.sheets - 1);
ui.boardMsg.textContent = L.tight
? '标题文字占用的空间太小,建议减少标题行数、减少行数或调整字体大小。'
: '';
}
ui_sheetPos.textContent = '第 ' + (state.sheet + 1) + ' 页,共 ' + L.sheets + ' 页';
uisheetCount.textContent =
plural(bd.list.length, '面板', '面板们') + ' 在 ' +
plural(L.sheets, '页', '页们') + ', ' +
PAPER NAMES[o.paper] + ' ' + o.orient;
q('[data-act="sheet-prev"]').disabled = state-sheet <= 0;
q('[data-act="sheet-next"]').disabled = state.sheet >= L.sheets - 1;
ui_sheetWrap.style.aspectRatio = L.W + ' / ' + L.H;
var cssW = ui.sheetWrap.clientWidth || 600;
var dpr = Math.min(window.devicePixelRatio || 1, 2);
var k = cssW * dpr / L.W;
var off = document.createElement('canvas');
off.width = Math.round(L.W * k);
off.height = Math.round(L.H * k);
var done = await drawSheet(
off.getContext('2d'),
o,
bd.list,
L,
state.sheet,
k,
'full',
function (n, w, h) {
return my === drawToken
? cachedPic(n, w, h)
: Promise.resolve(null);
}
);
if (!done || my !== drawToken) { return; }
ui-sheet.width = off.width;
ui.sheet.height = off.height;
ui_sheet.getContext('2d').drawImage(off, 0, 0);
off.width = 1;
var start = statesheet * L.per;
var on = bd.list.slice(start, start + L.per);
ui-sheet.setAttribute(
'aria-label',
'第 ' + (state.sheet + 1) + ' 页,共 ' + L.sheets + ' 页:' +
(on.length === 1 ? '第 ' + on[0] : '第 ' + on.join(', ') +
);
ui-sheet.setAttribute('data-drawn', String(statesheet + 1));
}
该函数首先会检查是否已加载PDF文档,随后调用board()函数来获取选中的页面并计算故事板的布局。
drawToken这个值有助于防止旧的预览渲染操作覆盖新的渲染结果。此外,该函数还会调用refreshTags()和syncPanel()来更新页面信息及设置面板的内容。
接下来,它会检查所选的故事板是否存在任何错误;如果选择有效,就会计算当前页码的位置,并更新页码计数器、面板数量、纸张信息以及“上一页”和“下一页”按钮。
然后,该函数会根据可用的预览宽度及设备的像素比例来计算画布的缩放比例。为了限制渲染工作量,设备的像素比例被设定为不超过2。
drawSheet()函数会将选中的故事板页面绘制到临时画布上;它会接收cachedPic()作为回调参数,这样就可以重复使用之前已经渲染好的PDF页面预览图。
绘制完成后,该函数会检查当前操作是否仍在继续进行中;如果仍在继续,就会将临时画布上的内容复制到可见的预览画布上。
最后,通过将临时画布的宽度设置为1来释放它所占用的资源。同时,应用程序还会更新预览界面的辅助说明文字,并记录哪些页面已经被绘制出来。
这样的处理方式使得用户能够在调整标题或设置后无需每次都下载新的PDF文件,就能看到故事板的变化情况。
选择故事板布局
该工具支持三种布局样式:网格布局、带注释的面板布局以及电影胶片条布局。每种布局都是为不同类型的项目设计的。
网格布局会将PDF页面按行和列进行排列,这种布局非常适合用于查看演示文稿幻灯片、漫画页或文档内容。
带注释的面板布局在每页下方提供了额外的书写空间,因此非常适用于制作讲座笔记、进行规划或复习视觉材料。
电影胶片条布局会将页面排列成一系列连续的帧,这种布局非常适合用于规划视频内容、分镜或场景设计。
用户选择的布局样式会保存在应用程序的状态中。当用户更换布局样式时,应用程序会自动调整列数和行数,以提供合适的默认设置。
onField()函数中的以下代码片段用于处理布局变更:
if (f === 'layout' && e.type === 'change') {
// 为每种布局样式设置相应的面板数量
if (o.layout === 'notes') {
o.cols = o.orient === 'landscape' ? 2 : 1;
o.rows = o.orient === 'landscape' ? 3 : 4;
}
if (o.layout === 'strip') {
ocols = 4;
o.rows = o.orient === 'landscape' ? 2 : 4;
}
if (o.layout === 'grid') {
o.cols = o.orient === 'landscape' ? 3 : 2;
o_rows = o.orient === 'landscape' ? 2 : 3;
}
}
savePrefs();
if (f === 'cols' || f === 'rows' || f === 'layout') {
state-sheet = 0;
}
redrawSoon(f === 'gap' ? 60 : 0);
这段代码会检测用户选择的布局类型,并根据所选布局及纸张方向来调整默认的列数和行数。
例如:
- 带注释的面板布局: 在横向模式下使用2列3行,在纵向模式下使用1列4行。
- 长条式布局: 横向模式下使用4列2行,纵向模式下使用4列4行。
- 网格布局: 横向模式下使用3列2行,纵向模式下使用2列3行。
更新这些设置后,savePrefs()函数会保存这些配置。当布局类型发生变化时,应用程序会将当前页面重置为第一页,并调用redrawSoon()函数来刷新预览界面。
用户仍然可以通过设置面板属性来手动调整列数和行数。
控制行数、列数及纸张尺寸
用户可以为每页选择1到6列或1到6行,这样就可以自行决定每页上显示多少页PDF内容。
该应用程序还支持多种纸张格式,包括A4纸、Letter纸、A3纸以及16:9比例的幻灯片格式。所有这些格式都可以以纵向或横向方式展示。
例如,3列2行的布局可以在一页上显示6页PDF内容,而4列3行的布局则可以显示12页。
应用程序会通过将用户选择的列数乘以行数来计算每页能容纳多少个面板,进而确定生成所有选定页面需要多少张纸。
实际应用程序中使用的以下函数会根据用户选择的纸张格式和方向来计算页面尺寸:
function sheetSize(o) {
var p = PAPERS[o.paper] || PAPERS.a4;
var a = Math.min(p[0], p[1]);
var b = Math.max(p[0], p[1]);
return o.orient === 'portrait' ? [a, b] : [b, a];
}
PAPERS对象中存储了所有支持的纸张格式的尺寸信息。该函数会获取用户选择的纸张规格,并根据所选的方向来调整这些尺寸。
在纵向模式下,较短的边长会成为宽度,较长的边长会成为高度;而在横向模式下,这两个数值的角色就会互换。
接下来,布局计算机制会根据用户选择的列数和行数来确定每页上能放置多少个面板:
var per = o.cols * o.rows;
var sheets = Math.max(1, Math.ceil(list.length / per));
在这里,per表示每页上的面板数量。Math.ceil()函数会向上取整,从而确保所需的纸张数量是整数,这样多余的页面就可以被安排在额外的纸张上。
布局引擎还会计算出可用的空间,并将各个面板放置在适当的位置,以确保所有内容都能适应所选纸张的尺寸限制。当选中的页面数量超过了单张纸张的容纳范围时,剩余的页面将会被分摊到其他纸张上。
选择PDF页面及其排序顺序
用户并不总是需要原始PDF中的所有页面。
该工具支持使用全部页面、选择特定页码范围,或输入自定义的页面顺序。
例如,用户可以输入如下内容:
1-4, 9, 6
这样就会按照“1、2、3、4、9、6”这个确切顺序来生成故事板。
应用程序会使用parseList()函数来处理用户输入的页面选择信息,并检查所指定的页码是否有效。
以下是该函数在应用程序中的实际实现代码:
function parseList(text) {
var t = String(text || '').trim();
if (!t) {
return { error: '请输入有效的页面编号,例如1-4、7、5。' };
}
var out = [], parts = t.split(/[,;\s]+/).filter(Boolean);
for (var i = 0; i < parts.length; i++) {
var part = parts[i],
m = /^(\d+)(?:\s*[-–]\s*(\d*))?$/.exec(part);
if (!m) {
return { error: '" + part + "'不是有效的页面编号或范围。' };
}
var a = parseInt(m[1], 10),
b = m[2] === undefined
? a
: (m[2] === '' ? state.count : parseInt(m[2], 10));
if (a < 1) {
return { error: '页面编号从1开始,因此'" + part + "'无效。' };
}
if (b < a) {
return { error: '" + part + "'的顺序错误。您是想输入' + b + '-' + a + '?'吗?' };
}
if (a > state.count) {
return {
error: '页面编号' + a + '超出了文档的总页数。该PDF文件一共有' +
plural(state.count, '页') + '页。'
};
}
if (b > state.count) {
return {
error: '页面编号' + b + '超出了文档的总页数。请输入' + a +
'-来查看最后一页。'
};
}
for (var n = a; n <= b; n++) {
out.push(n);
}
}
if (out.length > 500) {
return { error: '选中的页面数量超过了500页。请重新输入较小的数值。' };
}
return { pages: out };
}
该函数首先将用户输入的内容转换为去除多余字符后的字符串,然后根据逗号、分号或空格来分割这些页面编号。
正则表达式用于判断每个输入项是单个页码还是一个页码范围。例如,“3”表示第3页,而“2-5”则表示第2页到第5页。该函数还支持类似4-这样的开放式范围。在这种情况下,应用程序会使用state.count作为最终的页码值。
在将页面添加到结果中之前,该程序会检查页码是否从1开始、顺序是否为升序排列,以及所请求的页面数量是否没有超过PDF文件中的总页数。
该应用程序还将自定义选择的页面数量限制为500页。如果输入有效,该函数就会按照用户输入的顺序返回一个页码数组。
panels()函数在构建故事板时会使用这个结果。如果用户选择所有页面而不是输入自定义列表,那么它就会按原始顺序返回所有的页面。
当用户只需要从选中的场景或重要幻灯片中创建故事板,而不需要包含整个PDF文件时,这一功能非常有用。
添加面板标签
面板标签可以帮助用户在查看或讨论故事板时识别各个页面。
该应用程序支持多种标签样式,包括数字标签、场景名称标签、原始PDF页码标签,以及不使用标签的选项。
例如,一个故事板可以显示如下内容:
- 1, 2, 3, 4
- 场景1,场景2,场景3
- 第5页,第6页,第7页
用户可以在“面板标签设置”中选择自己喜欢的标签样式。
该应用程序会使用labelFor()函数为每个面板生成相应的标签:
function labelFor(o, idx, n) {
if (o.label === 'panel') {
return String(idx + 1);
}
if (o.label === 'page') {
return '第' + n + '页';
}
if (o.label === 'scene') {
return '场景' + (idx + 1);
}
return '';
}
这个函数接受三个参数:
o包含故事板的设置信息,其中包括用户选择的标签样式。
idx表示所选页面在故事板序列中的位置,其编号从0开始。
n表示原始PDF文件中的页码。
当用户选择“面板编号”作为标签样式时,idx + 1会生成如“1”、“2”、“3”这样的标签。
当用户选择“页码”作为标签样式时,该函数会使用n来显示原始PDF文件中的页码。这对于那些故事板中包含按自定义顺序排列的页面来说非常有用。
当用户选择“场景名称”作为标签样式时,该函数会生成如“场景1”、“场景2”、“场景3”这样的标签;如果用户选择“不使用标签”,则该函数会返回一个空字符串。
在绘制故事板各面板时,绘图功能会使用这些生成的标签信息,这样用户就能轻松识别各个面板,而不会改变原始PDF文件中的页码顺序。
绘制故事板页面
一旦布局计算完成,该应用程序就会在HTML画布上绘制故事板页面。
在绘制过程中,每个被选中的PDF页面都会被放置在其对应的面板区域内,然后会根据用户的设置添加面板标签和说明文字。
在实际应用中,该过程是使用异步执行的`drawSheet()`函数来完成的。以下代码片段展示了该函数是如何准备表格并选择属于该表格的页面的:async function drawSheet(ctx, o, list, L, sheetIdx, k, mode, getPic) {
var C = COLORS[o.colors] || COLORS.paper;
var W = L.W * k;
var H = L.H * k;
var token = state.loadToken;
var drawn = [];
ctx.save();
ctx.fillStyle = C.bg;
ctx.fillRect(0, 0, W, H);
ctx.textBaseline = 'alphabetic';
var start = sheetIdx * L.per;
var items = list.slice(start, start + L.per);
该函数接收画布上下文、故事板设置、选中的页面列表、计算出的布局信息、页码以及渲染比例。
首先,它会选择对应的页面颜色并绘制背景。变量start用于确定当前页面的第一个面板位置,而items则包含了分配给该页面的所有PDF页面。
for (var i = 0; i < items.length; i++) {
var cell = L.cells[i], n = items[i], idx = start + i;
var area = cell.img;
var strip = o.layout === 'strip';
if (strip) {
ctx.fillStyle = C.bandMat;
ctx.fillRect(
area.x * k, area.y * k,
area.w * k, area.h * k
);
} else if (o.frame) {
ctx.fillStyle = C.mat;
ctx.fillRect(
area.x * k, area.y * k,
area.w * k, area.h * k
);
}
var inset = strip ? 0 :
o.frame ? Math.min(area.w, area.h) * 0.04 : 0;
var pr = fitPage(n, area, inset);
var px = {
x: Math.round(pr.x * k),
y: Math.round(pr.y * k),
w: Math.max(1, Math.round(pr.w * k)),
h: Math.max(1, Math.round(pr.h * k))
};
drawn.push({ n: n, rect: pr });
if (mode === 'overlay') {
ctx.clearRect(px.x, px.y, px.w, px.h);
} else {
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(px.x, px.y, px.w, px.h);
var pic = getPic ? await getPic(n, px.w, px.h) : null;
if (token !== state.loadToken) {
ctx.restore();
return null;
}
if (pic) {
ctx.drawImage(pic, px.x, px.y, px.w, px.h);
}
}
}
函数fitPage()会计算出使原始PDF页面在面板中保持原有比例所需的位置和尺寸。
随后,应用程序会使用getPic()来获取渲染后的页面图像。如果图像已经准备就绪,drawImage()就会将其显示在故事板画布上。
此外,该函数还支持“覆盖”模式——在这种模式下,页面图像区域会被设置为透明状态。这种功能在导出PDF文件时非常有用,因为这样就可以保留原始页面的内容。
绘制面板标签和标题
在确定好页面预览的位置后,应用程序会继续绘制面板的标签和标题内容。
drawSheet()函数中的以下代码片段展示了面板标签和说明文字是如何在画布上被绘制出来的:var t = cell.text;
var fs = cell.fs * k;
var lh = cell.lh * k;
var y = t.y * k + fs;
var label = labelFor(o, idx, n);
ctx.textAlign = 'left';
if (label) {
ctx.font = '700 ' + fs + 'px ' + FONTS.mono;
ctx.fillStyle = C.ink;
ctx.fillText(label, t.x * k, y);
y += lh * 1.05;
}
var lines = [];
if (o.captions && captionFor(n)) {
ctx.font = '400 ' + fs + 'px ' + FONTS[o.font];
ctx.fillStyle = C.ink;
var maxL = cell.notes
? Math.max(1, Math.floor(
(t.y * k + t.h * k - y + lh - fs) / lh
))
: o_lines;
lines = wrap(
ctx,
captionFor(n),
t.w * k,
Math.min(maxL, cell.notes ? 40 : o_lines)
);
lines.forEach(function (ln, j) {
ctx.fillText(ln, t.x * k, y + j * lh);
});
}
labelFor()函数用于生成所选面板的标签,然后应用程序使用fillText()方法将这个标签绘制到画布上。
如果启用了标题显示且存在相应的标题,应用程序会使用captionFor()函数来获取该标题内容。wrap()函数则会将标题分割成多行,以便使其能够适应面板中的可用空间。
对于那些具有注释布局的面板,应用程序还会在标题区域下方绘制横线,从而为用户提供更多的书写空间。
整个绘制过程会将原始PDF页面预览内容与标签、标题、边框以及其他用户选定的设置结合起来,最终生成完整的故事板页面。
更新实时预览
实时预览功能是非常实用的。用户可以随时更改布局、纸张大小、方向、标题内容、字体样式、间距等设置,而无需每次都重新生成新的PDF文件。
应用程序会使用board()函数来计算当前的故事板配置信息,然后再绘制预览图。
以下是该函数的实现代码:
function board() {
var sel = panels();
if (sel.error) {
return { error: sel.error };
}
var L = layout(state.opts, sel.pages);
return {
list: sel_pages,
L: L
};
}
首先,该函数会调用panels()来获取用户选中的PDF页面。如果选中过程中出现了错误,它就会直接返回这个错误信息,而不会继续执行后续操作。
接下来,它会使用当前的故事板设置和选中的页面内容,调用layout()>函数来计算这些页面应该如何排列在故事板页面上。
最后,board()>函数会返回一个对象,其中包含用户选中的页面列表以及计算出的布局信息。drawPreview()函数则会利用这些信息,在临时画布上渲染出选中的页面内容。
当绘图完成后,结果会被复制到可视的预览画布上。应用程序还会更新页码显示信息,例如“第1页,共3页”,并根据当前浏览位置启用或禁用“上一页”和“下一页”按钮。
这样用户就可以在不同的故事板页面之间切换,并在下载最终结果之前查看自己所做的修改。
调整标题显示效果
标题有助于解释每页上展示的内容。
应用程序提供了启用标题、选择字体、更改文字大小以及控制标题行数的选项。
用户还可以选择是否显示面板边框和页码。
这些设置会被保存在stateopts中。syncPanel()函数会更新相应的控件设置,使其与当前的应用程序状态保持一致。
以下是具体的实现代码:
function syncPanel() {
var o = state.opts;
['layout', 'cols', 'rows', 'paper', 'orient', 'label',
'lines', 'tsize', 'font', 'colors'].forEach(function (f) {
q('[data-field="' + f + '"]').value = String(o[f]);
});
q('[data-field="captions"]').checked = o.captions;
q('[data-field="frame"]').checked = o.frame;
q('[data-field="numbers"]').checked = o.numbers;
q('[data-field="gap"]').value = String(o.gap);
q '[data-out="gap-val"]').textContent = o.gap + ' 毫米';
q('[data-field="pages"]').value = state.pages;
if (document.activeElement !== q('[data-field="title"]')) {
q('[data-field="title"]').value = state.title;
}
var strip = o.layout === 'strip';
q '[data-out="cols-label"]').textContent =
strip ? '每个条带中的帧数' : '列数';
q('[data-out="rows-label"]').textContent =
strip ? '每页中的条带数' : '行数';
q('[data-field="lines"]').disabled =
!o.captions || o.layout === 'notes';
q('[data-field="frame"]').disabled = strip;
ui.listWrap.hidden = state.pages !== 'list';
}
该函数会从stateopts中读取当前的设置选项,并更新相应的控件。例如,它会设置选中的字体、文字大小、标题行数以及纸张设置。
对于特定布局类型,该函数还会进行相应的处理。在“电影条带”布局模式下,列数和行数的标签会分别显示为“每个条带中的帧数”和“每页中的条带数”;当关闭标题显示功能或选择“带有注释的面板”布局时,标题行数的控件也会被禁用。
将标题适配到面板内部
较长的标题需要适应面板内可用的显示空间。应用程序会使用wrap()辅助函数,根据画布的可用宽度将文本分割成多行。
以下是实际绘图代码中如何使用该辅助函数的示例:
if (o.captions && captionFor(n)) {
ctx.font = '400 ' + fs + 'px ' + FONTS[o.font];
ctx.fillStyle = C.ink;
var maxL = cell.notes
? Math.max(1, Math.floor(
(t.y * k + t.h * k - y + lh - fs) / lh
))
: o.lines;
lines = wrap(
ctx,
captionFor(n),
t.w * k,
Math.min(maxL, cell.notes ? 40 : o_lines)
);
lines.forEach(function (ln, j) {
ctx.fillText(ln, t.x * k, y + j * lh);
});
}
该应用程序通过FONTS[o.font]来选择字体,并根据面板的布局计算出可显示的标题行数。
对于常规布局,所选的o.lines设置决定了最多能显示多少行。而对于带有注释的面板,可用的空间会决定能够展示多少行内容。
wrap()函数会将标题文本分割成适合当前可用宽度的若干行,然后fillText()方法会将这些行绘制到画布上。
当所选的标题显示设置无法在当前空间内完全展示时,预览界面会提示用户减少标题行数、减少行数或调整文本大小。
该应用程序还支持为面板添加边框以及设置页码。这些选项会被保存在应用程序的状态中,在绘制故事板时会根据这些设置进行相应的处理。
这样一来,用户就可以在不修改原始PDF文件的情况下,自由控制故事板中标题文字的外观与可读性。
在故事板各页之间切换
较大的PDF文件可能包含多页内容。
该应用程序提供了“上一页”和“下一页”按钮,用户可以逐一查看每一页。同时,程序还会显示当前所在的页码以及总页数。
用户选择的页面编号会被保存在state.sheet中,应用程序会根据这个值来确定应该显示哪一页内容。
如何使用“上一页”和“下一页”按钮
该应用程序在其主要的点击事件处理函数中实现了页面切换功能。具体实现代码如下:
case 'sheet-prev':
if (state.sheet > 0) {
state-sheet--;
drawPreview();
}
break;
case 'sheet-next':
state_sheet++;
drawPreview();
break;
当用户点击“上一页”按钮时,程序会检查当前页码是否大于0。如果是,就会将state.sheet的值减1,并重新绘制预览内容。
当用户点击“下一页”按钮时,页面编号会增加,预览内容也会相应更新。
该应用程序还会防止用户跳转到不存在的页面。在drawPreview()>函数中,程序会检查当前所在的页码,并在必要时禁用“上一页”和“下一页”按钮:
state-sheet = clamp(state.sheet, 0, L.sheets - 1);
uisheetPos.textContent =
'第' + (state_sheet + 1) + '页,共' + L.sheets页;
q('[data-act="sheet-prev"]').disabled = state(sheet <= 0;
q('[data-act="sheet-next"]').disabled =
state-sheet >= L.sheets - 1;
clamp()函数确保用户选择的页面编号始终处于有效范围内。页面计数器从1开始显示当前页码,同时也会显示总页数。在第一张表格上,“上一个”按钮是处于禁用状态的,而在最后一张表格上,“下一个”按钮也是处于禁用状态的。这样的设计使得在查看大型文档时更加方便,因为无需一次性显示所有的表格内容。使用OCR阅读扫描后的页面
有些PDF文件包含可选择的文本,而另一些则只包含打印页的扫描图像。
PDF.js可以从含有实际文本数据的页面中提取文本,但无法直接从仅包含图像的扫描页面中提取文字。
为了解决这类问题,该应用程序提供了使用Tesseract.js的OCR功能。
当用户选择“使用OCR阅读扫描后的页面”时,程序会识别那些尚未添加字幕的扫描页面,并利用OCR引擎对这些页面进行处理。
在需要时加载Tesseract.js
该应用程序仅会在用户请求使用OCR功能时才会加载Tesseract.js。它会首先检查这个库是否已经存在;如果仍在加载中,就会重用之前的加载请求。
以下是实际的loadTesseract()实现代码:
var tessPromise = null;
function loadTesseract() {
if (window.Tesseract) {
return Promise.resolve(window.Tesseract);
}
if (tessPromise) {
return tessPromise;
}
tessPromise = new Promise(function (resolve, reject) {
var s = document.createElement('script');
s.src = TESS + 'tesseract.min.js';
s.async = true;
s.onload = function () {
if (window.Tesseract) {
resolve(window.Tesseract);
} else {
tessPromise = null;
reject(new Error('engine'));
}
};
s.onerror = function () {
tessPromise = null;
reject(new Error('engine'));
};
document.head.appendChild(s);
setTimeout(function () {
reject(new Error('engine'));
}, 45000);
});
return tessPromise;
}
该函数会首先检查window.Tesseract,看OCR库是否已经加载完成。如果已经加载完毕,就会直接返回这个库;如果仍在加载中,就会重用之前的加载请求。
如果OCR库尚未加载完成,程序就会创建一个新的脚本元素,将其源代码设置为Tesseract.js库的路径,然后将其添加到文档中。onload和onerror事件处理器会用来判断加载是否成功。
创建OCR处理线程
一旦OCR库加载完成,程序就会创建一个专门用于识别英文文本的Tesseract处理线程。
以下是实际的runOcr()函数中的相关代码:
var T = await loadTesseract();
checkCancel();
var w = await T.createWorker('eng', 1, {
workerPath: TESS + 'worker.min.js',
corePath: TESS_CORE,
logger: function (m) {
if (!m || token !== state.loadToken || !state.busy) {
return;
}
if (/loading language/i.test(m.status)) {
setProgress(
m.progress || 0,
'正在获取英文语言数据……'
);
} else if (/recogniz/i.test(m.status)) {
setProgress(
(done + (mprogress || 0)) / list.length,
'正在阅读第' + list[done] + '页……'
);
}
}
});
state.worker = w;
checkCancel();
await w.setParameters({ debug_file: '/dev/null' });
createWorker()方法使用英语语言数据来初始化OCR引擎。工作进程文件路径与核心文件路径会告诉Tesseract.js从何处加载所需的辅助文件。
日志系统会记录语言加载及文本识别过程的进展情况。应用程序会利用这些信息,在OCR运行期间更新进度显示界面。
从扫描页面中识别文本
该应用程序会逐个处理每一张符合条件的扫描页面,在将其发送给Tesseract.js之前,会先以较高的分辨率渲染这些页面。
以下是runOcr()函数中用于处理页面的实际代码片段:
for (; done < list.length; done++) {
checkCancel();
var n = list[done],
page = await state.doc.getPage(n),
b = page.getViewport({ scale: 1 });
var c = await renderPage(
n,
Math.min(b.width * 300 / 72, 2600),
Math.min(b.height * 300 / 72, 2600)
);
checkCancel();
var png = await pngBytes(c);
c.width = 1;
var res = await w.recognize(png);
checkCancel();
if (token !== state.loadToken) {
return;
}
var cap = captionFromText(res.data && res.data.text);
if (cap && !captionFor(n)) {
state.captions[n] = cap;
var ta = ui.grid.querySelector('[data-cap="' + n + '"]');
if (ta) {
ta.value = cap;
}
}
}
该函数会获取每一张扫描后的PDF页面,并计算其原始尺寸;随后会以大约300 DPI的分辨率渲染这些页面,其最大显示尺寸为2,600像素。
pngBytes()辅助函数会将渲染后的图像数据转换为PNG格式。Tesseract.js会通过w.recognize(png)接收这些数据,并返回识别结果。
应用程序会将识别得到的文本传递给captionFromText()>函数进行清理和优化处理;如果处理后的结果有用,且该页面尚未添加标题信息,就会将其保存到state.captions中,并更新相应的标题字段。
此外,应用程序还会检查是否有人取消了操作,并确认PDF文件的加载过程仍在进行中,才会最终更新结果显示界面。
处理完成后,应用程序会提示用户查看生成的标题信息。需要注意的是,OCR技术并不能保证能正确识别所有文字;图像质量、手写字体的清晰度、不常见的字体类型以及页面的排版方式都可能影响识别结果。
用户在下载最终生成的PDF故事板之前,应该先仔细核对这些标题内容。
生成最终的PDF故事板
<当用户对布局和标题内容满意后,该应用程序就可以生成最终的PDF文件了。>此过程使用PDF-lib来创建新的PDF文档。应用程序会为每个故事板页面生成一个输出页面,然后将原始的PDF页面放置在计算出的位置上,并添加故事板的标题、标签和说明文字。
创建PDF文档
实际的应用程序会使用exportPdf()函数来生成最终结果。它首先创建一个新的PDF文档,然后检查原始的PDF页面是否可以以矢量格式被嵌入到新文档中。
以下是实际实现代码的第一部分:
async function exportPdf(snap, token) {
await engine();
var PL = window.PDFLib,
L = snap.L,
o = snap.o,
list = snap.list;
var out = await PL.PDFDocument.create(),
src = null,
embedded = {};
if (state.vector) {
try {
src = await PL.PDFDocument.load(
state.bytes.slice(0),
{ updateMetadata: false }
);
} catch (e) {
src = null;
}
}
var k = exportScale(L, 300, 14e6);
for (var s = 0; s < L.sheets; s++) {
checkCancel();
if (token !== state.loadToken) {
return;
}
setProgress(
s / L.sheets,
'正在处理第' + (s + 1) + '页,共' + L.sheets + '页…'
);
var page = out.addPage([L.W, L.H]);
var c = document.createElement('canvas');
c.width = Math.round(L.W * k);
c.height = Math.round(L.H * k);
var drawn = await drawSheet(
c.getContext('2d'),
o,
list,
L,
s,
k,
'overlay',
null
);
if (!drawn) {
return;
}
}
该函数首先使用engine()等待所需的PDF库加载完成,然后通过PDFDocument.create()创建一个新的PDF文档。
snap参数包含了所选布局、选项及页面列表的副本。应用程序会利用这些信息来确保导出结果符合用户的选择设置。
state.vector这一检查用于判断原始PDF文件是否可以以矢量格式被嵌入到新文档中。同时,应用程序还会计算出一个适用于300 DPI分辨率的渲染比例,并限制最终的像素数量。
循环会为每个故事板页面生成一个输出PDF页面,并以overlay模式调用drawSheet()>函数,以便在原始PDF页面上添加标题、面板标签和说明文字。
保留原始PDF页面
当支持矢量嵌入功能时,应用程序会直接将原始PDF页面嵌入到新文档中,而不会将其转换为普通图像。
以下代码片段展示了它是如何处理每一个被选中的面板的。
for (var i = 0; i < drawn.length; i++) {
checkCancel();
var d = drawn[i],
r = d.rect,
yb = L.H - r.y - r.h;
page.drawRectangle({
x: r.x,
y: yb,
width: r.w,
height: r.h,
color: PL.rgb(1, 1, 1)
});
if (src) {
var sp = src.getPage(d.n - 1);
if (!embedded[d.n]) {
var cb = sp.getCropBox();
embedded[d.n] = await out.embedPage(sp, {
left: cb.x,
bottom: cb.y,
right: cb.x + cb.width,
top: cb.y + cb.height
});
}
var em = embedded[d.n],
rot = ((sp.getRotation().angle % 360) + 360) % 360;
var turned = rot === 90 || rot === 270,
sx = r.w / (turned ? em.height : em.width);
var ew = em.width * sx,
eh = em.height * sx,
at = { x: r.x, y: yb };
if (rot === 90) {
at = { x: r.x, y: yb + ew };
} else if (rot === 180) {
at = { x: r.x + ew, y: yb + eh };
} else if (rot === 270) {
at = { x: r.x + eh, y: yb };
}
page.drawPage(em, {
x: at.x,
y: at.y,
width: ew,
height: eh,
rotate: PL.degrees(-rot)
});
}
该应用程序会根据原始PDF页面的页码来获取 해당页面的内容,然后使用embedPage()方法将该页面嵌入到新的PDF文档中。
embedded对象用于缓存已经嵌入过的页面,这样就不会重复嵌入相同的页面了。
代码还会检查原始页面的旋转角度,并在绘制之前调整其位置和尺寸,这样可以确保在将页面放入故事板面板中时,它们能够保持正确的显示方向。
处理受保护的PDF文件
如果无法将原始PDF文件作为矢量图像嵌入到新文档中,应用程序会采用栅格化处理方式作为替代方案。
以下是具体的替代代码:
else {
var pic = await renderPage(
d.n,
r.w * k,
r.h * k
);
var jpg = await new Promise(function (res) {
pic.toBlob(function (b) {
b.arrayBuffer().then(function (a) {
res(new Uint8Array(a));
});
}, 'image/jpeg', 0.92);
});
pic.width = 1;
page.drawImage(
await out.embedJpg(jpg),
{
x: r.x,
y: yb,
width: r.w,
height: r.h
}
);
}
在这种情况下,应用程序会先将原始PDF页面渲染到画布上,然后将其转换为JPEG图像数据,最后再将这些图像嵌入到相应的故事板面板中。
当无法进行矢量化嵌入时,就会使用这种替代方案,尤其是对于那些无法按照常规矢量处理流程进行处理的受保护PDF文件而言。
添加标题并保存PDF文件
在将原始页面的图片或矢量内容添加到PDF中之后,应用程序会再添加故事板的标题、标签、说明文字以及其他绘图元素,这些元素会被作为覆盖层显示在PDF页面上。
以下代码片段展示了如何添加这些覆盖层以及如何保存最终的PDF文件:
var over = await out.embedPng(await pngBytes(c));
c.width = 1;
page.drawImage(over, {
x: 0,
y: 0,
width: L.W,
height: L.H
});
await nextFrame();
当所有页面都处理完毕之后,应用程序会检查是否有人取消了操作,然后生成最终的PDF文件:
checkCancel();
setProgress(1, '正在保存PDF文件…');
out.setTitle(state.title || state.base + '故事板');
out.setCreator('AllInOneTools PDF故事板');
out.setProducer('AllInOneTools (pdf-lib)');
var bytes = await out.save();
if (token !== state.loadToken) {
return;
}
save(
new Blob([bytes], { type: 'application/pdf' }),
state.base + '-storyboard.pdf'
);
toast(
'已保存' + plural(L.sheets, '页', '张') +
'为' + state.base + '-storyboard.pdf文件。'
);
embedPng()方法会将故事板的覆盖层添加到PDF页面中,这些覆盖层包含了标题、面板标签、说明文字等各种视觉元素。
随后,应用程序会设置PDF文件的元数据,将文档保存为字节格式,并生成一个可供下载的PDF文件。
在整个导出过程中,系统会不断更新进度信息并检查是否有人取消了操作;这对于处理包含多页的故事板来说尤其有用。
最终的PDF文件会将原始的页面内容与故事板的布局及说明文字结合起来,从而形成一个可以下载、分享或打印的完整文件。
下载PNG格式的故事板页面
如果用户不需要PDF格式的文件,也可以选择下载PNG格式的单独页面。
应用程序会将每一页完整的故事板内容渲染成200 DPI分辨率的PNG图片。如果故事板只包含一页,浏览器会直接下载一个PNG文件;如果包含多页,应用程序会把它们打包成一个ZIP压缩文件。
将故事板导出为PNG格式
应用程序在exportJob()函数中处理PNG文件的导出工作。首先它会计算出渲染所需的缩放比例,然后依次处理每一页故事板内容。
以下代码片段展示了具体的PNG文件导出实现过程:
var k = exportScale(L, 200, 16e6),
files = [];
for (var s = 0; s < L.sheets; s++) {
checkCancel();
setProgress(
s / L.sheets,
'正在绘制第' + (s + 1) + '页,共' + L.sheets + '页……'
);
var c = document.createElement('canvas');
c.width = Math.round(L.W * k);
c.height = Math.round(L.H * k);
var ok = await drawSheet(
c.getContext('2d'),
o,
list,
L,
s,
k,
'full',
function (n, w, h) {
checkCancel();
return renderPage(n, w, h);
}
);
if (!ok) { return; }
files.push({
name: state.base + '-storyboard-sheet-' +
pad2(s + 1) + '.png',
data: await pngBytes(c)
});
c.width = 1;
}
if (files.length === 1) {
save(
new Blob([files[0].data], { type: 'image/png' }),
state.base + '-storyboard.png'
);
} else {
save(makeZip(files), state.base + '-storyboard-sheets.zip');
}
toast(
files.length === 1
? '故事板已成功导出为PNG文件。'
: '共' + files.length + '页故事板内容已被打包成ZIP文件保存。'
);
该应用程序使用exportScale()函数来计算在保持画布像素数量不超过1600万的前提下、适合200 DPI分辨率的渲染比例。
对于每一页内容,程序都会创建一个画布,并以full模式调用drawSheet()函数。这样就能绘制出PDF页面的预览图,同时还会显示故事板的标题、标签、说明文字以及其他用户选定的设置内容。
pngBytes()辅助函数会将处理完成的画布数据转换为PNG格式文件。每张生成的图片都会被添加到files数组中,其文件名会包含对应的页面编号。
在处理完所有页面后,程序会检查最终生成了多少个PNG文件。如果只有一页内容需要导出,那么该页面会直接以PNG格式下载;如果有多页内容,那么它们会被打包成一个ZIP压缩文件,这一过程是通过makeZip()函数来完成的。
这种格式非常方便在演示文稿、消息发送或其他应用中分享故事板的预览图。
下载单页图片
第三种导出选项是“页面图片(ZIP格式)”。
与“PNG页面文件”选项不同,这种导出方式会生成故事板中所选PDF页面的单个图片文件。
程序会分别渲染每一页内容,然后将生成的PNG文件放入一个ZIP压缩包中。
导出单张PDF页面
实际上,这个功能是在exportJob()函数中实现的。程序首先会生成一份包含所有选定页面的列表,然后分别渲染每一页内容。
以下是具体的实现代码:
var pages = unique(list),
out = [];
for (var i = 0; i < pages.length; i++) {
checkCancel();
setProgress(
i / pages.length,
'正在保存第' + pages[i] +
'页(共' + pages.length + '页)…'
);
var pg = await state.doc getPage(pages[i]),
vb = pg.getViewport({ scale: 1 }),
sc = Math.min(
2,
Math.sqrt(12e6 / (vb.width * vb.height))
);
var pc = await renderPage(
pages[i],
vb.width * sc,
vb.height * sc
);
out.push({
name: 'panel-' +
pad2(list.indexOf(pages[i]) + 1) +
'-page-' + pad2_pages[i]) + '.png',
data: await pngBytes(pc)
});
pc.width = 1;
}
save(makeZip(out), state.base + '-panels.zip');
toast(
'已成功保存' +
plural(out.length, '页图片', '页图片') +
'到ZIP文件中。'
);
首先,该函数会调用unique()方法来去除选定列表中的重复页面编号。
然后,程序会分别处理每一页内容。getPage()方法用于获取原始PDF页面,而getViewport()方法则用来获取该页面的尺寸信息。
通过计算合适的渲染比例,可以将页面的分辨率限制在2范围内,同时确保渲染后的图像尺寸大约在1200万像素左右。这样在处理较大规模的页面内容时,可以有效控制内存使用量。renderPage()函数用于生成页面图像,而pngBytes()函数则将canvas转换为PNG格式的数据。
每张生成的图片都会被添加到out数组中,其文件名中会包含该图片在布局中的位置以及原始PDF文档中的页码。这样的命名方式有助于人们识别这些图片,并保持它们在故事板中的原有顺序。
最后,makeZip()函数会将所有生成的PNG图片打包成一个ZIP文件,浏览器会自动下载这个文件。
当用户希望单独处理PDF文档中的某些页面而不是整个故事板时,这个功能非常有用。
性能与重要注意事项
渲染大型PDF文件或生成高分辨率图像会消耗大量的浏览器内存。
该应用程序通过使用页面预览缓存、临时canvas对象、进度报告功能以及取消操作机制来有效管理这类任务。
为了获得更好的处理效果,在处理大型文档时,每页应使用较少的面板元素;如果故事板中包含大量面板,使用A3尺寸的纸张也会帮助节省空间。
请注意,用户的布局设置会保存在设备上,但PDF文件及其中的文字说明仍然会保留在当前浏览器标签页中。因此在关闭标签页之前,请务必先下载已完成的作品。
演示:PDF故事板生成器
您可以在这里尝试使用这个成品应用程序:
只需上传PDF文件,调整页面布局,添加文字说明,自定义故事板内容,然后即可下载最终结果。
结论
开发这个PDF故事板生成器,实际上是将多种实用的浏览器技术结合在了一个应用程序中。
PDF.js负责处理PDF文件的加载与页面渲染;Tesseract.js可对扫描后的页面进行文本识别(此功能为可选);PDF-lib则用于生成最终的故事板PDF文件;而HTML canvas则用于显示预览效果及导出图片。
通过使用JavaScript将这些组件连接起来,我们便能够打造出一个实用的工具,用来整理演示文稿中的幻灯片、漫画页面、脚本文件等各类PDF文档。
这个项目还展示了基于浏览器的应用程序如何能够在本地处理文档,而无需用户将文件上传到服务器上。
有了这套完整的工作流程,用户就可以将普通的PDF页面转换成结构清晰的故事板文件,从而更方便地查看、打印或分享这些内容。 相关文章
技术实践
如何利用人工智能提示更快地构建网站用户界面
过去,创建网站的用户界面需要花费数小时的时间来手动编写HTML、CSS代码以及各种组件代码。但现在,你只需用简单的文字描述页面的结构,就可以让人工智能编码工具生成最初的代码版本。 不过,最终生成的代码质量取决于你提供的指令。如果指令过于简短或模糊,那么生成的代码可能只是包含随机颜色和重复代码的简单页面;而如果你能提供详细的说明——包括所使用的技术栈、文件结构以及设计规则,那么工具就能生成可实际使用的代码。 在本教程中,你将学习如何编写这样的指令。通过这些指令,你可以使用Next.js、Tailwind CSS以及shadcn/ui组件来构建一个带有固定导航栏的全屏页面。 完成本教程后,你将能够
阅读全文
技术实践
如何使用JavaScript开发一款可将基于浏览器的图像故事板转换为PDF格式的工具
在任何人开始拍摄视频、制作广告或绘制漫画的最终画面之前,通常会先画出一份故事板草图:按照拍摄顺序为每一镜头绘制一幅图片,并附上简短的说明,说明该镜头中发生了什么以及持续时间有多长。 大多数人已经拥有这些图片了——它们可能是手机照片、截图、粗略的草图,或者是通过人工智能生成的图像文件,这些文件都保存在某个文件夹里。但他们所缺乏的是一种能够将这些文件快速整理成格式规范、编号整齐的文档,并将其打印出来或发送给客户的工具。 在这个教程中,你将学习如何制作这样一个基于浏览器的工具。你需要添加一组图片,为每张图片指定标题、描述和持续时间,选择合适的布局方式,预览生成的文档,然后下载可供打印的PDF文件。
阅读全文
技术实践
由 TypeScript 7 提供支持的 Angular 编译器已进入测试阶段,该编译器配备了基于 Rust 的预处理工具。
Angular团队推出了ngp这一编译器预处理器,该工具利用Oxc重新实现了Rust语言中的分析阶段处理流程。它取代了ngc对Transformer API的依赖——而TypeScript 7.0已经移除了对Transformer API的支持。早期的测试结果显示,使用ngp进行冷构建时,编译速度可以提高2到3倍。预计这一新技术将在Angular 22的次要版本中正式被采用;在此之前,生产环境仍然会继续使用ngc进行编译。 作者:Daniel Curtis
阅读全文
技术实践
谷歌云的支出上限功能现已通过API接口正式推出,并允许用户每月进行重置设置。
Google Cloud现已全面推出了针对云账单预算的使用额度限制功能:一旦某个项目的月度预算被用完,相关服务的新增使用请求将会被暂停,但现有资源不会被删除。该系统提供了API接口、预估费用功能以及自动每月重置机制;不过这些额度限制仅适用于Gemini API、Agent Platform、Cloud Run以及Cloud Run Functions等服务。在限制措施生效期间,如果仍出现超支情况,相关费用仍然会正常计费。 作者:Steef-Jan Wiggers
阅读全文