npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@scanmate/merge

v0.24.6

Published

Merge PDFs, images and rasters into one PDF: PDF pages copied as they are, JPEGs embedded without recompression, every page of a TIFF.

Readme

scanmate merge

@scanmate/merge

PDFs, images and rasters into one PDF, in the order given and mixed freely.

three photographed pages and a PDF, merged into one document

Three photographed pages and a PDF, in one document: the JPEGs embedded as their own bytes, the PDF's pages copied, never re-rendered. Made from the IRS Form W-9 (a work of the United States government, in the public domain): filled in as a generator would, printed, signed by hand and scanned crooked.

Install

npm install @scanmate/merge
import { mergeDocuments } from '@scanmate/merge'

// The picture above: three photographed pages, then a PDF, as one document.
const { pdf, pages } = await mergeDocuments(['page-1.jpg', 'page-2.jpg', 'page-3.jpg', 'fw9-issued.pdf'])

// A pipeline's aligned pages as one evidence file:
const evidence = await mergeDocuments(
  aligned.map(p => ({ raster: p.aligned.raster, dpi: p.original.dpi })),
  { metadata: { title: 'Aligned scan' } },
)

What goes in, and how

| Source | Result | |---|---| | PDF (path or bytes) | Pages copied, never re-rendered: text layer, vectors and signatures stay intact. Encrypted PDFs are refused with a clear error. | | JPEG, upright, RGB or grey | Embedded as its own bytes, so it is never compressed twice. | | PNG | Pixels carried over losslessly. | | JPEG needing EXIF rotation, CMYK JPEG, TIFF (every page), WebP, HEIF, AVIF, GIF | Decoded (rotation applied, transparency flattened onto white), then encoded once. | | Raster, or { raster, dpi, image? }, i.e. any PageImage | Its image bytes are embedded when they are PNG or JPEG; otherwise the raster is encoded. |

The type of each source is detected from its content, not its name. A single PDF on its own comes back byte for byte (passedThrough: true), unless metadata asks for a new file.

Page size

By default each image page is the image at its resolution. A 2480 × 3508 scan at 300 dpi becomes an A4 page, so a reader that divides pixels by page inches, as @scanmate/extract does, gets the scan's real resolution back. Resolution comes from the caller, then the file (if it records at least 100 dpi; 72 and 96 are software defaults), then imageDpi. pageSize: 'a4' | 'letter' | { width, height } fits the image on paper instead: centred, turned landscape for a landscape image, with an optional margin.

| Option | Default | | |---|---|---| | pageSize | 'image' | Or 'a4', 'letter', { width, height } in points. | | imageDpi | 150 | For images that record no resolution, or 72/96. | | encoding | 'png' | For images that must be re-encoded; 'jpeg' is much smaller. | | quality | 92 | JPEG quality. | | passThrough | true | Return a lone PDF unchanged. | | metadata | none | Title, author, subject, keywords, creator. The producer is always @scanmate/merge. | | password | none | Opens an encrypted PDF source that needs a password to be read. A signed or permission-restricted PDF - locked with an owner password alone - needs none. Tried on every encrypted source; one it does not open is tried with no password instead, so it cannot lock out a source that needed nothing. |

Each entry in pages says which source and source page it came from, how it was embedded, and its size. A source that cannot be used raises a MergeSourceError carrying its index.

Measured on real scans: three 120-dpi page JPEGs plus a 7-page PDF merged in 20 ms (0.73 MB). Seven aligned pages made a 3.3 MB PNG or 0.74 MB JPEG evidence file.

PDF writing uses @cantoo/pdf-lib, the maintained fork of pdf-lib, which is pure JavaScript with nothing to install on the host.

Drawing on a PDF, not just assembling one

The other thing this package writes is marks. markPages draws a set of regions on a PDF as vector rectangles, each with its bleed dashed around it, and hands the PDF back:

import { markPages } from '@scanmate/merge'

const { pdf, drawn, warnings } = await markPages('issued.pdf', [
  { page: 1, id: 'signature', x: 120, y: 577, width: 262, height: 22, bleedBottom: 20 },   // its own room below
  { page: 1, id: 'date',      x: 404, y: 577, width: 171, height: 22 },                    // the options' room
], { bleedTop: 2 })

The bleed - bleed for every side, bleedTop, bleedRight, bleedBottom, bleedLeft for one - defaults to 6 points on each side. On the options it is the room around every mark; on a mark it is that mark's own, and wins side by side.

It exists to check that the regions a validation will measure are where the document's fields actually are - most people reach it as Scanmate.mark in @scanmate/scan, whose README has a worked example on the W-9.

Signed documents open as they are. They usually arrive encrypted - an owner password restricting editing, none needed to read - which pdf-lib refuses by default. They are decrypted as they are read, with nothing to pass; password is only for a PDF that needs one to be read at all.

Two things it gets right that a naive version would not. Rotation and crop box: a region is in points from the top-left of the page as displayed, which is how @scanmate/extract reports text, while pdf-lib draws from the bottom-left of the unrotated media box. The conversion between the two is pdf.js's own page transform, ported line for line and inverted, and its spec pins it to values read off real pdf.js for every quarter turn, with and without an offset crop box. Bleed: it is resolved by resolveBleed and resolveRegionBleed from @scanmate/ink, the same functions the pixel comparison uses, so the band drawn is the band measured - per mark, when a mark carries its own.

How it decides

documentation/algorithms.md has the algorithms in full: what each step measures, the decision flows, every constant with the measurement behind it, and what the package deliberately does not do.