About this course
<p>PDF files are commonly used to share presentations, scripts, reports, lesson materials, comic pages, and other visual documents. But reviewing a long PDF page by page can take time, especially when someone needs to understand the overall sequence of scenes, slides, or visual content.</p>
<p>A storyboard provides a more organized way to review this information. Instead of opening every page individually, users can arrange selected PDF pages into a single sheet containing multiple panels. Each panel can include a page number, a caption, and a visual preview of the original page.</p>
<p>In this tutorial, we'll explore how to build a browser-based PDF Storyboard Generator using JavaScript, PDF.js, and PDF-lib.</p>
<p>The application takes pages from an existing PDF and arranges them into printable storyboard sheets. Users can choose different layouts, select the pages they want to include, add captions, customize the appearance, preview the result, and download the finished storyboard.</p>
<p>The application runs directly in the browser. The uploaded PDF is processed locally rather than uploaded to the website's server.</p>
<p>We'll examine the implementation step by step, including PDF rendering, page selection, caption handling, layout calculations, preview generation, and file export.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><a href="#heading-what-is-a-pdf-storyboard-generator">What Is a PDF Storyboard Generator?</a></li>
<li><a href="#heading-main-features-of-the-application">Main Features of the Application</a></li>
<li><a href="#heading-understanding-the-browser-based-workflow">Understanding the Browser-Based Workflow</a></li>
<li><a href="#heading-project-setup">Project Setup</a></li>
<li><a href="#heading-libraries-used">Libraries Used</a></li>
<li><a href="#heading-preparing-the-html-workspace">Preparing the HTML Workspace</a></li>
<li><a href="#heading-selecting-the-main-dom-elements">Selecting the Main DOM Elements</a></li>
<li><a href="#heading-defining-important-configuration-values">Defining Important Configuration Values</a></li>
<li><a href="#heading-understanding-the-application-state">Understanding the Application State</a></li>
<li><a href="#heading-uploading-a-pdf">Uploading a PDF</a></li>
<li><a href="#heading-loading-the-pdf-with-pdfjs">Loading the PDF with PDF.js</a></li>
<li><a href="#heading-measuring-pdf-pages">Measuring PDF Pages</a></li>
<li><a href="#heading-rendering-pdf-pages-on-canvas">Rendering PDF Pages on Canvas</a></li>
<li><a href="#heading-reusing-rendered-page-previews">Reusing Rendered Page Previews</a></li>
<li><a href="#heading-reading-text-from-pdf-pages">Reading Text from PDF Pages</a></li>
<li><a href="#heading-filling-empty-captions-automatically">Filling Empty Captions Automatically</a></li>
<li><a href="#heading-updating-the-preview">Updating the Preview</a></li>
<li><a href="#heading-choosing-the-storyboard-layout">Choosing the Storyboard Layout</a></li>
<li><a href="#heading-controlling-rows-columns-and-paper-size">Controlling Rows, Columns, and Paper Size</a></li>
<li><a href="#heading-selecting-pdf-pages-and-their-order">Selecting PDF Pages and Their Order</a></li>
<li><a href="#heading-adding-panel-labels">Adding Panel Labels</a></li>
<li><a href="#heading-drawing-the-storyboard-sheet">Drawing the Storyboard Sheet</a></li>
<li><a href="#heading-updating-the-live-preview">Updating the Live Preview</a></li>
<li><a href="#heading-adjusting-caption-appearance">Adjusting Caption Appearance</a></li>
<li><a href="#heading-navigating-between-storyboard-sheets">Navigating Between Storyboard Sheets</a></li>
<li><a href="#heading-reading-scanned-pages-with-ocr">Reading Scanned Pages with OCR</a></li>
<li><a href="#heading-generating-the-final-storyboard-pdf">Generating the Final Storyboard PDF</a></li>
<li><a href="#heading-downloading-png-storyboard-sheets">Downloading PNG Storyboard Sheets</a></li>
<li><a href="#heading-downloading-individual-page-pictures">Downloading Individual Page Pictures</a></li>
<li><a href="#heading-performance-and-important-considerations">Performance and Important Considerations</a></li>
<li><a href="#heading-demo-pdf-storyboard-generator">Demo: PDF Storyboard Generator</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h2 id="heading-what-is-a-pdf-storyboard-generator">What Is a PDF Storyboard Generator?</h2>
<p>A PDF Storyboard Generator converts selected pages from an existing PDF into a structured visual sheet.</p>
<p>For example, imagine that you have a PDF containing 20 presentation slides. Instead of sharing all 20 slides separately, you can arrange them into a storyboard containing three columns and two rows per sheet.</p>
<p>Each panel displays a scaled version of its original PDF page. You can also add a number or label and write a short description underneath.</p>
<p>The same approach can be useful for:</p>
<ul>
<li>Reviewing presentation slides.</li>
<li>Organizing comic pages.</li>
<li>Planning video scenes.</li>
<li>Preparing visual scripts.</li>
<li>Reviewing scanned documents.</li>
<li>Creating printable teaching materials.</li>
<li>Organizing page sequences for creative projects.</li>
</ul>
<p>The application doesn't create new scenes or draw illustrations. It arranges existing PDF pages into a storyboard layout.</p>
<h2 id="heading-main-features-of-the-application">Main Features of the Application</h2>
<p>Before writing the code, let's understand the features we're going to implement.</p>
<h3 id="heading-1-pdf-upload">1. PDF Upload</h3>
<p>Users can select a PDF from their device or drag and drop it into the upload area.</p>
<h3 id="heading-2-multiple-storyboard-layouts">2. Multiple Storyboard Layouts</h3>
<p>The application supports three layouts:</p>
<ul>
<li>Grid: Arranges pages in rows and columns.</li>
<li>Panels with notes: Provides space for written notes alongside the page panels.</li>
<li>Film strip: Arranges pages in a film-strip style.</li>
</ul>
<h3 id="heading-3-page-selection-and-ordering"><strong>3. Page Selection and Ordering</strong></h3>
<p>Users can choose which PDF pages should appear in the storyboard. They can also specify a custom order rather than following the original PDF sequence.</p>
<h3 id="heading-4-captions">4. Captions</h3>
<p>Users can type captions manually, fill empty captions using extracted PDF page text, or use OCR to read text from scanned pages.</p>
<h3 id="heading-5-customization">5. Customization</h3>
<p>Users can customize the paper size, orientation, number of columns and rows, panel labels, sheet colors, caption font, text size, spacing, and panel frames.</p>
<h3 id="heading-6-live-preview">6. Live Preview</h3>
<p>The application generates a preview of the storyboard sheets. Users can review the arrangement before downloading the final output.</p>
<p><strong>7. Multiple Export Formats</strong></p>
<p>Users can export their work as:</p>
<ul>
<li>A PDF storyboard.</li>
<li>PNG storyboard sheets.</li>
<li>A ZIP file containing individual page pictures.</li>
</ul>
<p>The PDF export preserves original page content as vectors when possible, helping maintain sharp text and graphics.</p>
<h2 id="heading-understanding-the-browser-based-workflow">Understanding the Browser-Based Workflow</h2>
<p>The application follows a straightforward process.</p>
<ol>
<li>The user selects or drops a PDF file into the upload area.</li>
<li>PDF.js loads the document and renders its pages.</li>
<li>The application displays page previews and caption fields.</li>
<li>The user selects pages, changes their order, and enters captions.</li>
<li>The user configures the storyboard layout and appearance.</li>
<li>JavaScript calculates the position and dimensions of every panel.</li>
<li>The preview is updated to reflect the selected settings.</li>
<li>PDF-lib and browser canvas functionality are used to generate the requested output.</li>
<li>The browser downloads the finished file.</li>
</ol>
<p><strong>PDF Upload Workspace</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/1d23ecf5-1677-4549-84e1-03a08e8943eb.png" alt="PDF Storyboard Generator upload workspace with a drag-and-drop area and PDF selection button." style="display: block;" width="600" height="400" loading="lazy">
<p><strong>Panels and Captions</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/996d2382-8020-4ddc-b1b8-5956ec86bbdd.png" alt="PDF pages displayed in a storyboard workspace with page previews, caption fields, and OCR controls." style="display: block;" width="600" height="400" loading="lazy">
<p><strong>Board Settings and Preview</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/882dbe78-4c8c-4d21-aa64-6519fea5a4bf.png" alt="Storyboard settings panel showing layout, page selection, paper size, orientation, captions, and a live preview." style="display: block;" width="600" height="400" loading="lazy">
<p><strong>Final Storyboard Preview</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/74d7c855-7a6f-44f0-83c2-09efe38a5c3c.png" alt="Final PDF storyboard preview with three numbered panels and captions arranged on an A4 landscape sheet." style="display: block;" width="600" height="400" loading="lazy">
<p><strong>Downloaded PDF</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/64397878-ad42-46eb-b5b6-d39fbf104335.png" alt="Downloaded PDF storyboard containing three arranged panels with page numbers and captions." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-project-setup">Project Setup</h2>
<p>Before writing the JavaScript functionality, we need to understand how the application is organized.</p>
<p>The PDF Storyboard Generator combines HTML, CSS, and JavaScript. HTML provides the interface, CSS controls the appearance of the workspace and storyboard sheets, and JavaScript handles PDF processing, page previews, captions, layout calculations, and downloads.</p>
<p>For a standalone development project, we can organize the files like this:</p>
<pre><code class="language-text">pdf-storyboard-generator/
├── index.html
├── style.css
├── script.js
└── assets/
</code></pre>
<p>The <code>index.html</code> file contains the application structure, including the upload area, page panels, caption fields, board settings, and preview.</p>
<p>The <code>style.css</code> file controls the appearance of the application. It defines the workspace, panel grid, form controls, buttons, preview area, and responsive layout.</p>
<p>The <code>script.js</code> file contains the main application logic. It loads PDF documents, renders their pages, manages captions, processes OCR requests, calculates storyboard layouts, and generates downloadable files.</p>
<p>The <code>assets</code> directory can hold any additional local resources required by the project.</p>
<p>In the existing AllInOneTools implementation, these components are integrated into the website's tool page rather than being deployed as four separate files. The same separation of responsibilities still applies.</p>
<h2 id="heading-libraries-used">Libraries Used</h2>
<p>The application uses three main JavaScript libraries or browser technologies for its PDF and image-processing workflow.</p>
<h3 id="heading-1-pdfjs">1. PDF.js</h3>
<p>We'll use PDF.js to open PDF documents and render their pages inside the browser.</p>
<p>It allows JavaScript to access individual pages, retrieve their dimensions, create viewports, and draw page content onto an HTML canvas.</p>
<p>For this project, PDF.js is responsible for the original PDF page previews.</p>
<p>The application uses the PDF.js document API to retrieve pages and render them at the required dimensions. This is important because the storyboard must display a visual representation of each selected page before the user downloads the finished sheet.</p>
<h3 id="heading-2-pdf-lib">2. PDF-lib</h3>
<p>We'll use PDF-lib used when generating the final PDF document.</p>
<p>PDF.js and PDF-lib have different responsibilities. PDF.js reads and renders the source document, while PDF-lib creates the new storyboard PDF.</p>
<p>The application uses PDF-lib to create output pages, embed the original PDF pages when possible, and place the finished storyboard elements onto those pages.</p>
<p>Keeping the original PDF pages as vector content helps preserve text and line quality when users zoom in or print the exported document. The implementation also uses a raster-based fallback for password-protected PDFs that can't be loaded through the regular PDF embedding path.</p>
<h3 id="heading-3-tesseractjs">3. Tesseract.js</h3>
<p>This library provides optional Optical Character Recognition (OCR).</p>
<p>Not every PDF contains selectable text. A scanned document, for example, may contain photographs of printed pages instead of actual text data.</p>
<p>When the user selects the OCR option, the application loads Tesseract.js and its English language data. It then processes eligible scanned pages and attempts to extract their text for captions.</p>
<p>OCR is optional, so users don't need to load the OCR engine when they only want to arrange PDF pages and write captions manually.</p>
<p>The following example illustrates how a standalone project could include the libraries through CDN scripts:</p>
<pre><code class="language-html"><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>
</code></pre>
<p>For a production application, use library versions that you've tested and configure the matching PDF.js worker file as required by your chosen PDF.js build.</p>
<h3 id="heading-why-these-libraries-work-together">Why These Libraries Work Together</h3>
<p>Each library has a separate responsibility.</p>
<p>PDF.js reads the source PDF and renders its pages. Tesseract.js optionally extracts text from scanned pages. PDF-lib creates the final storyboard PDF.</p>
<p>JavaScript connects these operations and manages the user interface.</p>
<p>This separation makes the application easier to understand and maintain. It also avoids using one library for tasks that are better handled by another.</p>
<h2 id="heading-preparing-the-html-workspace">Preparing the HTML Workspace</h2>
<p>The interface needs several areas to support the complete storyboard workflow.</p>
<p>The upload area accepts the source PDF. The panel area displays rendered pages and caption fields. The board settings area contains layout and appearance controls. Finally, the preview area displays the generated storyboard sheets.</p>
<p>A simplified HTML structure looks like this:</p>
<pre><code class="language-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">
Select a PDF
</button>
</div>
<div id="aiot-sb-grid"></div>
<div id="aiot-sb-settings"></div>
<div id="aiot-sb-preview"></div>
</section>
</code></pre>
<p>This is an illustrative structure showing the main interface areas. The actual application contains additional controls, accessibility attributes, and elements for its different operations.</p>
<p>The <code>aiot-sb</code> container provides a common root for the application. JavaScript can use this root to find the relevant interface elements without searching the entire website.</p>
<h2 id="heading-selecting-the-main-dom-elements">Selecting the Main DOM Elements</h2>
<p>JavaScript needs references to the interface elements before it can respond to user actions.</p>
<p>The application starts by finding its root container and checking whether initialization has already taken place:</p>
<pre><code class="language-javascript">var root = document.getElementById("aiot-sb");
if (!root || root.getAttribute("data-aiot-sb-ready")) {
return;
}
root.setAttribute("data-aiot-sb-ready", "1");
</code></pre>
<p>This prevents the application from initializing again if the script is executed more than once.</p>
<p>The implementation uses a small helper to find elements inside the application:</p>
<pre><code class="language-javascript">function q(selector) {
return root.querySelector(selector);
}
</code></pre>
<p>For example, <code>q('[data-act="browse"]')</code> finds the button used to open the PDF file picker.</p>
<p>Using a root-specific helper also helps keep the application code organized when the website contains other tools or interface components.</p>
<h2 id="heading-defining-important-configuration-values">Defining Important Configuration Values</h2>
<p>The application needs a few shared configuration values for its layouts and output settings.</p>
<p>For example, it defines supported paper dimensions and font families:</p>
<pre><code class="language-javascript">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"
};
</code></pre>
<p>PDF page dimensions are commonly expressed in points, while some of the storyboard spacing settings are easier to understand in millimeters. The <code>MM</code> conversion value helps translate between these units.</p>
<p>The <code>PAPERS</code> object stores the available page dimensions. The application can use these values when calculating the size of each storyboard sheet.</p>
<p>The <code>PAPER_NAMES</code> object provides readable labels for the corresponding settings.</p>
<p>The application also defines font families for captions and other text:</p>
<pre><code class="language-javascript">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"
};
</code></pre>
<p>These values allow the storyboard renderer to use different text styles without repeating font definitions throughout the code.</p>
<h2 id="heading-understanding-the-application-state">Understanding the Application State</h2>
<p>The application needs to remember information about the currently loaded PDF, its pages, captions, selected layout, and export process.</p>
<p>Instead of relying on individual variables scattered throughout the application, the implementation groups related information into a shared state object.</p>
<p>For example, the application tracks information such as:</p>
<ul>
<li>The loaded PDF document.</li>
<li>The original PDF bytes.</li>
<li>The current filename.</li>
<li>Page captions.</li>
<li>Page preview data.</li>
<li>Loading and export status.</li>
<li>The OCR worker when OCR is active.</li>
<li>Cancellation and loading tokens.</li>
</ul>
<p>The actual implementation manages these values within its application state and uses them throughout the rendering and export functions.</p>
<p>This becomes especially important when the user changes a caption or layout setting. The application needs to know which values have changed so it can update the storyboard preview without unnecessarily reopening the source document.</p>
<h2 id="heading-uploading-a-pdf">Uploading a PDF</h2>
<p>The first step is allowing users to select a PDF from their device. The PDF Storyboard Generator supports two methods: selecting a file through the file picker or dragging and dropping it into the upload area.</p>
<p>The file input uses the following HTML:</p>
<pre><code class="language-html"><input
type="file"
id="aiot-sb-file-input"
accept="application/pdf,.pdf"
>
</code></pre>
<p>Let's understand the <code>accept</code> attribute:</p>
<ul>
<li><code>application/pdf</code> is the MIME type for PDF files.</li>
<li><code>.pdf</code> is the file extension.</li>
</ul>
<p>A MIME type (Multipurpose Internet Mail Extensions type) identifies the format of a file. Browsers use MIME types to describe the kind of content a file contains. For example, <code>application/pdf</code> identifies a PDF document.</p>
<p>The application checks both the MIME type and the filename extension. This is useful because some browsers or operating systems may provide an empty or incorrect MIME type for a selected file.</p>
<p>The actual application uses this helper:</p>
<pre><code class="language-javascript">function isPdf(file) {
return file.type === 'application/pdf' ||
/\.pdf$/i.test(file.name || '');
}
</code></pre>
<p>The first condition checks the file's MIME type. The second checks whether its filename ends with <code>.pdf</code>. The <code>i</code> flag makes the extension check case-insensitive, so filenames such as <code>document.PDF</code> are also accepted.</p>
<p>After validation, both upload methods use the same <code>handleFiles()</code> function. Its initial steps are:</p>
<pre><code class="language-javascript">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("That isn't a PDF. Choose a PDF file.", 'error');
return Promise.resolve();
}
var note = all.length > 1
? ' Only ' + file.name + ' was opened; a storyboard uses one PDF.'
: '';
var token = ++state.loadToken;
var hadDoc = !!state.doc;
var task, bytes;
if (hadDoc) {
ui.fileMeta.textContent = 'Opening ' + file.name + '…';
}
setStatus('Opening ' + file.name + '…', true);
// The remaining function loads and processes the selected PDF.
}
</code></pre>
<p>The function converts the supplied file list into an array and selects the first valid PDF. If no valid PDF is found, it displays an error message and stops.</p>
<p>The <code>loadToken</code> value helps the application distinguish the current loading operation from an older one. This matters when a user selects another document while a previous document is still being processed.</p>
<p>Next, the application waits for the PDF libraries to become available and reads the selected file:</p>
<pre><code class="language-javascript">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;
});
</code></pre>
<p>The <code>file.arrayBuffer()</code> method reads the PDF into browser memory. The application converts the data into a <code>Uint8Array</code> and passes it to PDF.js through <code>getDocument()</code>.</p>
<p>If a password was supplied, it's added to the PDF.js options before the document is opened.</p>
<p>The complete function continues by measuring the PDF pages, checking whether the original pages can be embedded as vectors, updating the application state, and displaying the workspace.</p>
<p>This means the selected PDF is processed in the browser. It isn't uploaded to the website's server.</p>
<h2 id="heading-loading-the-pdf-with-pdfjs">Loading the PDF with PDF.js</h2>
<p>Once a file has been selected, the application reads its contents and passes the data to PDF.js.</p>
<p>The PDF.js loading process returns a document object. This object provides access to the document's pages and metadata.</p>
<p>In the actual application, PDF.js is initialized inside the file-loading process. The application first waits for the PDF libraries to load, reads the selected file, and then passes its data to PDF.js.</p>
<pre><code class="language-javascript">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;
});
</code></pre>
<p>The <code>engine()</code> function ensures that the required PDF libraries are available before processing begins. The application then uses <code>file.arrayBuffer()</code> to read the selected PDF into browser memory.</p>
<p>The <code>Uint8Array</code> constructor converts the file data into a format that PDF.js can process. If the user has supplied a password, it's added to the PDF.js options before the document is opened.</p>
<p>The <code>getDocument()</code> method starts the PDF loading process, and <code>task.promise</code> resolves when PDF.js has successfully opened the document.</p>
<p>The resulting document object provides access to the PDF's pages and metadata. The application stores this document for later operations, including measuring pages, extracting text, rendering previews, and generating the storyboard.</p>
<h2 id="heading-measuring-pdf-pages">Measuring PDF Pages</h2>
<p>Before arranging pages into storyboard panels, the application needs to know their dimensions.</p>
<p>PDF documents can contain pages with different sizes and orientations. Measuring each page helps the layout engine fit it correctly inside its panel.</p>
<p>The actual application uses the <code>pageFacts()</code> function to measure every page and periodically update the interface while processing large PDFs.</p>
<pre><code class="language-javascript">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) {
setStatus(
'Measuring pages: ' + n + ' of ' + 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;
}
</code></pre>
<p>The function starts by creating an empty <code>sizes</code> array. It then loops through all pages using <code>doc.numPages</code> and retrieves each page with <code>doc.getPage(n)</code>.</p>
<p>The <code>getViewport({ scale: 1 })</code> method measures the page at its original scale. The function stores the resulting width and height in the <code>sizes</code> array.</p>
<p>The <code>token</code> parameter helps prevent an outdated loading operation from continuing. If the current token no longer matches <code>state.loadToken</code>, the function stops and returns <code>null</code>.</p>
<p>For larger PDFs, the application updates the loading status after every 25 pages and calls <code>nextFrame()</code> to give the browser an opportunity to update the interface.</p>
<p>Finally, the function returns the array containing the dimensions of all measured pages. These measurements are used later by the storyboard layout calculations.</p>
<h2 id="heading-rendering-pdf-pages-on-canvas">Rendering PDF Pages on Canvas</h2>
<p>The next task is displaying the PDF pages in the workspace.</p>
<p>PDF.js can render an individual page onto an HTML canvas. The canvas provides a visual representation that can be used in the page cards and storyboard preview.</p>
<p>The core rendering function looks like this:</p>
<pre><code class="language-javascript">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;
}
</code></pre>
<p>The function accepts three parameters: <code>n</code> is the PDF page number, while <code>w</code> and <code>h</code> define the available width and height for the preview.</p>
<p>First, <code>state.doc.getPage(n)</code> retrieves the requested PDF page. The function then creates a viewport at scale 1 to obtain the page's original dimensions.</p>
<p>Next, <code>Math.min(w / b.width, h / b.height)</code> calculates a scale that fits the page inside the available space without changing its proportions.</p>
<p>The function creates an HTML canvas and sets its width and height using the calculated viewport dimensions. It fills the canvas with white before rendering the PDF page.</p>
<p>Finally, <code>page.render()</code> draws the page onto the canvas. The function waits for rendering to finish and returns the completed canvas for use in the page previews and storyboard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/fb6abec4-c11f-44e7-b91e-730afd066723.png" alt="PDF Storyboard Generator displaying three PDF page previews rendered on canvas, including text and graphics from the original document." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-reusing-rendered-page-previews">Reusing Rendered Page Previews</h2>
<p>Rendering the same PDF page repeatedly can consume unnecessary processing time. For example, a page might be rendered once for its card preview and again when the storyboard preview is updated.</p>
<p>The application uses a page-picture cache to reuse previously rendered canvases.</p>
<pre><code class="language-javascript">var picCache = new Map();
// For the preview: reuse a page picture when one at least as big is already made.
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;
}
</code></pre>
<p>The application stores rendered page previews in a JavaScript <code>Map</code> called <code>picCache</code>. Each entry contains the rendered canvas and its width.</p>
<p>When <code>cachedPic()</code> is called, it first checks whether a cached image exists for the requested page. If the cached image is at least 92% of the requested width, the application reuses it instead of rendering the page again.</p>
<p>The function moves a reused entry to the end of the Map. This helps the application keep recently used previews available.</p>
<p>If a suitable cached image isn't available, the function calls <code>renderPage()</code> to create a new canvas. It renders the page at 1.15 times the requested width and height, providing some extra resolution for the preview.</p>
<p>To control memory usage, the cache is limited to 60 entries. When that limit is exceeded, the oldest entry is removed, and its canvas width is reduced to 1 to release its drawing resources.</p>
<p>This caching approach avoids unnecessary PDF rendering while keeping memory usage under control, especially when users work with longer documents.</p>
<h2 id="heading-reading-text-from-pdf-pages">Reading Text from PDF Pages</h2>
<p>The storyboard generator can create captions in three ways:</p>
<ol>
<li>The user writes captions manually.</li>
<li>The application extracts text from the PDF.</li>
<li>OCR reads text from scanned pages.</li>
</ol>
<p>For PDFs containing selectable text, PDF.js provides the <code>getTextContent()</code> method.</p>
<p>The actual application uses the <code>readTexts()</code> function to extract and store text from every page in the loaded PDF.</p>
<pre><code class="language-javascript">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());
}
</code></pre>
<p>The function starts by accessing the loaded PDF document through <code>state.doc</code> and creating an empty array called <code>out</code> to store the extracted text.</p>
<p>It then loops through every page and calls <code>getTextContent()</code> to retrieve its text items. The <code>map()</code> method combines each item's text into a single string. It also adds a newline when the PDF.js item indicates the end of a line through <code>hasEOL</code>.</p>
<p>If text extraction fails for a page, the function adds an empty string to the results instead of stopping the entire process.</p>
<p>The <code>token</code> check prevents an older PDF-loading operation from updating the current application state. The function also calls <code>nextFrame()</code> after every 20 pages to give the browser an opportunity to update the interface.</p>
<p>After processing all pages, the extracted text is stored in <code>state.texts</code>. The application then calls <code>refreshTags(board())</code> to update the page information displayed in the workspace.</p>
<p>This stored text is used later when users choose to fill empty captions automatically.</p>
<h2 id="heading-filling-empty-captions-automatically">Filling Empty Captions Automatically</h2>
<p>Once the application has extracted text from the PDF pages, it can use that text to create captions.</p>
<p>The important behaviour is that the application fills only empty caption fields. It doesn't overwrite captions that users have already entered.</p>
<p>The application uses the <code>captionFromText()</code> helper to clean and shorten the extracted text:</p>
<pre><code class="language-javascript">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*$/, '') + '…';
}
</code></pre>
<p>Here's what this function does:</p>
<ol>
<li>Converts the supplied value into a string.</li>
<li>Replaces repeated whitespace with single spaces and removes whitespace from the beginning and end.</li>
<li>Checks whether the text contains enough letters to be useful as a caption.</li>
<li>Returns the complete text if it contains no more than 200 characters.</li>
<li>For longer text, tries to end the caption at a sentence boundary. If there isn't a suitable boundary, it removes the incomplete final word and adds an ellipsis.</li>
</ol>
<p>The application then uses <code>fillFromText()</code> to apply the generated captions to the selected storyboard pages.</p>
<pre><code class="language-javascript">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 || 'No pages on the board.', 'error');
return;
}
toast(
filled
? plural(filled, 'caption', 'captions') +
' filled from the page text. Edit them in the cards below.' +
(scans
? ' ' + plural(scans, 'scanned page has', 'scanned pages have') +
' no text; use OCR for ' + (scans === 1 ? 'it' : 'them') + '.'
: '')
: scans
? 'The empty pages are scans with no text. Use Read with OCR.'
: 'Every panel already has a caption.'
);
}
</code></pre>
<p>The <code>fillFromText()</code> function starts by calling <code>board()</code> to get the currently selected storyboard pages. It uses <code>unique()</code> to avoid processing the same page more than once.</p>
<p>For each selected page, <code>captionFor(n)</code> checks whether a caption already exists. If one is present, the function skips that page so the user's existing caption isn't overwritten.</p>
<p>If the caption is empty, the function retrieves the extracted text from <code>state.texts</code> and passes it to <code>captionFromText()</code>. When useful text is available, it saves the generated caption in <code>state.captions</code> and updates the corresponding textarea in the page card.</p>
<p>If a page has no extractable text and is identified as a scan, the function increases the scanned-page counter. Users can process these pages separately using OCR.</p>
<p>Finally, the application enables captions if necessary, saves the updated preferences, and schedules a preview refresh. It also displays a message showing how many captions were filled or whether OCR is needed.</p>
<p>This allows users to generate starting captions automatically while keeping their manually written descriptions intact.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/ded521e8-6e44-42c4-b02f-115683f40ea3.png" alt="PDF Storyboard Generator showing automatically generated captions below PDF page previews." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-updating-the-preview">Updating the Preview</h2>
<p>Whenever a caption or relevant setting changes, the storyboard preview needs to be refreshed.</p>
<p>The application uses a small scheduling function to avoid redrawing the preview immediately after every individual change:</p>
<pre><code class="language-javascript">function redrawSoon(ms) {
clearTimeout(drawTimer);
drawTimer = setTimeout(
drawPreview,
ms === undefined ? 120 : ms
);
}
</code></pre>
<p>The <code>redrawSoon()</code> function first cancels any previously scheduled preview update using <code>clearTimeout()</code>. It then schedules <code>drawPreview()</code> using <code>setTimeout()</code>.</p>
<p>By default, the application waits 120 milliseconds before refreshing the preview. If another change happens during that time, the previous scheduled redraw is cancelled and a new one is scheduled.</p>
<p>This helps prevent unnecessary repeated rendering when users are typing captions or adjusting settings.</p>
<h3 id="heading-drawing-the-updated-preview">Drawing the Updated Preview</h3>
<p>Once the redraw is scheduled, the <code>drawPreview()</code> function calculates the current storyboard configuration and updates the visible preview canvas.</p>
<p>The following is the actual implementation from the application:</p>
<pre><code class="language-javascript">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 = 'No sheets';
ui.sheetCount.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
? 'Captions leave little room for the pages. Use fewer caption lines, fewer rows or a smaller text size.'
: '';
ui.sheetPos.textContent = 'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets;
ui.sheetCount.textContent =
plural(bd.list.length, 'panel', 'panels') + ' on ' +
plural(L.sheets, 'sheet', '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 = state.sheet * L.per;
var on = bd.list.slice(start, start + L.per);
ui.sheet.setAttribute(
'aria-label',
'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets + ': ' +
(on.length === 1 ? 'page ' + on[0] : 'pages ' + on.join(', '))
);
ui.sheet.setAttribute('data-drawn', String(state.sheet + 1));
}
</code></pre>
<p>The function first checks whether a PDF document is loaded. It then calls <code>board()</code> to retrieve the selected pages and calculate the storyboard layout.</p>
<p>The <code>drawToken</code> value helps prevent an older preview-rendering operation from replacing a newer one. The function also calls <code>refreshTags()</code> and <code>syncPanel()</code> to update the page information and settings panel.</p>
<p>Next, it checks whether the selected board has any errors. If the selection is valid, it calculates the current sheet position and updates the sheet counter, panel count, paper information, and Previous and Next buttons.</p>
<p>The function then calculates the canvas scale using the available preview width and the device pixel ratio. The device pixel ratio is capped at 2 to limit the amount of rendering work.</p>
<p>The <code>drawSheet()</code> function draws the selected storyboard sheet onto a temporary canvas. It receives <code>cachedPic()</code> as a callback so previously rendered PDF page previews can be reused.</p>
<p>After drawing finishes, the function checks whether the operation is still current. If it is, the completed temporary canvas is copied onto the visible preview canvas.</p>
<p>Finally, the temporary canvas is released by reducing its width to 1. The application also updates the preview's accessibility label and records which sheet has been drawn.</p>
<p>This process allows users to see their storyboard changes without downloading a new PDF every time they adjust a caption or setting.</p>
<h2 id="heading-choosing-the-storyboard-layout">Choosing the Storyboard Layout</h2>
<p>The tool supports three layout styles: Grid, Panels with notes, and Film strip. Each layout is designed for a different type of project.</p>
<p>Grid layout arranges PDF pages in rows and columns. It works well for reviewing presentation slides, comic pages, and documents.</p>
<p>Panels with notes provides additional writing space below each page. It's useful for lecture notes, planning, and reviewing visual material.</p>
<p>Film strip arranges pages as a sequence of frames. It's useful for planning videos, reels, and scenes.</p>
<p>The selected layout is stored in the application's state. When users choose a different layout, the application updates the number of columns and rows to provide suitable default settings.</p>
<p>The following excerpt from the actual <code>onField()</code> function handles layout changes:</p>
<pre><code class="language-javascript">if (f === 'layout' && e.type === 'change') {
// sensible panel counts for each layout
if (o.layout === 'notes') {
o.cols = o.orient === 'landscape' ? 2 : 1;
o.rows = o.orient === 'landscape' ? 3 : 4;
}
if (o.layout === 'strip') {
o.cols = 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);
</code></pre>
<p>This code checks which layout the user has selected and adjusts the default column and row counts according to the layout and paper orientation.</p>
<p>For example:</p>
<ul>
<li><strong>Panels with notes:</strong> Uses 2 columns and 3 rows in landscape orientation, or 1 column and 4 rows in portrait orientation.</li>
<li><strong>Film strip:</strong> Uses 4 columns, with 2 rows in landscape orientation or 4 rows in portrait orientation.</li>
<li><strong>Grid:</strong> Uses 3 columns and 2 rows in landscape orientation, or 2 columns and 3 rows in portrait orientation.</li>
</ul>
<p>After updating these values, <code>savePrefs()</code> saves the settings. The application resets the current sheet to the first sheet when the layout changes and calls <code>redrawSoon()</code> to refresh the preview.</p>
<p>Users can still adjust the number of rows and columns manually using the board settings.</p>
<h2 id="heading-controlling-rows-columns-and-paper-size">Controlling Rows, Columns, and Paper Size</h2>
<p>Users can choose between 1 and 6 columns and rows per sheet. This gives them control over how many PDF pages appear together.</p>
<p>The application also supports different paper sizes, including A4, Letter, A3, and a 16:9 slide format. Each format can be displayed in portrait or landscape orientation.</p>
<p>For example, a 3-column and 2-row layout can display six PDF pages on one sheet. A 4-column and 3-row layout can display twelve pages.</p>
<p>The application calculates the number of panels that fit on each sheet by multiplying the selected columns by the selected rows. It then determines how many sheets are needed for all selected pages.</p>
<p>The following function from the actual application calculates the sheet dimensions based on the selected paper size and orientation:</p>
<pre><code class="language-javascript">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];
}
</code></pre>
<p>The <code>PAPERS</code> object contains the dimensions for each supported paper format. The function retrieves the selected dimensions and arranges them according to the chosen orientation.</p>
<p>For portrait orientation, the shorter dimension becomes the width and the longer dimension becomes the height. For landscape orientation, these dimensions are reversed.</p>
<p>The layout calculation then uses the selected rows and columns to determine the number of panels per sheet:</p>
<pre><code class="language-javascript">var per = o.cols * o.rows;
var sheets = Math.max(1, Math.ceil(list.length / per));
</code></pre>
<p>Here, <code>per</code> represents the number of panels on one sheet. The <code>Math.ceil()</code> function rounds up the total number of required sheets so that any remaining pages are placed on an additional sheet.</p>
<p>The layout engine also calculates the available space and positions each panel so that the pages fit within the selected sheet dimensions.</p>
<p>When more pages are selected than can fit on one sheet, the remaining panels are placed on additional sheets.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/6f6ef0dd-5d4b-40db-adfb-265687c4c576.png" alt="PDF Storyboard Generator board settings for choosing layout, columns, rows, paper size, and orientation." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-selecting-pdf-pages-and-their-order">Selecting PDF Pages and Their Order</h2>
<p>Users don't always need every page in the original PDF.</p>
<p>The tool supports using all pages, selecting a page range, or entering a custom page sequence.</p>
<p>For example, a user could enter:</p>
<pre><code class="language-text">1-4, 9, 6
</code></pre>
<p>This creates a storyboard using pages 1, 2, 3, 4, 9, and 6 in that exact order.</p>
<p>The application uses the <code>parseList()</code> function to process the custom page selection and check whether the requested page numbers are valid.</p>
<p>Here is the actual implementation from the application:</p>
<pre><code class="language-javascript">function parseList(text) {
var t = String(text || '').trim();
if (!t) {
return { error: 'Type the pages, for example 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 + '" isn\'t a page number or range.' };
}
var a = parseInt(m[1], 10),
b = m[2] === undefined
? a
: (m[2] === '' ? state.count : parseInt(m[2], 10));
if (a < 1) {
return { error: 'Pages start at 1, so "' + part + '" doesn\'t work.' };
}
if (b < a) {
return { error: '"' + part + '" is backwards. Did you mean ' + b + '-' + a + '?' };
}
if (a > state.count) {
return {
error: 'Page ' + a + ' is past the end; this PDF has ' +
plural(state.count, 'page', 'pages') + '.'
};
}
if (b > state.count) {
return {
error: 'Page ' + b + ' is past the end. Type ' + a +
'- to go to the last page.'
};
}
for (var n = a; n <= b; n++) {
out.push(n);
}
}
if (out.length > 500) {
return { error: 'That\'s ' + out.length + ' panels. Use 500 or fewer.' };
}
return { pages: out };
}
</code></pre>
<p>The function first converts the input into a trimmed string. It then splits the selection using commas, semicolons, or spaces.</p>
<p>The regular expression checks whether each entry is a single page number or a page range. For example, <code>3</code> represents page 3, while <code>2-5</code> represents pages 2 through 5.</p>
<p>The function also supports an open-ended range such as <code>4-</code>. In this case, the application uses <code>state.count</code> as the final page number.</p>
<p>Before adding pages to the result, it checks that page numbers start at 1, ranges are in ascending order, and no requested page exceeds the total number of pages in the PDF.</p>
<p>The application also limits custom selections to 500 panels. If the input is valid, the function returns an array of page numbers in the exact order entered by the user.</p>
<p>The <code>panels()</code> function then uses this result when building the storyboard. If the user selects all pages instead of entering a custom list, it returns every page in the original order.</p>
<p>This feature is helpful when creating a storyboard from selected scenes or important slides without including the entire PDF.</p>
<h2 id="heading-adding-panel-labels">Adding Panel Labels</h2>
<p>Panel labels help users identify individual pages when reviewing or discussing a storyboard.</p>
<p>The application supports different label styles, including numbers, scene labels, original PDF page numbers, and no labels.</p>
<p>For example, a storyboard could display:</p>
<ul>
<li>1, 2, 3, 4</li>
<li>Scene 1, Scene 2, Scene 3</li>
<li>Page 5, Page 6, Page 7</li>
</ul>
<p>Users can select their preferred label style from the Panel label setting.</p>
<p>The application uses the <code>labelFor()</code> function to generate the appropriate label for each panel:</p>
<pre><code class="language-javascript">function labelFor(o, idx, n) {
if (o.label === 'panel') {
return String(idx + 1);
}
if (o.label === 'page') {
return 'Page ' + n;
}
if (o.label === 'scene') {
return 'Scene ' + (idx + 1);
}
return '';
}
</code></pre>
<p>The function accepts three parameters:</p>
<ul>
<li><code>o</code> contains the storyboard settings, including the selected label style.</li>
<li><code>idx</code> represents the panel's position in the selected storyboard sequence. It starts at 0.</li>
<li><code>n</code> represents the original PDF page number.</li>
</ul>
<p>When the user selects the panel number option, <code>idx + 1</code> generates labels such as 1, 2, and 3.</p>
<p>When the user selects the page number option, the function uses <code>n</code> to display the original PDF page number. This is useful when the storyboard contains selected pages in a custom order.</p>
<p>For the scene label option, the function generates labels such as Scene 1, Scene 2, and Scene 3. If the user selects No label, it returns an empty string.</p>
<p>The drawing function uses this result when rendering each panel on the storyboard sheet. This helps users identify individual panels without changing the original PDF pages.</p>
<h2 id="heading-drawing-the-storyboard-sheet">Drawing the Storyboard Sheet</h2>
<p>Once the layout has been calculated, the application draws the storyboard sheet on an HTML canvas.</p>
<p>The drawing process places each selected PDF page inside its calculated panel area. It then adds panel labels and captions according to the user's settings.</p>
<p>The actual application uses the asynchronous <code>drawSheet()</code> function for this process. The following excerpt shows how it prepares the sheet and selects the pages that belong on it:</p>
<pre><code class="language-javascript">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);
</code></pre>
<p>The function receives the canvas context, storyboard settings, selected page list, calculated layout, sheet index, and rendering scale.</p>
<p>It first selects the sheet colors and paints the background. The <code>start</code> variable determines the first panel on the current sheet, while <code>items</code> contains the PDF pages assigned to that sheet.</p>
<p>The following excerpt shows how the application fits each PDF page inside its panel and retrieves its rendered image:</p>
<pre><code class="language-javascript">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);
}
}
}
</code></pre>
<p>The <code>fitPage()</code> function calculates the position and dimensions needed to fit the original PDF page inside the panel without changing its proportions.</p>
<p>The application then uses <code>getPic()</code> to retrieve the rendered page image. If the image is available, <code>drawImage()</code> places it on the storyboard canvas.</p>
<p>The function also supports an overlay mode, which leaves the page picture areas transparent. This is useful when exporting PDFs while preserving the original PDF page content.</p>
<h3 id="heading-drawing-panel-labels-and-captions">Drawing Panel Labels and Captions</h3>
<p>After positioning the page previews, the application draws the panel labels and captions.</p>
<p>The following excerpt from the actual <code>drawSheet()</code> function shows how panel labels and captions are drawn on the canvas:</p>
<pre><code class="language-javascript">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);
});
}
</code></pre>
<p>The <code>labelFor()</code> function generates the selected panel label. The application then uses <code>fillText()</code> to draw it on the canvas.</p>
<p>If captions are enabled and a caption exists, the application uses <code>captionFor()</code> to retrieve it. The <code>wrap()</code> function breaks the caption into lines that fit within the available panel space.</p>
<p>For the Panels with notes layout, the application also draws ruled lines below the caption area. This provides additional writing space for users.</p>
<p>The drawing process combines the original PDF page previews with the labels, captions, frames, and other selected settings to create the completed storyboard sheet.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/4acf38c9-c401-4e11-86d9-cdf62bb67912.png" alt=" Live PDF storyboard preview showing multiple page panels arranged on an A4 landscape sheet." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-updating-the-live-preview">Updating the Live Preview</h2>
<p>One of the most useful features is the live preview. Users can change the layout, paper size, orientation, captions, font, spacing, and other settings without creating a new PDF every time.</p>
<p>The application uses the <code>board()</code> function to calculate the current storyboard configuration before drawing the preview.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">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
};
}
</code></pre>
<p>The function first calls <code>panels()</code> to retrieve the selected PDF pages. If the selection contains an error, it returns that error instead of continuing.</p>
<p>Next, it calls <code>layout()</code> with the current storyboard settings and selected pages. The layout function calculates how the pages should be arranged on the storyboard sheets.</p>
<p>Finally, <code>board()</code> returns an object containing the selected page list and the calculated layout. The <code>drawPreview()</code> function uses this information to render the selected sheet onto a temporary canvas.</p>
<p>Once drawing is complete, the result is copied to the visible preview canvas. The application also updates the sheet counter, such as <code>Sheet 1 of 3</code>, and enables or disables the Previous and Next buttons depending on the current position.</p>
<p>This allows users to move between storyboard sheets and review their changes before downloading the result.</p>
<h2 id="heading-adjusting-caption-appearance">Adjusting Caption Appearance</h2>
<p>Captions help explain what's happening on each page.</p>
<p>The application provides options for enabling captions, selecting a font, changing text size, and controlling the number of caption lines.</p>
<p>Users can also choose whether to display panel frames and sheet numbers.</p>
<p>These settings are stored in <code>state.opts</code>. The <code>syncPanel()</code> function updates the settings controls to match the current application state.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">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 + ' mm';
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 ? 'Frames per strip' : 'Columns';
q('[data-out="rows-label"]').textContent =
strip ? 'Strips per sheet' : 'Rows';
q('[data-field="lines"]').disabled =
!o.captions || o.layout === 'notes';
q('[data-field="frame"]').disabled = strip;
ui.listWrap.hidden = state.pages !== 'list';
}
</code></pre>
<p>The function reads the current options from <code>state.opts</code> and updates the corresponding controls. For example, it sets the selected font, text size, caption line count, and paper settings.</p>
<p>It also handles layout-specific controls. In Film strip mode, the columns and rows labels change to Frames per strip and Strips per sheet. The caption-line control is disabled when captions are turned off or when the Panels with notes layout is selected.</p>
<h3 id="heading-fitting-captions-inside-panels">Fitting Captions Inside Panels</h3>
<p>Long captions need to fit inside the available panel space. The application uses a <code>wrap()</code> helper to divide text into lines based on the available canvas width.</p>
<p>Here's how the actual drawing code uses that helper:</p>
<pre><code class="language-javascript">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);
});
}
</code></pre>
<p>The application selects the font using <code>FONTS[o.font]</code> and calculates the available number of caption lines based on the panel layout.</p>
<p>For regular layouts, the selected <code>o.lines</code> setting controls the maximum number of lines. For Panels with notes, the available space determines how many lines can be displayed.</p>
<p>The <code>wrap()</code> function breaks the caption into lines that fit the available width. The <code>fillText()</code> method then draws each line onto the canvas.</p>
<p>When there isn't enough space for the selected caption settings, the preview displays a warning suggesting fewer caption lines, fewer rows, or a smaller text size.</p>
<p>The application also supports panel frames and sheet numbers. These options are stored in the application state and applied when the storyboard is drawn.</p>
<p>This gives users control over the appearance and readability of their storyboard captions without changing the original PDF.</p>
<h2 id="heading-navigating-between-storyboard-sheets">Navigating Between Storyboard Sheets</h2>
<p>A large PDF may require several storyboard sheets.</p>
<p>The application provides Previous and Next controls so users can review each sheet individually. It also displays the current sheet number and the total number of sheets.</p>
<p>The selected sheet is stored in <code>state.sheet</code>. The application uses this value to determine which storyboard sheet should be displayed.</p>
<h3 id="heading-handling-the-previous-and-next-buttons">Handling the Previous and Next Buttons</h3>
<p>The application handles sheet navigation inside its main click event listener. Here's the actual implementation:</p>
<pre><code class="language-javascript">case 'sheet-prev':
if (state.sheet > 0) {
state.sheet--;
drawPreview();
}
break;
case 'sheet-next':
state.sheet++;
drawPreview();
break;
</code></pre>
<p>When the user clicks Previous, the application checks whether the current sheet number is greater than zero. If it is, <code>state.sheet</code> is decreased by one and <code>drawPreview()</code> refreshes the displayed sheet.</p>
<p>When the user clicks Next, the sheet number is increased and the preview is refreshed.</p>
<p>The application also prevents users from moving beyond the available sheets. Inside <code>drawPreview()</code>, it checks the current sheet position and disables the navigation buttons when necessary:</p>
<pre><code class="language-javascript">state.sheet = clamp(state.sheet, 0, L.sheets - 1);
ui.sheetPos.textContent =
'Sheet ' + (state.sheet + 1) + ' of ' + L.sheets;
q('[data-act="sheet-prev"]').disabled = state.sheet <= 0;
q('[data-act="sheet-next"]').disabled =
state.sheet >= L.sheets - 1;
</code></pre>
<p>The <code>clamp()</code> function keeps the selected sheet within the valid range. The sheet counter displays the current position, starting from 1, and the total number of sheets.</p>
<p>The Previous button is disabled on the first sheet, while the Next button is disabled on the last sheet. This makes it easier to review large documents without displaying every storyboard sheet at once.</p>
<h2 id="heading-reading-scanned-pages-with-ocr">Reading Scanned Pages with OCR</h2>
<p>Some PDF documents contain selectable text, while others contain scanned images of printed pages.</p>
<p>PDF.js can extract text from pages containing actual text data. But it can't directly extract words from a scanned page that contains only an image.</p>
<p>To handle these pages, the application provides an optional OCR feature using Tesseract.js.</p>
<p>When users select Read scanned pages with OCR, the application identifies scanned pages that don't already have captions and processes them using the OCR engine.</p>
<h3 id="heading-loading-tesseractjs-when-needed">Loading Tesseract.js When Needed</h3>
<p>The application loads Tesseract.js only when the user requests OCR. It first checks whether the library is already available and reuses an existing loading promise if the script is still being loaded.</p>
<p>Here's the actual <code>loadTesseract()</code> implementation:</p>
<pre><code class="language-javascript">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;
}
</code></pre>
<p>The function checks <code>window.Tesseract</code> to see whether the OCR library is already available. If it is, the function returns it immediately.</p>
<p>If the library is still loading, the existing <code>tessPromise</code> is returned instead of creating another script request.</p>
<p>Otherwise, the application creates a script element, sets its source to the Tesseract.js library, and adds it to the document. The <code>onload</code> and <code>onerror</code> handlers detect whether loading succeeds or fails.</p>
<h3 id="heading-creating-the-ocr-worker">Creating the OCR Worker</h3>
<p>After the library loads, the application creates a Tesseract worker configured for English text recognition.</p>
<p>The following excerpt comes from the actual <code>runOcr()</code> function:</p>
<pre><code class="language-javascript">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,
'Getting the English language data (first time only)…'
);
} else if (/recogniz/i.test(m.status)) {
setProgress(
(done + (m.progress || 0)) / list.length,
'Reading page ' + list[done] +
' (' + (done + 1) + ' of ' + list.length + ')…'
);
}
}
});
state.worker = w;
checkCancel();
await w.setParameters({ debug_file: '/dev/null' });
</code></pre>
<p>The <code>createWorker()</code> method initializes the OCR engine with the English language data. The worker and core paths tell Tesseract.js where to load its supporting files.</p>
<p>The logger reports language-loading and text-recognition progress. The application uses this information to update its progress indicator while OCR is running.</p>
<h3 id="heading-recognizing-text-from-scanned-pages">Recognizing Text from Scanned Pages</h3>
<p>The application processes each eligible scanned page individually. It renders the page at a higher resolution before sending it to Tesseract.js.</p>
<p>Here's the actual page-processing excerpt from <code>runOcr()</code>:</p>
<pre><code class="language-javascript">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;
}
}
}
</code></pre>
<p>The function retrieves each scanned PDF page and calculates its original dimensions. It then renders the page at approximately 300 DPI, with a maximum dimension of 2,600 pixels.</p>
<p>The <code>pngBytes()</code> helper converts the rendered canvas into PNG image data. Tesseract.js receives this data through <code>w.recognize(png)</code> and returns the recognized text.</p>
<p>The application passes the recognized text to <code>captionFromText()</code> to clean and shorten it. If the result is useful and the page still has no caption, the application saves it in <code>state.captions</code> and updates the corresponding caption field.</p>
<p>The application also checks for cancellation and verifies that the PDF-loading operation is still current before updating the results.</p>
<p>After processing, the application displays a message asking users to review the generated captions. OCR isn't guaranteed to recognize every word correctly. Image quality, handwriting, unusual fonts, and page orientation can affect the result.</p>
<p>Users should review generated captions before downloading the storyboard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/100aaec2-33a2-440a-956e-79d078692589.png" alt="PDF Storyboard Generator showing the OCR option for extracting text from scanned PDF pages." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-generating-the-final-storyboard-pdf">Generating the Final Storyboard PDF</h2>
<p>Once the user is satisfied with the layout and captions, the application can generate the final PDF.</p>
<p>This process uses PDF-lib to create a new PDF document. The application creates one output page for each storyboard sheet. It then places the original PDF pages inside their calculated panel positions and adds the storyboard's titles, labels, and captions.</p>
<h3 id="heading-creating-the-pdf-document">Creating the PDF Document</h3>
<p>The actual application uses the <code>exportPdf()</code> function to generate the storyboard. It first creates a new PDF document and checks whether the original PDF pages can be embedded as vector content.</p>
<p>Here's the first part of the actual implementation:</p>
<pre><code class="language-javascript">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,
'Making sheet ' + (s + 1) + ' of ' + 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;
}
</code></pre>
<p>The function first waits for the required PDF libraries using <code>engine()</code>. It then creates a new PDF document with <code>PDFDocument.create()</code>.</p>
<p>The <code>snap</code> parameter contains a snapshot of the selected layout, options, and page list. The application uses these values so the export follows the settings selected by the user.</p>
<p>The <code>state.vector</code> check determines whether the original PDF can be loaded through PDF-lib for vector embedding. The application also calculates a rendering scale targeting 300 DPI, subject to a maximum pixel limit.</p>
<p>The loop creates one output PDF page for every storyboard sheet. It also calls <code>drawSheet()</code> in <code>overlay</code> mode to prepare the titles, panel labels, and captions that will be placed over the original PDF pages.</p>
<h3 id="heading-preserving-original-pdf-pages">Preserving Original PDF Pages</h3>
<p>When vector embedding is available, the application embeds the original PDF pages rather than converting them into ordinary images.</p>
<p>The following excerpt shows how it handles each selected panel:</p>
<pre><code class="language-javascript">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)
});
}
</code></pre>
<p>The application retrieves the original PDF page using its page number. It then uses <code>embedPage()</code> to embed that page into the new PDF document.</p>
<p>The <code>embedded</code> object caches embedded pages so the same original page doesn't need to be embedded repeatedly.</p>
<p>The code also checks the original page rotation and adjusts its position and dimensions before drawing it. This helps preserve the correct orientation when placing pages inside storyboard panels.</p>
<h3 id="heading-handling-protected-pdfs">Handling Protected PDFs</h3>
<p>If the original PDF can't be embedded as vector content, the application uses a raster fallback.</p>
<p>Here's the actual fallback code:</p>
<pre><code class="language-javascript">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
}
);
}
</code></pre>
<p>In this case, the application renders the original PDF page onto a canvas and converts it into JPEG image data. PDF-lib then embeds the image inside the corresponding storyboard panel.</p>
<p>This fallback is used when vector embedding is unavailable, including protected PDFs that can't follow the normal vector workflow.</p>
<h3 id="heading-adding-captions-and-saving-the-pdf">Adding Captions and Saving the PDF</h3>
<p>After the original page images or vector content have been placed, the application adds the storyboard's titles, labels, captions, and other drawing elements as an overlay.</p>
<p>The following excerpt shows how the overlay is added and the final PDF is saved:</p>
<pre><code class="language-javascript">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();
</code></pre>
<p>After all sheets have been processed, the application checks for cancellation and prepares the final PDF:</p>
<pre><code class="language-javascript">checkCancel();
setProgress(1, 'Saving the PDF…');
out.setTitle(state.title || state.base + ' storyboard');
out.setCreator('AllInOneTools PDF Storyboard');
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(
'Saved ' + plural(L.sheets, 'sheet', 'sheets') +
' as ' + state.base + '-storyboard.pdf.'
);
</code></pre>
<p>The <code>embedPng()</code> method adds the storyboard overlay to the PDF page. This overlay contains the titles, panel labels, captions, and other visual elements.</p>
<p>The application then sets the PDF metadata, saves the document as bytes, and creates a downloadable PDF file.</p>
<p>Progress updates and cancellation checks are used throughout the export process, which is particularly helpful when generating storyboards with multiple sheets.</p>
<p>The final PDF combines the original PDF pages with the storyboard layout and captions, ready to download, share, or print.</p>
<h2 id="heading-downloading-png-storyboard-sheets">Downloading PNG Storyboard Sheets</h2>
<p>Users who don't need a PDF can choose PNG sheets.</p>
<p>The application renders each complete storyboard sheet as a PNG image at 200 DPI. If the storyboard contains only one sheet, the browser downloads a single PNG file. If it contains multiple sheets, the application packages them into a ZIP archive.</p>
<h3 id="heading-exporting-the-storyboard-as-png">Exporting the Storyboard as PNG</h3>
<p>The application handles PNG exports inside the <code>exportJob()</code> function. It first calculates the rendering scale and then processes each storyboard sheet.</p>
<p>The following excerpt shows the actual PNG export implementation:</p>
<pre><code class="language-javascript">var k = exportScale(L, 200, 16e6),
files = [];
for (var s = 0; s < L.sheets; s++) {
checkCancel();
setProgress(
s / L.sheets,
'Drawing sheet ' + (s + 1) + ' of ' + 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
? 'Saved the storyboard as a PNG.'
: 'Saved ' + files.length + ' sheets as PNG files in a ZIP.'
);
</code></pre>
<p>The application uses <code>exportScale()</code> to calculate a suitable rendering scale for 200 DPI while keeping the canvas within a maximum pixel limit of 16 million pixels.</p>
<p>For every sheet, it creates a canvas and calls <code>drawSheet()</code> in <code>full</code> mode. This draws the PDF page previews together with the storyboard titles, labels, captions, and other selected settings.</p>
<p>The <code>pngBytes()</code> helper converts the completed canvas into PNG data. Each image is added to the <code>files</code> array with a filename containing its sheet number.</p>
<p>After processing all sheets, the application checks how many PNG files were generated. A single sheet is downloaded directly as a PNG. Multiple sheets are packaged into a ZIP archive using <code>makeZip()</code>.</p>
<p>This format is useful for sharing storyboard previews in presentations, messages, and other applications.</p>
<h2 id="heading-downloading-individual-page-pictures">Downloading Individual Page Pictures</h2>
<p>The third export option is Page pictures (ZIP).</p>
<p>Unlike the PNG sheets option, this export contains individual pictures of the PDF pages selected for the storyboard.</p>
<p>The application renders each selected page separately and places the resulting PNG files into a ZIP archive.</p>
<h3 id="heading-exporting-individual-pdf-pages">Exporting Individual PDF Pages</h3>
<p>The actual application handles this option inside the <code>exportJob()</code> function. It first creates a unique list of selected pages and then renders each page individually.</p>
<p>Here's the actual implementation:</p>
<pre><code class="language-javascript">var pages = unique(list),
out = [];
for (var i = 0; i < pages.length; i++) {
checkCancel();
setProgress(
i / pages.length,
'Saving page ' + pages[i] +
' (' + (i + 1) + ' of ' + 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(
'Saved ' +
plural(out.length, 'page picture', 'page pictures') +
' in a ZIP.'
);
</code></pre>
<p>The function first calls <code>unique()</code> to remove duplicate page numbers from the selected list.</p>
<p>It then processes each page individually. The <code>getPage()</code> method retrieves the original PDF page, and <code>getViewport()</code> obtains its dimensions.</p>
<p>The scale calculation limits the rendering scale to 2 while keeping the rendered page within approximately 12 million pixels. This helps control memory usage when processing large pages.</p>
<p>The <code>renderPage()</code> function creates the page image, while <code>pngBytes()</code> converts the canvas into PNG data.</p>
<p>Each image is added to the <code>out</code> array with a filename containing both its panel position and original PDF page number. This makes it easier to identify the pictures and maintain their storyboard order.</p>
<p>Finally, <code>makeZip()</code> packages all the generated PNG images into a ZIP archive, which the browser downloads.</p>
<p>This option is useful when users want to work with individual PDF page pictures rather than complete storyboard sheets.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/895e4a25-0a30-4402-a728-d4a51fb97b38.png" alt="Board settings and preview, including the PDF, PNG sheets, and Page pictures download buttons." style="display: block;" width="600" height="400" loading="lazy">
<img src="https://cdn.hashnode.com/uploads/covers/6979d22f93bc273cc33971b1/bacaaf21-ffb9-476d-abca-9b0a0d8dc537.png" alt="Exported storyboard PDF opened in a PDF viewer." style="display: block;" width="600" height="400" loading="lazy">
<h2 id="heading-performance-and-important-considerations">Performance and Important Considerations</h2>
<p>Rendering large PDFs and generating high-resolution images can consume significant browser memory.</p>
<p>The application uses page-preview caching, temporary canvases, progress reporting, and cancellation controls to manage this work.</p>
<p>For better results, use fewer panels per sheet when working with large documents. A3 paper can also provide more room when a storyboard contains many panels.</p>
<p>Remember that layout preferences are remembered on the device, but the PDF and captions remain in the current browser tab. Download your work before closing the tab.</p>
<h2 id="heading-demo-pdf-storyboard-generator">Demo: PDF Storyboard Generator</h2>
<p>You can try the finished application here:</p>
<p><a href="https://allinonetools.net/pdf-storyboard-generator/?utm_source=chatgpt.com">PDF Storyboard Generator – AllInOneTools</a></p>
<p>Upload a PDF, arrange its pages, add captions, customize the storyboard, and download the finished result.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building a PDF Storyboard Generator combines several useful browser technologies into one application.</p>
<p>PDF.js handles PDF loading and page rendering. Tesseract.js provides optional text recognition for scanned pages. PDF-lib creates the final storyboard PDF, while HTML canvas handles previews and image exports.</p>
<p>By connecting these components with JavaScript, we can create a practical tool for organizing presentation slides, comic pages, scripts, and other PDF documents.</p>
<p>The project also demonstrates how browser-based applications can process documents locally without requiring users to upload their files to a server.</p>
<p>With the complete workflow in place, users can turn ordinary PDF pages into organized storyboard sheets that are easier to review, print, and share.</p>