用 JavaScript 打造浏览器端图片分镜转 PDF 工具教程
在拍视频、拍广告或画漫画最终页之前,通常会有人先用分镜把方案画出来:每个镜头一张图,按顺序排列,并附上简短说明——发生了什么、持续多久。
这些图片其实大多数人都有。它们可能是手机照片、截图、随手涂鸦,或者躺在文件夹里的 AI 生成帧。缺的只是一个快速的办法,把这个文件夹变成整洁、带编号、可以打印或发给客户的分镜表。
在本教程中,你将构建一个浏览器端的工具来解决这个问题。你可以添加一组图片,为每张图设置标题、描述和时长,选择版式,预览页面,最后下载一份可直接打印的 PDF。
所有操作都在浏览器中完成。图片不会被上传到服务器——当这些画面是未发布的产品图或客户作品时,这一点很重要。
过程中你会学到:如何修正横拍的手机照片、如何按用户习惯排序文件、如何用 PDF 点数来排版页面、如何在 canvas 上换行显示文本、如何用 IndexedDB 保存草稿,以及如何生成一份既保证照片清晰度又不会让文件过大的 PDF。
这个工具是我为 AllInOneTools 开发的,也就是 Image Storyboard to PDF 工具。本文会带你实现一个简化版本,只需三个文件即可完成。
内容概览
- 这个分镜工具的功能与工作原理
- 项目搭建
- 使用的库和浏览器 API
- 创建 HTML 布局
- 添加和加载图片
- 管理分镜帧
- 构建设置面板
- 分镜页面排版
- 在 Canvas 上绘制页面
- 预览页面
- 用 IndexedDB 保存草稿
- 用 PDF-lib 生成 PDF
- 重命名和下载
- 演示:图片分镜工具如何运作
- 性能优化技巧
- 常见错误
- 结论
图片分镜工具的功能与工作原理
该工具接收一系列图片,并将其转换为分镜脚本页。每张图片生成一个画框,包含:
- 标签,如
Shot 1(镜头 1)或Scene 1(场景 1) - 可选标题
- 发生内容的描述
- 可选的秒数时长
画框按四种版式之一排布在页面上:
- 每页一框:便于大图展示每个镜头
- 网格:一目了然地查看整个序列
- 带笔记的面板:配有线格,供片场手写备注
- 胶片条:带齿孔,呈现经典外观
每页顶部标注标题、总时长和页码。
以下是从头到尾的处理流程:
Images → frames (title, description, duration)
→ layout (where each box goes, in PDF points)
→ canvas (labels, text, lines)
→ PDF (photos as JPEG + one text layer per sheet)
→ download
为什么将文字绘制在 Canvas 上
PDF-lib 支持在 PDF 中写入真实文本,但前提是你必须嵌入字体文件。标准 PDF 字体仅覆盖基础拉丁字符,因此若描述文字使用印地语、古吉拉特语、阿拉伯语或日语,就会无法正确显示。
相比之下,Canvas 可以借助设备已有的字体,绘制任何该设备能显示的文本。因此,该工具以 300 dpi 在 Canvas 上绘制所有文字,然后将此 Canvas 作为图像嵌入 PDF。
权衡之处在于:PDF 中的文字不可选中或搜索。对于主要用途是打印和查看的分镜脚本,这通常可以接受。若需支持可搜索文本,则需借助 PDF-lib 的fontkit支持嵌入字体。
项目初始化
只需三个文件:
image-storyboard/
├── index.html
├── style.css
└── script.js
请通过本地服务器打开 index.html(例如使用 VS Code 的 Live Server 扩展),而不要直接以 file:// 页面方式打开,这样 IndexedDB 和下载功能的表现才和真实网站上一致。
用到的库和浏览器 API
这个项目只用了一个库和几个浏览器内置 API:
- PDF-lib:负责生成 PDF、嵌入 JPEG 和 PNG 图片并保存文件。
- Canvas API:绘制预览用的工作表,以及 PDF 的文字层。
createImageBitmap:解码图片,并根据手机照片中存储的旋转信息进行修正。- IndexedDB:在浏览器中保存草稿,页面刷新后工作内容不会丢失。
这里不需要 PDF.js,因为没有任何 PDF 需要读取,一切都从图片开始。
在 body 末尾、你自己的脚本之前,引入固定版本的 PDF-lib:
<script src="https://unpkg.com/pdf-lib@1.17.1/dist/pdf-lib.min.js"></script>
<script src="script.js"></script>
锁定版本号可以避免库日后升级时悄悄破坏你的工具。
搭建 HTML 布局
页面由四个部分组成:
- 带文件选择框的上传区域
- 用于恢复已保存草稿的提示条
- 帧面板,每张图片对应一张卡片
- 设置面板和预览面板
下面是上传区域和草稿提示条的代码:
<!-- 1. Upload -->
<section id="uploadArea" class="upload-area">
<p>Drop images here or</p>
<button type="button" id="selectBtn">Select images</button>
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp,image/gif" multiple hidden>
<p id="statusText" class="status" aria-live="polite"></p>
</section>
<div id="draftBar" class="draft-bar" hidden>
<span id="draftText"></span>
<button type="button" id="restoreBtn">Restore</button>
<button type="button" id="discardBtn">Discard</button>
</div>
文件选择框只接受所有浏览器都能解码的格式:JPEG、PNG、WebP 和 GIF。
工作区包含三个面板:
<!-- 2. Workspace -->
<section id="workspace" class="workspace" hidden>
<div class="frames-panel">
<div class="panel-head">
<h2>Frames</h2>
<span id="totalTime"></span>
</div>
<label>Storyboard title
<input type="text" id="boardTitle" placeholder="My video">
</label>
<ol id="frameList" class="frame-list"></ol>
<button type="button" id="undoBtn" hidden>Undo delete</button>
<button type="button" id="addMoreBtn">Add more images</button>
</div>
设置面板由若干 select 和 number 输入框组成。每个输入框都有一个 id,对应 JavaScript 中设置对象的键名:
<div class="settings-panel">
<h2>Board settings</h2>
<label>Layout
<select id="layout">
<option value="page">One frame per page</option>
<option value="grid" selected>Grid</option>
<option value="notes">Panels with notes</option>
<option value="strip">Film strip</option>
</select>
</label>
<label>Columns <input type="number" id="cols" min="1" max="6" value="3"></label>
<label>Rows <input type="number" id="rows" min="1" max="6" value="2"></label>
<!-- Paper, Orientation, Frame label, Font, Text size and
Description lines follow the same pattern -->
</div>
预览面板包含画布、页面导航和下载控制。
<div class="preview-panel">
<h2>预览</h2>
<canvas id="previewCanvas"></canvas>
<div class="pager">
<button type="button" id="prevBtn">上一页</button>
<span id="sheetInfo"></span>
<button type="button" id="nextBtn">下一页</button>
</div>
<label>文件名 <input type="text" id="fileName" value="storyboard"></label>
<label>图片质量
<select id="quality">
<option value="300">打印用,300 dpi</option>
<option value="150">文件更小,150 dpi</option>
</select>
</label>
<button type="button" id="downloadBtn" class="primary">下载 PDF</button>
<button type="button" id="resetBtn">重新开始</button>
<p id="resultText" class="status" aria-live="polite"></p>
</div>
在 style.css 里,有一条规则很容易被忽略:
[hidden] { display: none !important; }
工作区用的是 display: grid,草稿栏用的是 display: flex。这些规则的优先级高于浏览器对 hidden 属性的默认样式,所以不加这一行,被隐藏的元素照样会显示出来。
获取主要元素
在 script.js 的开头,先定义常量、状态和元素引用:
// ---------- 常量 ----------
const PAPERS = {
a4: [595.28, 841.89],
letter: [612, 792],
a3: [841.89, 1190.55],
slide: [405, 720],
};
const FONTS = {
sans: "Arial, Helvetica, 'Noto Sans', sans-serif",
serif: "Georgia, 'Times New Roman', 'Noto Serif', serif",
mono: "'Courier New', Courier, monospace",
};
const THUMB_SIZE = 1000;
const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
// ---------- 状态 ----------
const state = {
frames: [],
title: '',
sheet: 0,
nextId: 1,
lastDeleted: null,
opts: {
layout: 'grid', cols: 3, rows: 2, paper: 'a4', orient: 'landscape',
label: 'shot', font: 'sans', textSize: 'm', lines: 3,
},
};
// ---------- 元素 ----------
const $ = (id) => document.getElementById(id);
const els = {
uploadArea: $('uploadArea'), fileInput: $('fileInput'), selectBtn: $('selectBtn'),
statusText: $('statusText'), workspace: $('workspace'), frameList: $('frameList'),
totalTime: $('totalTime'), boardTitle: $('boardTitle'), undoBtn: $('undoBtn'),
addMoreBtn: $('addMoreBtn'), canvas: $('previewCanvas'), prevBtn: $('prevBtn'),
nextBtn: $('nextBtn'), sheetInfo: $('sheetInfo'), fileName: $('fileName'),
quality: $('quality'), downloadBtn: $('downloadBtn'), resetBtn: $('resetBtn'),
resultText: $('resultText'), draftBar: $('draftBar'), draftText: $('draftText'),
restoreBtn: $('restoreBtn'), discardBtn: $('discardBtn'),
};
const SETTING_IDS = ['layout', 'cols', 'rows', 'paper', 'orient', 'label', 'font', 'textSize', 'lines'];
state 对象是唯一的真实数据源。帧列表、预览和 PDF 内容均基于它构建,从而确保各处状态保持一致。
纸张尺寸以PDF 点为单位。1 点等于 1/72 英寸,因此 A4 页面的尺寸为 595.28 × 841.89 点。布局计算时直接使用点单位,仅在绘制时转换为像素。
添加与加载图片
通过按钮选择或拖放打开文件
点击“选择图片”按钮会弹出隐藏的文件选择输入框;将文件拖放到页面上任意位置也能将其添加到项目中:
// ---------- 上传区域 ----------
els.selectBtn.addEventListener('click', () => els.fileInput.click());
els.addMoreBtn.addEventListener('click', () => els.fileInput.click());
els.fileInput.addEventListener('change', () => {
addFiles(els.fileInput.files);
els.fileInput.value = '';
});
['dragenter', 'dragover'].forEach((type) => {
document.addEventListener(type, (e) => {
if (!e.dataTransfer.types.includes('Files')) return;
e.preventDefault();
els.uploadArea.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach((type) => {
document.addEventListener(type, () => els.uploadArea.classList.remove('dragover'));
});
document.addEventListener('drop', (e) => {
if (!e.dataTransfer.files.length) return;
e.preventDefault();
addFiles(e.dataTransfer.files);
});
function showWorkspace() {
els.uploadArea.hidden = true;
els.draftBar.hidden = true;
els.workspace.hidden = false;
}
每次选择文件后重置 fileInput.value,用户之后才能再次选择同一个文件。而 dataTransfer.types.includes('Files') 这个检查确保拖放高亮只在拖入文件时出现,拖动列表内部的分镜卡片时不会触发。
修正手机照片的旋转问题
手机横着拿时并不会真正旋转像素,而是按传感器方向保存图片,再加一个 EXIF 标签,注明“显示时旋转 90°”。
在某些浏览器里,如果用 img 元素把这类照片绘制到 canvas 上,或者在旋转生效前就读取宽高,得到的分镜就会是横着的,或者比例不对。
使用 createImageBitmap 并设置 imageOrientation: 'from-image',可以让浏览器在解码时就应用 EXIF 旋转:
// ---------- 加载图片 ----------
// 手机照片会将旋转信息存储在 EXIF 中。imageOrientation: 'from-image'
// 指示浏览器应用此旋转,确保图片正向显示。
function decodeImage(blob) {
if ('createImageBitmap' in window) {
return createImageBitmap(blob, { imageOrientation: 'from-image' })
.catch(() => loadWithImgTag(blob));
}
return loadWithImgTag(blob);
}
function loadWithImgTag(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { URL.revokeObjectURL(url); resolve(img); };
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('decode')); };
img.src = url;
});
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('encode'))), type, quality);
});
}
当 createImageBitmap 不可用或调用失败时,代码会回退到常规的 img 元素。现代浏览器默认也会为 img 元素应用 EXIF 旋转。
canvasToBlob 将基于回调的 canvas.toBlob() 封装为 Promise,便于配合 await 使用。
创建包含缩略图的 Frame 对象
手机照片的尺寸可能达到 4000 × 3000 像素甚至更大。每当用户输入字符时都重绘几十张此类大图,会导致预览刷新缓慢。
因此,每个 Frame 除了保留用于生成 PDF 的原始文件外,还额外存一份小尺寸的 JPEG 副本(最长边不超过 1000 像素),专供 Frame 列表和预览使用:
async function makeFrame(file) {
const pic = await decodeImage(file);
const w = pic.naturalWidth || pic.width;
const h = pic.naturalHeight || pic.height;
// 生成一份小尺寸副本,用于帧列表和预览
const scale = Math.min(1, THUMB_SIZE / Math.max(w, h));
const c = document.createElement('canvas');
c.width = Math.round(w * scale);
c.height = Math.round(h * scale);
const ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, c.width, c.height);
ctx.drawImage(pic, 0, 0, c.width, c.height);
if (pic.close) pic.close();
const thumb = await canvasToBlob(c, 'image/jpeg', 0.86);
return {
id: 'f' + state.nextId++,
blob: file, name: file.name, w, h,
thumb, thumbUrl: URL.createObjectURL(thumb), thumbPic: null,
title: '', desc: '', dur: '',
};
}
注意,w 和 h 是在解码后才读取的,所以拿到的是旋转后的尺寸。手机上拍的竖版照片,得到的自然就是竖版画框。
绘制前先用白色填充这一步很重要,因为要处理带透明通道的 PNG。JPEG 本身不支持透明,否则透明区域会变成黑色。
按文件名排序
用户的文件常常是这样命名的:shot-1.jpg、shot-2.jpg……shot-10.jpg。普通的字符串排序会把 shot-10 排在 shot-2 前面,因为它是逐字符比较,1 小于 2。
而 localeCompare 加上 numeric 选项后,会把名称中的数字当作数值来比较:
// 让 "shot-2.jpg" 排在 "shot-10.jpg" 前面
function naturalCompare(a, b) {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
添加文件
addFiles 会先过滤掉不支持的文件,再对剩下的排序,逐个生成帧对象,最后报告被跳过的文件:
async function addFiles(fileList) {
const files = Array.from(fileList);
const images = files.filter((f) => ACCEPTED.includes(f.type))
.sort((a, b) => naturalCompare(a.name, b.name));
const skipped = files.length - images.length;
let failed = 0;
for (let i = 0; i < images.length; i++) {
els.statusText.textContent = `正在读取第 ${i + 1}/${images.length} 张图片...`;
try {
state.frames.push(await makeFrame(images[i]));
} catch (err) {
failed++;
}
}
const notes = [];
if (skipped) notes.push(`跳过 ${skipped} 个文件:格式不是 JPG、PNG、WebP 或 GIF`);
if (failed) notes.push(`无法读取 ${failed} 张图片`);
els.statusText.textContent = notes.join('。');
if (state.frames.length) {
showWorkspace();
renderFrameList();
drawPreview();
saveDraftSoon();
}
}
图片是逐个读取的,而不是用 Promise.all 一次性全部加载。如果并行解码 50 张大图,手机可能会内存溢出;按顺序读取也能确保状态消息准确。
管理帧
帧卡片
每一帧生成一张卡片,包含缩略图、标题输入框、描述框、时长输入框,以及三个按钮:
// ---------- Frame list ----------
function escapeHtml(s) {
const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
return String(s).replace(/[&<>"']/g, (c) => map[c]);
}
function renderFrameList() {
els.frameList.innerHTML = state.frames.map((f, i) => `
<li class="frame-card" data-id="${f.id}">
<img src="${f.thumbUrl}" alt="" draggable="true">
<div>
<span class="num">${i + 1}. ${escapeHtml(f.name)}</span>
<input type="text" data-field="title" placeholder="Title" value="${escapeHtml(f.title)}">
<textarea data-field="desc" placeholder="What happens">${escapeHtml(f.desc)}</textarea>
<input type="number" data-field="dur" min="0" step="0.5" placeholder="Seconds" value="${f.dur}">
<div class="frame-tools">
<button type="button" data-act="up" aria-label="Move frame ${i + 1} up">Up</button>
<button type="button" data-act="down" aria-label="Move frame ${i + 1} down">Down</button>
<button type="button" data-act="delete" aria-label="Delete frame ${i + 1}">Delete</button>
</div>
</div>
</li>`).join('');
updateTotal();
}
function updateTotal() {
const total = totalDuration();
els.totalTime.textContent = `${state.frames.length} frames` + (total ? `, ${formatDuration(total)}` : '');
}
function findFrame(el) {
const li = el.closest('[data-id]');
return li ? state.frames.findIndex((f) => f.id === li.dataset.id) : -1;
}
标题和描述都是用户输入的内容,所以在插入 HTML 前要先转义。如果没有 escapeHtml,像 Shot "A" 这样的标题会破坏 value 属性的结构。
边输入边更新状态
如果每敲一个键都重新渲染整个列表,输入框每次都会失去焦点。更好的做法是在列表上挂一个 input 监听器,只更新状态:
els.frameList.addEventListener('input', (e) => {
const i = findFrame(e.target);
const field = e.target.dataset.field;
if (i < 0 || !field) return;
state.frames[i][field] = e.target.value;
updateTotal();
drawPreviewSoon();
saveDraftSoon();
});
这里用了事件委托:在列表上绑定一个监听器,即可处理所有卡片内的每个字段,包括之后动态添加的卡片。通过 data-field 属性告诉它要更新哪个属性。
预览重绘由 drawPreviewSoon() 延迟执行(稍后实现),这样只在用户暂停输入时触发一次,而不是每敲一个字符都触发。
时长与总播放时间
时长以秒为单位存储,显示规则为:不足一分钟显示为 4.5 s,一分钟及以上显示为 1:10:
// ---------- 时长 ----------
function formatDuration(seconds) {
const s = Number(seconds) || 0;
if (s <= 0) return '';
if (s < 60) return `${Math.round(s * 10) / 10} s`;
const m = Math.floor(s / 60);
const r = Math.round(s - m * 60);
return `${m}:${String(r).padStart(2, '0')}`;
}
function totalDuration() {
return state.frames.reduce((sum, f) => sum + (Number(f.dur) || 0), 0);
}
重新排序帧
上移、下移和删除按钮由同一个点击监听器处理:
els.frameList.addEventListener('click', (e) => {
const btn = e.target.closest('[data-act]');
if (!btn) return;
const i = findFrame(btn);
if (btn.dataset.act === 'up') moveFrame(i, i - 1);
if (btn.dataset.act === 'down') moveFrame(i, i + 1);
if (btn.dataset.act === 'delete') deleteFrame(i);
});
function moveFrame(from, to) {
if (to < 0 || to >= state.frames.length || from === to) return;
const [frame] = state.frames.splice(from, 1);
state.frames.splice(to, 0, frame);
renderFrameList();
drawPreview();
saveDraftSoon();
}
splice(from, 1) 移除该帧,splice(to, 0, frame) 将其放回新位置。
对于鼠标用户,将缩略图拖拽到另一张卡片上即可移动。HTML5 拖放 API 要求在 dragover 事件中调用 preventDefault(),否则浏览器会拒绝放置:
// 将缩略图拖到另一张卡片上即可移动
let dragIndex = -1;
els.frameList.addEventListener('dragstart', (e) => {
dragIndex = findFrame(e.target);
e.dataTransfer.effectAllowed = 'move';
e.target.closest('li').classList.add('dragging');
});
els.frameList.addEventListener('dragover', (e) => {
if (dragIndex > -1) e.preventDefault();
});
els.frameList.addEventListener('drop', (e) => {
if (dragIndex < 0) return;
e.preventDefault();
e.stopPropagation();
const to = findFrame(e.target);
moveFrame(dragIndex, to < 0 ? state.frames.length - 1 : to);
dragIndex = -1;
});
els.frameList.addEventListener('dragend', () => {
dragIndex = -1;
els.frameList.querySelectorAll('.dragging').forEach((li) => li.classList.remove('dragging'));
});
drop 处理器里的 stopPropagation() 阻止文档级的 drop 处理器把这次移动当作新文件拖入来处理。
即使有了拖拽功能,也要保留上移和下移按钮。因为拖拽没法用键盘操作,在触屏上也很别扭。
删除画格与撤销
如果一个画格里已经输入了很长的描述,误删会非常难受。所以工具会保留最后删除的画格及其位置:
function deleteFrame(i) {
const [frame] = state.frames.splice(i, 1);
state.lastDeleted = { frame, index: i };
els.undoBtn.hidden = false;
renderFrameList();
drawPreview();
saveDraftSoon();
}
els.undoBtn.addEventListener('click', () => {
if (!state.lastDeleted) return;
const { frame, index } = state.lastDeleted;
state.frames.splice(Math.min(index, state.frames.length), 0, frame);
state.lastDeleted = null;
els.undoBtn.hidden = true;
renderFrameList();
drawPreview();
saveDraftSoon();
});
如果列表在此期间变短了,Math.min(index, state.frames.length) 会把画格放回列表末尾。
构建画板设置
所有设置统一在一处读取到 state.opts,数值字段都被限制在合理的范围内:
// ---------- 设置 ----------
function readSettings() {
const o = state.opts;
SETTING_IDS.forEach((id) => { o[id] = $(id).value; });
o.cols = Math.min(6, Math.max(1, parseInt(o.cols, 10) || 1));
o.rows = Math.min(6, Math.max(1, parseInt(o.rows, 10) || 1));
o.lines = Math.min(6, Math.max(1, parseInt(o.lines, 10) || 1));
}
SETTING_IDS.forEach((id) => {
$(id).addEventListener('change', () => {
readSettings();
state.sheet = 0;
drawPreview();
});
});
els.boardTitle.addEventListener('input', () => {
state.title = els.boardTitle.value.trim();
drawPreviewSoon();
saveDraftSoon();
});
任何设置变更都会回到第一页并重绘预览。分镜标题也会保存为草稿,因为它同样是用户工作的一部分。
四种布局改变的只是排列方式,画面帧的数据不变,所以用户可以随意切换布局,不会丢失任何内容。
分镜页的布局计算
这是整个工具的核心。在真正绘制之前,代码会先算出每个格子应该放在哪里,坐标以 PDF 点为单位,从纸张左上角起算。
把布局和绘制分开,意味着屏幕上的小预览和 300 dpi 的 PDF 用的是同一套布局,两者不会出现偏差。
计算纸张尺寸
// ---------- 布局:所有元素的坐标,以 PDF 点为单位,从左上角起算 ----------
function sheetSize(o) {
const [a, b] = PAPERS[o.paper];
const short = Math.min(a, b);
const long = Math.max(a, b);
return o.orient === 'portrait' ? [short, long] : [long, short];
}
纸张尺寸只定义一次,横向只是把短边和长边对调。
排布画面帧格子
function computeLayout(o, frameCount) {
const [W, H] = sheetSize(o);
const short = Math.min(W, H);
const margin = short * 0.05;
const gap = 10;
const cols = o.layout === 'page' ? 1 : o.cols;
const rows = o.layout === 'page' ? 1 : o.rows;
const perSheet = cols * rows;
const sheets = Math.max(1, Math.ceil(frameCount / perSheet));
const headH = short * 0.045; // 每张页面顶部的标题行
const top = margin + headH + margin * 0.5;
const box = { x: margin, y: top, w: W - 2 * margin, h: H - top - margin };
const sizeScale = { s: 0.85, m: 1, l: 1.2 }[o.textSize];
const cellW = (box.w - (cols - 1) * gap) / cols;
const cellH = (box.h - (rows - 1) * gap) / rows;
const cells = [];
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const x = box.x + c * (cellW + gap);
const y = box.y + r * (cellH + gap);
const fs = Math.min(13, Math.max(6.5, cellW * 0.05)) * sizeScale;
const lh = fs * 1.3;
if (o.layout === 'notes') {
// 图片在左,标题和横线在右
const imgW = Math.min(cellW * 0.5, cellH * 1.6);
const tx = x + imgW + 8;
cells.push({ img: { x, y, w: imgW, h: cellH }, text: { x: tx, y, w: x + cellW - tx, h: cellH }, fs, lh, notes: true });
} else {
// 图片在上,文字在下
const textH = lh * (1.2 + Number(o.lines)) + fs * 0.6;
const imgH = Math.max(10, cellH - textH);
cells.push({ img: { x, y, w: cellW, h: imgH }, text: { x, y: y + imgH + fs * 0.6, w: cellW, h: textH - fs * 0.6 }, fs, lh, strip: o.layout === 'strip' });
}
}
}
return { W, H, margin, headH, perSheet, sheets, cells };
}
下面一步步说明这段代码做了什么:
- 页边距取短边的
5%,随纸张尺寸自动缩放。 - "每页一格"布局就是一个 1 × 1 网格。
- 页数等于画面总数除以每页容纳的格数,向上取整。
- 顶部保留一行空间用于页面标题。
- 剩余区域按固定间距平均划分成格子。
- 字号由格子宽度决定(取其
5%),限制在6.5到13磅之间,再乘以 Text size 设置的缩放系数。 - 大多数版式中,图片在上方,文字在下方。文字区域的大小按标签行加上所选的描述行数来计算。
- 在“带备注的面板”版式中,图片在左侧,文字和横线在右侧。
最终每个单元格都有一个 img 框和一个 text 框,绘制代码只需要知道这些就够了。
不拉伸地适配图片
单元格的形状很少和图片一致。拉伸会让人脸和产品变形,裁剪又可能切掉重要内容。所以做法是把图片等比缩放到框内并居中:
function fitContain(imgW, imgH, box) {
const s = Math.min(box.w / imgW, box.h / imgH);
const w = imgW * s;
const h = imgH * s;
return { x: box.x + (box.w - w) / 2, y: box.y + (box.h - h) / 2, w, h };
}
这和 CSS 里的 object-fit: contain 是同一个思路。取两个缩放系数中较小的那个,图片就总能完整放进框里。
在 Canvas 上绘制分镜表
手动换行
Canvas 不支持自动换行,fillText 一行一行地画,写多长就画多长。所以需要用 measureText 测量文字宽度,自己构建每一行:
// ---------- Text ----------
function wrapText(ctx, text, maxWidth, maxLines) {
const words = String(text).trim().split(/\s+/).filter(Boolean);
const lines = [];
let line = '';
for (const word of words) {
const test = line ? line + ' ' + word : word;
if (ctx.measureText(test).width <= maxWidth || !line) {
line = test;
} else {
lines.push(line);
line = word;
}
}
if (line) lines.push(line);
if (lines.length > maxLines) {
lines.length = maxLines;
let last = lines[maxLines - 1];
while (last && ctx.measureText(last + '…').width > maxWidth) last = last.slice(0, -1);
lines[maxLines - 1] = last.trimEnd() + '…';
}
return lines;
}
如果描述文字超出了可用行数,最后一行会以省略号结尾,让用户知道文字被截断了。
绘制标签、标题和时长
function frameLabel(o, index) {
if (o.label === 'number') return String(index + 1);
if (o.label === 'scene') return `Scene ${index + 1}`;
if (o.label === 'shot') return `Shot ${index + 1}`;
return '';
}
function drawFrameText(ctx, o, cell, frame, index, k) {
const fs = cell.fs * k;
const lh = cell.lh * k;
const x = cell.text.x * k;
const w = cell.text.w * k;
let y = cell.text.y * k + fs;
ctx.fillStyle = '#16181b';
ctx.textBaseline = 'alphabetic';
// 第一行:左边是编号和标题,右边是时长
const dur = formatDuration(frame.dur);
let room = w;
if (dur) {
ctx.font = `${fs}px ${FONTS.mono}`;
ctx.textAlign = 'right';
ctx.fillText(dur, x + w, y);
room -= ctx.measureText(dur).width + fs;
}
ctx.textAlign = 'left';
const head = [frameLabel(o, index), frame.title.trim()].filter(Boolean).join(' ');
if (head) {
ctx.font = `bold ${fs}px ${FONTS[o.font]}`;
ctx.fillText(wrapText(ctx, head, room, 1)[0] || '', x, y);
}
y += lh * 1.2;
// 描述文字:自动换行并截断到设定的行数
ctx.font = `${fs}px ${FONTS[o.font]}`;
const maxLines = cell.notes ? Math.floor((cell.text.h * k - lh * 1.2) / lh) : Number(o.lines);
const lines = frame.desc.trim() ? wrapText(ctx, frame.desc, w, maxLines) : [];
lines.forEach((line, j) => ctx.fillText(line, x, y + j * lh));
// 手写笔记用的横线
if (cell.notes) {
ctx.strokeStyle = '#c9c6bd';
ctx.lineWidth = Math.max(1, 0.6 * k);
const bottom = (cell.text.y + cell.text.h) * k;
for (let ly = y + lines.length * lh; ly < bottom - 2; ly += lh * 1.25) {
ctx.beginPath();
ctx.moveTo(x, ly);
ctx.lineTo(x + w, ly);
ctx.stroke();
}
}
}
参数 k 是点到像素的缩放比例。所有位置都按点来定义,绘制前再统一乘以 k。正是这个设计让同一个函数既能绘制 600 像素的预览,也能生成 300 dpi 的 PDF 页面。
时长先以右对齐方式绘制,然后从编号和标题的可用宽度中扣除它占用的空间。这样无论标题多长,都不会和时长重叠。
绘制整张分镜表
drawSheet 先绘制表头,然后逐帧绘制表上的每个分镜:
// ---------- 绘制单张图纸 ----------
// k 用于把 PDF 点换算成 canvas 像素。mode 为 'preview' 时会绘制图片,
// mode 为 'overlay' 时图片区域保持透明,留给 PDF 用。
async function drawSheet(ctx, o, L, sheetIndex, k, mode) {
const W = L.W * k;
const H = L.H * k;
const placed = [];
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, W, H);
// 图纸标题栏:左侧放标题,右侧放总时长和页码
const headFs = L.headH * 0.6 * k;
const base = (L.margin + L.headH * 0.78) * k;
const total = totalDuration();
const right = [total ? `Total ${formatDuration(total)}` : '', `Sheet ${sheetIndex + 1} of ${L.sheets}`].filter(Boolean).join(', ');
ctx.font = `${headFs * 0.72}px ${FONTS.mono}`;
ctx.fillStyle = '#55585e';
ctx.textAlign = 'right';
ctx.fillText(right, W - L.margin * k, base);
if (state.title) {
ctx.font = `bold ${headFs}px ${FONTS[o.font]}`;
ctx.fillStyle = '#16181b';
ctx.textAlign = 'left';
ctx.fillText(state.title, L.margin * k, base);
}
ctx.strokeStyle = '#c9c6bd';
ctx.lineWidth = Math.max(1, 0.75 * k);
ctx.beginPath();
ctx.moveTo(L.margin * k, (L.margin + L.headH) * k);
ctx.lineTo(W - L.margin * k, (L.margin + L.headH) * k);
ctx.stroke();
const start = sheetIndex * L.perSheet;
const items = state.frames.slice(start, start + L.perSheet);
for (let i = 0; i < items.length; i++) {
const cell = L.cells[i];
const frame = items[i];
// 胶片条:图片背后是带齿孔的深色条带
if (cell.strip) {
const b = cell.img;
const hole = b.h * 0.08;
ctx.fillStyle = '#16181b';
ctx.fillRect(b.x * k, b.y * k, b.w * k, b.h * k);
ctx.fillStyle = '#ffffff';
for (let hx = b.x + hole; hx + hole < b.x + b.w; hx += hole * 2) {
ctx.fillRect(hx * k, (b.y + hole * 0.3) * k, hole * k, hole * 0.5 * k);
ctx.fillRect(hx * k, (b.y + b.h - hole * 0.8) * k, hole * k, hole * 0.5 * k);
}
}
const area = cell.strip
? { x: cell.img.x, y: cell.img.y + cell.img.h * 0.12, w: cell.img.w, h: cell.img.h * 0.76 }
: cell.img;
const rect = fitContain(frame.w, frame.h, area);
placed.push({ frame, rect });
const px = { x: Math.round(rect.x * k), y: Math.round(rect.y * k), w: Math.round(rect.w * k), h: Math.round(rect.h * k) };
if (mode === 'overlay') {
ctx.clearRect(px.x, px.y, px.w, px.h);
} else {
if (!frame.thumbPic) frame.thumbPic = await decodeImage(frame.thumb);
ctx.drawImage(frame.thumbPic, px.x, px.y, px.w, px.h);
}
if (!cell.strip) {
ctx.strokeStyle = '#16181b';
ctx.lineWidth = Math.max(1, 0.9 * k);
ctx.strokeRect(cell.img.x * k, cell.img.y * k, cell.img.w * k, cell.img.h * k);
}
drawFrameText(ctx, o, cell, frame, start + i, k);
}
return placed;
}
这里的mode参数是关键技巧:
- 在
'preview'模式下,图片直接从缩略图绘制。 - 在
'overlay'模式下,图片区域会被清除为透明,并返回每张图片应该放置的位置。
为什么这样做,在 PDF 那部分你会看到。对于胶片条,先绘制深色底带和齿孔,图片则放在底带中间。
预览分镜表
预览时按画布在屏幕上的实际宽度绘制当前分镜表,并乘以设备像素比(devicePixelRatio),保证在高分屏上依然清晰:
// ---------- Preview ----------
let previewTimer = null;
function drawPreviewSoon() {
clearTimeout(previewTimer);
previewTimer = setTimeout(drawPreview, 150);
}
async function drawPreview() {
if (!state.frames.length) return;
const o = state.opts;
const L = computeLayout(o, state.frames.length);
state.sheet = Math.min(state.sheet, L.sheets - 1);
// Draw at the canvas's on-screen width, sharp on high-density screens
const cssWidth = els.canvas.clientWidth || 600;
const k = (cssWidth * Math.min(2, window.devicePixelRatio || 1)) / L.W;
els.canvas.width = Math.round(L.W * k);
els.canvas.height = Math.round(L.H * k);
await drawSheet(els.canvas.getContext('2d'), o, L, state.sheet, k, 'preview');
els.sheetInfo.textContent = `Sheet ${state.sheet + 1} of ${L.sheets}`;
els.prevBtn.disabled = state.sheet === 0;
els.nextBtn.disabled = state.sheet >= L.sheets - 1;
}
els.prevBtn.addEventListener('click', () => { state.sheet--; drawPreview(); });
els.nextBtn.addEventListener('click', () => { state.sheet++; drawPreview(); });
设备像素比上限设为 2。如果是 3 倍屏的手机,画布尺寸会达到 CSS 盒子的九倍,既慢又看不出差别。
用 IndexedDB 保存草稿
给 30 个画面写描述要花不少时间,万一不小心刷新页面导致工作丢失,会让人很郁闷。所以这个工具会在用户操作时自动保存草稿。
这里的 localStorage 派不上用场。它只能存字符串,而且大多数浏览器限制在 5 MB 左右,还装不下两张手机照片。IndexedDB 可以直接存 Blob 对象,可用空间也大得多。
打开数据库
// ---------- Draft in IndexedDB ----------
const DB_NAME = 'image-storyboard';
let dbPromise = null;
function openDb() {
if (!dbPromise) {
dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 1);
req.onupgradeneeded = () => req.result.createObjectStore('drafts');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
return dbPromise;
}
async function dbRun(mode, work) {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction('drafts', mode);
const result = work(tx.objectStore('drafts'));
tx.oncomplete = () => resolve(result && result.result);
tx.onerror = () => reject(tx.error);
});
}
dbRun 用 Promise 封装了事务,事务完成时才 resolve,也就是说只有数据真正落盘后,写入才算完成。
保存草稿
let saveTimer = null;
function saveDraftSoon() {
clearTimeout(saveTimer);
saveTimer = setTimeout(saveDraft, 800);
}
function saveDraft() {
const draft = {
title: state.title,
savedAt: Date.now(),
frames: state.frames.map(({ blob, name, title, desc, dur }) => ({ blob, name, title, desc, dur })),
};
return dbRun('readwrite', (store) => store.put(draft, 'current')).catch(() => {
els.resultText.textContent = 'This browser could not save a draft. Download the PDF before closing the tab.';
});
}
草稿里保存的是原始图片文件的 Blob,以及用户输入的文本。缩略图和 object URL 不存,因为它们随时可以由原图重新生成。
saveDraftSoon 会等用户停顿 800 毫秒后才触发保存,避免每敲一个键就写一次数据库。
如果保存失败(比如在禁用存储的隐私窗口中),工具会提示用户在关闭标签页前先下载 PDF,而不是默默失败。
恢复草稿
页面加载时,工具会检查是否有草稿,并询问用户是否恢复:
async function checkDraft() {
try {
const draft = await dbRun('readonly', (store) => store.get('current'));
if (!draft || !draft.frames.length) return;
els.draftText.textContent = `你有一份已保存的分镜,包含 ${draft.frames.length} 帧。`;
els.draftBar.hidden = false;
} catch (err) { /* 没有可用的存储,无需恢复 */ }
}
els.restoreBtn.addEventListener('click', async () => {
const draft = await dbRun('readonly', (store) => store.get('current'));
for (const saved of draft.frames) {
const file = new File([saved.blob], saved.name, { type: saved.blob.type });
const frame = await makeFrame(file);
Object.assign(frame, { title: saved.title, desc: saved.desc, dur: saved.dur });
state.frames.push(frame);
}
state.title = draft.title || '';
els.boardTitle.value = state.title;
showWorkspace();
renderFrameList();
drawPreview();
});
els.discardBtn.addEventListener('click', () => {
dbRun('readwrite', (store) => store.delete('current'));
els.draftBar.hidden = true;
});
每个已保存的 Blob 都会重新经过 makeFrame 处理,这样恢复出来的帧就能重新生成缩略图,然后再把之前保存的文字信息填回去。
用 PDF-lib 生成 PDF
生成 PDF 最简单的方式,是把每张完整的页面(连同图片)整体画到 canvas 上,再作为一张图片放进 PDF。这样做可行,但会面临一个两难选择:
- 存成 PNG,照片会让文件体积暴增。
- 存成 JPEG,文字和细线条的边缘会发虚。
所以这个工具在每页上采用了两层结构:
- 图片存成 JPEG,每张按用户选择的分辨率输出,精确放在对应的框内
- 文字、标注和线条合并为一张 300 dpi 的透明 PNG,覆盖在整个页面上层
JPEG 适合照片,PNG 能保持文字清晰。这也是 drawSheet 之所以提供 'overlay' 模式的原因——它会在图片位置留下空洞。
把每张图片转成 JPEG
// ---------- 生成 PDF ----------
async function imageAsJpeg(frame, widthPx, heightPx) {
const pic = await decodeImage(frame.blob);
const c = document.createElement('canvas');
c.width = Math.max(1, Math.round(widthPx));
c.height = Math.max(1, Math.round(heightPx));
const ctx = c.getContext('2d');
ctx.fillStyle = '#ffffff'; // 透明 PNG 加上白色背景
ctx.fillRect(0, 0, c.width, c.height);
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(pic, 0, 0, c.width, c.height);
if (pic.close) pic.close();
const blob = await canvasToBlob(c, 'image/jpeg', 0.9);
return new Uint8Array(await blob.arrayBuffer());
}
图片是从原始文件重新解码的,而不是用缩略图,这样可以保证 PDF 的完整画质。
构建页面
async function buildPdf() {
const { PDFDocument } = PDFLib;
const o = state.opts;
const L = computeLayout(o, state.frames.length);
const dpi = Number(els.quality.value);
const k = 300 / 72; // 文字层按 300 dpi
const pdf = await PDFDocument.create();
for (let s = 0; s < L.sheets; s++) {
els.resultText.textContent = `正在生成第 ${s + 1} / ${L.sheets} 页...`;
const page = pdf.addPage([L.W, L.H]);
// 1. 文字、标签和线条先画在 canvas 上,图片的位置留出空缺
const c = document.createElement('canvas');
c.width = Math.round(L.W * k);
c.height = Math.round(L.H * k);
const placed = await drawSheet(c.getContext('2d'), o, L, s, k, 'overlay');
// 2. 每张图片按选定分辨率导出为 JPEG,填入对应空缺
for (const { frame, rect } of placed) {
const scale = Math.min(1, (rect.w * dpi / 72) / frame.w);
const jpg = await pdf.embedJpg(await imageAsJpeg(frame, frame.w * scale, frame.h * scale));
// PDF 的 y 轴从页面底部算起,canvas 的 y 轴从顶部算起
page.drawImage(jpg, { x: rect.x, y: L.H - rect.y - rect.h, width: rect.w, height: rect.h });
}
// 3. 把文字层叠加在最上面,作为一张覆盖整页的透明 PNG
const pngBlob = await canvasToBlob(c, 'image/png');
const png = await pdf.embedPng(new Uint8Array(await pngBlob.arrayBuffer()));
page.drawImage(png, { x: 0, y: 0, width: L.W, height: L.H });
c.width = 1; // 释放 canvas 内存
}
pdf.setTitle(state.title || 'Storyboard');
return pdf.save();
}
每页的处理流程如下:
addPage([L.W, L.H])以点为单位创建页面,与布局使用相同的单位。- 文字层按
k = 300 / 72绘制,即每英寸 300 像素。 - 每张图片根据其在页面上的尺寸缩放到选定的 dpi。一张 4000 像素的照片如果页面上只显示 2 英寸宽,300 dpi 下只需要 600 像素,因此文件体积可以保持很小。
Math.min(1, ...)确保小图片永远不会被放大。 - 先绘制图片,再叠加透明 PNG。
注意 y 坐标的写法:L.H - rect.y - rect.h。布局和 canvas 的 y 坐标是从页面顶部开始计算的,但 PDF 是从底部开始。忘了这个翻转,所有图片就会在页面里上下颠倒——这是 PDF-lib 的经典错误。
用完后设置 c.width = 1 可以立即释放 canvas 内存,在手机上导出多张分镜时这一点很重要。
重命名与下载
文件名来自文本框输入,并会清理掉文件名中不允许出现的字符:
// ---------- Download ----------
function cleanFileName(name) {
const n = name.trim().replace(/\.pdf$/i, '').replace(/[\\/:*?"<>|]+/g, '-');
return (n || 'storyboard') + '.pdf';
}
function formatSize(bytes) {
return bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
els.downloadBtn.addEventListener('click', async () => {
if (!state.frames.length) return;
els.downloadBtn.disabled = true;
try {
const bytes = await buildPdf();
const name = cleanFileName(els.fileName.value);
const url = URL.createObjectURL(new Blob([bytes], { type: 'application/pdf' }));
const a = document.createElement('a');
a.href = url;
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 60000);
els.resultText.textContent = `Saved ${name} (${formatSize(bytes.length)}).`;
} catch (err) {
console.error(err);
els.resultText.textContent = 'The PDF could not be made. Try fewer images or the 150 dpi setting.';
} finally {
els.downloadBtn.disabled = false;
}
});
object URL 会在一分钟后被释放,这段时间足够启动下载,之后内存也会随之回收。生成 PDF 期间按钮会被禁用,避免双击触发两次导出。
最后,“重新开始”会清空状态、草稿和对象 URL。文件最后两行代码会在页面加载时读取默认设置并检查是否有已保存的草稿:
// ---------- Start over ----------
els.resetBtn.addEventListener('click', () => {
clearTimeout(saveTimer);
state.frames.forEach((f) => URL.revokeObjectURL(f.thumbUrl));
state.frames = [];
state.title = '';
state.sheet = 0;
state.lastDeleted = null;
els.boardTitle.value = '';
els.undoBtn.hidden = true;
els.resultText.textContent = '';
els.workspace.hidden = true;
els.uploadArea.hidden = false;
dbRun('readwrite', (store) => store.delete('current')).catch(() => {});
});
readSettings();
checkDraft();
演示:图像分镜工具的使用流程
下面是成品工具的完整使用流程。
第 1 步:添加图片
把整个文件夹的图片拖到页面上,或点击 Select images。图片会按文件名排序,所以 shot-1、shot-2……shot-10 会按正确顺序排列。
第 2 步:调整帧顺序
把缩略图拖到另一张卡片上,或使用上下移动按钮。
第 3 步:填写分镜脚本
为每个帧填写标题、描述和时长。总时长会随输入实时更新。
第 4 步:选择布局
可以选择每页单帧、网格、带备注的分镜面板或胶片条布局,然后设置纸张大小和方向。
第 5 步:检查预览
下载前可以用上一页、下一页按钮浏览每一页的效果。
第 6 步:下载 PDF
输入文件名,选择图片质量,然后点击 Download PDF。
性能优化建议
屏幕上的一切都用缩略图。 用户输入和调整设置时,预览会频繁重绘。绘制 1000 像素的缩略图而不是完整照片,才能保持流畅。只有导出 PDF 时才处理原始文件。
缩略图只解码一次。 在 drawSheet 中,每帧的已解码缩略图都缓存在 frame.thumbPic 里,切换画板时不用重复解码同一张图。
逐张处理图片。 用 Promise.all 同时读取或导出几十张大照片,内存占用可能超出手机的承受能力。用一个带 await 的简单 for 循环,每次只在内存中保留一张大图。
按页面尺寸而非相机分辨率来定图片大小。 PDF 中图片的像素尺寸应取决于它在页面上的大小和所选的 dpi。buildPdf 里的这一行代码,往往就是 3 MB 和 60 MB 文件的差别所在。
及时释放内存。 用完 ImageBitmap 后调用 close(),把用过的 canvas 宽度设为 width = 1,并撤销不再需要的 object URL。
常见错误
手机照片方向错误。 如果读取图片尺寸或绘制时没有应用 EXIF 方向信息,竖拍照片会变成横的。应使用 createImageBitmap(blob, { imageOrientation: 'from-image' })。
图片被拉伸变形。 直接用 drawImage(img, x, y, cellW, cellH) 把图片画进格子里会拉伸变形。一定要先用 fitContain 这类 "contain" 计算来适配尺寸。
文件顺序错乱。 文件选择器或拖放返回的文件顺序无法保证。需要自己排序,并且要用数字排序,这样 shot-10 才能排在 shot-9 后面。
忘了翻转 y 轴。Canvas 的 y 轴从顶部往下增长,PDF 的 y 轴从底部往上增长。用 PDF-lib 放置元素时需要用 pageHeight - y - height 进行转换。
透明 PNG 出现黑色背景。JPEG 不支持透明度。把 PNG 绘制到画布并保存为 JPEG 前,先用白色填充画布。
用户输入时重建列表。每次按键都重新渲染输入框会导致光标移动、失去焦点。在 input 事件中更新状态,只在顺序变化时才重建列表。
画布尺寸过大。浏览器对画布尺寸有限制,手机上的限制更严格。A3 页面在 300 dpi 下约为 4960 × 3508 像素,这个没问题,但别再乘上设备像素比。
结语
在本教程中,你构建了一个浏览器工具,可以把一组图片转换成分镜表和可打印的 PDF。
你学会了如何用 createImageBitmap 应用手机照片的旋转信息、按数字顺序对文件名排序、让布局计算保持 PDF 点单位以确保预览和 PDF 始终一致、在画布上换行绘制文本、用 IndexedDB 保存图片和文本的草稿,以及用 PDF-lib 构建一个两层 PDF——文字清晰、图片紧凑。
全部功能都在浏览器本地运行,图片不会离开用户的设备。
你可以在 Image Storyboard to PDF 体验完整版,它还支持更多选项,比如表单底色、手写字体、每帧单独一份 PDF 以及 PNG 导出。