Teams that work with contracts, invoices, forms, and evidence need more than a passive viewer. They need a browser app that can assemble pages from PDFs, image files, and scanners, annotate the result, remove pages, and export a finished document, all without uploading sensitive files to a server.
This tutorial builds that app with Dynamsoft Document Viewer (DDV) v4, Dynamic Web TWAIN (DWT), Vite, and TypeScript.
What you'll build: a client-side document annotation studio that opens PDFs and images, appends additional files instead of replacing the current document, captures pages from a scanner through Dynamic Web TWAIN, creates movable redaction marks and stamps, deletes unwanted pages, and exports to PDF, PNG, JPEG, or TIFF.
Demo: Assemble, Annotate, Redact, and PDF Export in the Browser
The video below shows the complete workflow in action: opening a PDF, appending images and scanned pages, adding a movable redaction mark and an approval stamp, deleting an unwanted page, and exporting the finished document as an editable PDF.
Prerequisites
- Node.js 18+
dynamsoft-document-viewer@^4.0.0- A Dynamsoft Document Viewer license key for production Get a 30-day free trial license
- Optional scanner capture:
- Dynamic Web TWAIN license key
- Dynamic Web TWAIN Service installed/running on the client machine
- A supported TWAIN/WIA/ICA/SANE/eSCL scanner
Optionally create .env.local to pre-fill the license input:
VITE_DDVR_LICENSE="your-document-viewer-license"
VITE_DWT_LICENSE="your-dynamic-web-twain-license"
Step 1: Scaffold the Vite + TypeScript Project
The project has one npm runtime dependency because DWT is loaded lazily from CDN in scanner.ts:
{
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"dynamsoft-document-viewer": "^4.0.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}
Run:
npm install
npm run dev
Then open http://localhost:5173/.
Step 2: License Screen, DDV Init, and the EditViewer
When the app opens, a license screen appears first. The user can paste their own DDV license key or click "Use Default License" to proceed with a built-in trial key. A link to the Dynamsoft trial license page is provided for those who need one. Once a license is chosen, the app hides the license screen and initializes the DDV engine.
import { DDV } from "dynamsoft-document-viewer";
const DEFAULT_LICENSE =
"DLS2eyJ..."; // built-in trial key
const ENV_LICENSE: string | undefined =
import.meta.env.VITE_DDVR_LICENSE;
const ENGINE_RESOURCE_PATH =
"https://cdn.jsdelivr.net/npm/dynamsoft-document-viewer@4.0.0/dist/engine";
async function initDDV(license: string): Promise<void> {
DDV.Core.license = license;
DDV.Core.engineResourcePath = ENGINE_RESOURCE_PATH;
await DDV.Core.init();
DDV.setProcessingHandler("imageFilter", new DDV.ImageFilter());
}
The waitForLicense() function returns a Promise<string> that resolves when the user clicks Activate or Use Default License. The bootstrap sequence awaits it before showing the WASM loading overlay.
The EditViewer is configured with a built-in DDV toolbar and a left thumbnail rail:
const options: EditViewerConstructorOptions = {
container,
uiConfig: buildUiConfig(),
thumbnailConfig: {
visibility: "visible",
position: "left",
size: "204px",
columns: 1,
multiselectMode: false,
},
};
const viewer = new EditViewer(options);
The app keeps file, scan, quick action, and export controls in its own header, while DDV owns the in-canvas editing toolbar. The text-search switch is omitted because it is not part of the required workflow.
Step 3: Append PDFs and Images Instead of Replacing the Document
The key change from a simple viewer is that opening another image should append pages to the active document. DDV supports this through doc.loadSource(source, index).
import { DDV, PdfSource, Source } from "dynamsoft-document-viewer";
async function loadIntoViewer(
handle: EditViewerHandle,
fileData: Blob,
password?: string,
fileName?: string
): Promise<void> {
const { docManager, viewer } = handle;
const existing = viewer.currentDocument;
const doc = existing ?? docManager.createDocument();
const insertAt = doc.pages.length;
const isPdf =
fileData.type === "application/pdf" ||
fileData.type === "application/x-pdf" ||
(fileData.type === "" && /\.(pdf)$/i.test(fileName ?? ""));
if (isPdf) {
// PDF: use PdfSource with convertMode, password, and renderOptions
// for annotation round-trip support.
const pdfSource: PdfSource = {
fileData,
convertMode: DDV.EnumConvertMode.CM_AUTO,
password: password ?? "",
renderOptions: {
renderAnnotations: DDV.EnumAnnotationRenderMode.LOAD_ANNOTATIONS,
},
};
await doc.loadSource(pdfSource, insertAt);
} else {
// Image (PNG/JPEG/TIFF/BMP): use plain Source — no convertMode needed.
const imgSource: Source = { fileData };
await doc.loadSource(imgSource, insertAt);
}
if (!existing) viewer.openDocument(doc.uid);
viewer.goToPage(insertAt);
document.dispatchEvent(new CustomEvent("app:document-opened"));
}
PDFs use a PdfSource with convertMode, password, and renderOptions for annotation round-trip support. Images use a plain Source object. Some browsers assign an empty Blob.type for certain PDFs, so the filename extension is checked as a fallback.
With this logic, selecting multiple image files creates a multi-page DDV document. Selecting more files later adds more pages to that same document.
Step 4: Delete the Current Page
Unwanted pages are removed with deletePages():
export function deleteCurrentPage(handle: EditViewerHandle): void {
const doc = handle.getCurrentDoc();
if (!doc) {
showToast("Open a document first.", "error");
return;
}
const index = handle.viewer.getCurrentPageIndex();
if (index < 0) return;
const ok = doc.deletePages([index]);
if (!ok) {
showToast("Could not delete the current page.", "error");
return;
}
if (doc.pages.length === 0) {
const uid = doc.uid;
handle.viewer.closeDocument();
try {
handle.docManager.deleteDocuments([uid]);
} catch {
/* already deleted */
}
document.dispatchEvent(new CustomEvent("app:document-closed"));
showToast("Removed the last page.", "success");
return;
}
handle.viewer.goToPage(Math.min(index, doc.pages.length - 1));
document.dispatchEvent(new CustomEvent("app:page-changed"));
showToast("Page deleted.", "success");
}
When the last page is deleted, the document is closed and cleaned up via deleteDocuments() (wrapped in try/catch because DDV may have already disposed it). The header disables document-dependent actions through the app:document-closed event.
Step 5: Add Scanner Capture with Dynamic Web TWAIN
Browsers do not expose a standard scanner API, so scanner capture uses Dynamic Web TWAIN and its local service. The app loads DWT lazily only when the user refreshes scanners or starts scanning.
const DWT_VERSION = "19.4.1";
const DWT_CDN = `https://cdn.jsdelivr.net/npm/dwt@${DWT_VERSION}/dist`;
const DWT_SERVICE_INSTALLER_PATH = `https://unpkg.com/dwt@${DWT_VERSION}/dist/dist`;
const DWT_SCRIPT = `${DWT_CDN}/dynamsoft.webtwain.min.js`;
const DWT_LICENSE = import.meta.env.VITE_DWT_LICENSE ?? "";
The service installer path is important. The installers are under dist/dist. If you set:
Dynamsoft.DWT.ServiceInstallerLocation =
"https://cdn.jsdelivr.net/npm/dwt@latest/dist";
DWT may try to download a wrong URL such as:
https://cdn.jsdelivr.net/npm/dwt@latest/dist/DynamicWebTWAINServiceSetup.msi
Use https://unpkg.com/dwt@19.4.1/dist/dist or self-host the SDK's service installers.
The scanner bridge creates a headless WebTwain object:
async function getDwtObject(): Promise<any> {
await ensureDwtLoaded();
const dwt = window.Dynamsoft?.DWT;
dwt.ResourcesPath = DWT_CDN;
dwt.ServiceInstallerLocation = DWT_SERVICE_INSTALLER_PATH;
dwt.ProductKey = DWT_LICENSE;
dwt.UseLocalService = true;
return new Promise((resolve, reject) => {
dwt.CreateDWTObjectEx(
{ WebTwainId: "scanBridge" },
(object: any) => resolve(object),
(error: any) => reject(error)
);
});
}
Listing and scanning use the modern async DWT APIs:
export async function listScanners(refresh = false): Promise<ScannerOption[]> {
const dwt = await getDwtObject();
devices = await dwt.GetDevicesAsync(undefined, refresh);
return devices.map((device, index) => ({
index,
name: scannerName(device, index),
}));
}
export async function scanFromDevice(deviceIndex: number): Promise<ScanResult | null> {
const dwt = await getDwtObject();
if (!devices.length) devices = await dwt.GetDevicesAsync(undefined, true);
const device = devices[deviceIndex];
if (!device) {
showToast("Select a scanner first.", "error");
return null;
}
const before = imageCount(dwt);
setBusy(true, `Scanning from ${scannerName(device, deviceIndex)}\u2026`);
try {
await dwt.SelectDeviceAsync(device);
await dwt.AcquireImageAsync({
IfShowUI: false,
IfCloseSourceAfterAcquire: true,
IfFeederEnabled: true,
IfDuplexEnabled: false,
PixelType: window.Dynamsoft?.DWT?.EnumDWT_PixelType?.TWPT_RGB ?? 2,
Resolution: 200,
});
const after = imageCount(dwt);
if (after <= before) {
showToast("No pages were scanned.", "info");
return null;
}
const indices = Array.from({ length: after - before }, (_, i) => before + i);
const blob = await convertToPdfBlob(dwt, indices);
return { blob, pageCount: indices.length };
} finally {
setBusy(false);
}
}
The scanned pages are converted to a PDF blob via ConvertToBlob() and appended with the same DDV loading path used for ordinary files. IfShowUI: false avoids showing the scanner vendor's TWAIN configuration dialog and makes the Scan button behave like a quick action. The before/after image count comparison handles scanners that already had images in the buffer.
Step 6: Create Movable Redaction Marks and Stamps
Programmatic annotations use PDF point units, and coordinates should come from pageData.mediaBox.
Quick Redact creates a redaction annotation and selects it, but does not immediately apply the redaction because users need to move or resize the region first.
export function quickRedact(handle: EditViewerHandle): void {
const ctx = currentPageUid(handle);
if (!ctx) {
showToast("Open a page first.", "error");
return;
}
const { pageUid, mediaBox } = ctx;
const width = mediaBox.width * 0.5;
const height = mediaBox.height * 0.12;
const x = (mediaBox.width - width) / 2;
const y = (mediaBox.height - height) / 2;
const created = DDV.annotationManager.createAnnotation(pageUid, "redaction", {
redactionType: "rectangle",
background: "#000000",
rects: [{ x, y, width, height }],
overlayText: {
text: "REDACTED",
color: "#ffffff",
textAlign: "center",
fontSize: 10,
repeatText: true,
autoFontSize: true,
},
});
handle.viewer.selectAnnotations([created.uid]);
showToast(
"Redaction mark added. Move or resize it, then apply it from the redaction toolbar.",
"info"
);
}
When the mark is correct, apply permanent redaction from DDV's redaction toolbar. Permanent redaction destroys underlying content, while a redaction annotation alone only marks the region.
The approval stamp uses a movable textBox annotation:
DDV.annotationManager.createAnnotation(pageUid, "textBox", {
x,
y,
width,
height,
borderColor: "transparent",
background: "rgba(255, 214, 0, 0.9)",
textContents: [
{
content: stampText,
color: "#1a1a1a",
fontSize: 13,
fontFamily: "Helvetica",
fontWeight: "bold",
},
],
});
Step 7: Export the Finished Document
DDV exports the current assembled document. The exported filename is generated from the current time, such as annotation-studio-20260615-143022.pdf, rather than from any imported file. This keeps exports predictable when the document was assembled from many files and scanned pages.
switch (format) {
case "pdf-editable": {
setBusy(true, "Exporting PDF (editable)\u2026");
const blob = await doc.saveToPdf({ saveAnnotation: "annotation" });
saveBlob(blob, `${base}.pdf`);
break;
}
case "pdf-flatten": {
setBusy(true, "Exporting PDF (flattened)\u2026");
const blob = await doc.saveToPdf({ saveAnnotation: "flatten" });
saveBlob(blob, `${base}.pdf`);
break;
}
case "pdf-image": {
setBusy(true, "Exporting PDF (as image)\u2026");
const blob = await doc.saveToPdf({ saveAnnotation: "image" });
saveBlob(blob, `${base}.pdf`);
break;
}
case "png": {
setBusy(true, "Exporting PNG\u2026");
const count = doc.pages.length;
for (let i = 0; i < count; i++) {
const blob = await doc.saveToPng(i);
saveBlob(blob, count > 1 ? `${base}_page${i + 1}.png` : `${base}.png`);
}
break;
}
case "jpeg": {
setBusy(true, "Exporting JPEG\u2026");
const count = doc.pages.length;
for (let i = 0; i < count; i++) {
const blob = await doc.saveToJpeg(i, { quality: 90 });
saveBlob(blob, count > 1 ? `${base}_page${i + 1}.jpg` : `${base}.jpg`);
}
break;
}
case "tiff": {
setBusy(true, "Exporting TIFF\u2026");
const blob = await doc.saveToTiff({
compression: DDV.EnumTIFFCompressionType.TIFF_AUTO,
});
saveBlob(blob, `${base}.tif`);
break;
}
}
The PDF annotation modes are:
-
"annotation": preserve editable PDF annotations. -
"flatten": burn annotations into visible page content. -
"image": rasterize each page. -
"none": discard annotations.
JPEG export uses saveToJpeg(pageIndex, { quality: 90 }) for compressed output. TIFF export uses saveToTiff({ compression: TIFF_AUTO }) to produce a single multi-page TIFF archive. PNG and JPEG export one file per page, and multi-page documents get a _pageN suffix.

Top comments (0)