@scanmate/merge 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eduardo Russo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ ![scanmate merge](./scanmate-merge.svg)
2
+
3
+ # `@scanmate/merge`
4
+
5
+ PDFs, images and rasters into one PDF, in the order given and mixed freely.
6
+
7
+ ```ts
8
+ import { mergeDocuments } from '@scanmate/merge'
9
+
10
+ // What a person uploaded, page by page, as one document:
11
+ const { pdf, pages } = await mergeDocuments(['page-1.jpg', 'page-2.jpg', 'annex.pdf'])
12
+
13
+ // A pipeline's aligned pages as one evidence file:
14
+ const evidence = await mergeDocuments(
15
+ aligned.map(p => ({ raster: p.aligned.raster, dpi: p.original.dpi })),
16
+ { metadata: { title: 'Aligned scan' } },
17
+ )
18
+ ```
19
+
20
+ ## What goes in, and how
21
+
22
+ | Source | Result |
23
+ |---|---|
24
+ | PDF (path or bytes) | Pages copied, never re-rendered: text layer, vectors and signatures stay intact. Encrypted PDFs are refused with a clear error. |
25
+ | JPEG, upright, RGB or grey | Embedded as its own bytes, so it is never compressed twice. |
26
+ | PNG | Pixels carried over losslessly. |
27
+ | JPEG needing EXIF rotation, CMYK JPEG, TIFF (every page), WebP, HEIF, AVIF, GIF | Decoded (rotation applied, transparency flattened onto white), then encoded once. |
28
+ | `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. |
29
+
30
+ 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.
31
+
32
+ ## Page size
33
+
34
+ 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`.
35
+
36
+ | Option | Default | |
37
+ |---|---|---|
38
+ | `pageSize` | `'image'` | Or `'a4'`, `'letter'`, `{ width, height }` in points. |
39
+ | `imageDpi` | `150` | For images that record no resolution, or 72/96. |
40
+ | `encoding` | `'png'` | For images that must be re-encoded; `'jpeg'` is much smaller. |
41
+ | `quality` | `92` | JPEG quality. |
42
+ | `passThrough` | `true` | Return a lone PDF unchanged. |
43
+ | `metadata` | none | Title, author, subject, keywords, creator. The producer is always `@scanmate/merge`. |
44
+
45
+ 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`.
46
+
47
+ 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.
48
+
49
+ PDF writing uses `@cantoo/pdf-lib`, the maintained fork of pdf-lib, which is pure JavaScript with nothing to install on the host.
@@ -0,0 +1 @@
1
+ export * from "./src/index.js";
@@ -0,0 +1,357 @@
1
+ import { PDFDocument, EncryptedPDFError } from '@cantoo/pdf-lib';
2
+ import { isRaster, decodeImage, readImageMetadata, encodeImage } from '@scanmate/ink';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ /**
7
+ * How big an image's page is, and where on it the image goes.
8
+ *
9
+ * By default a page is the image at its resolution: a 2480 x 3508 scan at 300
10
+ * dpi becomes an A4 page, and a downstream reader that divides pixels by page
11
+ * inches gets the scan's real resolution back - which is exactly what
12
+ * `@scanmate/extract` does to decide how to render. A fixed paper size instead
13
+ * fits the image inside it, centred, turning the page landscape for a landscape
14
+ * image.
15
+ *
16
+ * Resolution comes from, in order: what the caller says; what the file records,
17
+ * if it is at least {@link MIN_RECORDED_DPI}; the `imageDpi` fallback. The floor
18
+ * is there because 72 and 96 are what cameras and editors write when they know
19
+ * nothing - a phone photo "at 72 dpi" would make a page over a metre tall.
20
+ */
21
+ const PAPER = {
22
+ a4: {
23
+ width: 595.28,
24
+ height: 841.89
25
+ },
26
+ letter: {
27
+ width: 612,
28
+ height: 792
29
+ }
30
+ };
31
+ /** Recorded densities below this are software defaults, not a scan's resolution. */
32
+ const MIN_RECORDED_DPI = 100;
33
+ function resolveDpi(given, recorded, fallback) {
34
+ if (given !== undefined && given !== null && given > 0) return given;
35
+ if (recorded !== null && recorded >= MIN_RECORDED_DPI) return recorded;
36
+ return fallback;
37
+ }
38
+ function placeImage(pixelWidth, pixelHeight, dpi, pageSize, margin = 0) {
39
+ const width = pixelWidth / dpi * 72;
40
+ const height = pixelHeight / dpi * 72;
41
+ if (pageSize === 'image') return {
42
+ pageWidth: width,
43
+ pageHeight: height,
44
+ x: 0,
45
+ y: 0,
46
+ width,
47
+ height
48
+ };
49
+ const paper = typeof pageSize === 'string' ? PAPER[pageSize] : pageSize;
50
+ const landscape = pixelWidth > pixelHeight;
51
+ const pageWidth = landscape ? Math.max(paper.width, paper.height) : Math.min(paper.width, paper.height);
52
+ const pageHeight = landscape ? Math.min(paper.width, paper.height) : Math.max(paper.width, paper.height);
53
+ const scale = Math.min((pageWidth - 2 * margin) / width, (pageHeight - 2 * margin) / height);
54
+ const fitted = {
55
+ width: width * scale,
56
+ height: height * scale
57
+ };
58
+ return {
59
+ pageWidth,
60
+ pageHeight,
61
+ x: (pageWidth - fitted.width) / 2,
62
+ y: (pageHeight - fitted.height) / 2,
63
+ ...fitted
64
+ };
65
+ }
66
+
67
+ /** A source that could not be used, and which one it was. */
68
+ class MergeSourceError extends Error {
69
+ index;
70
+ /**
71
+ * @param index - Zero-based position of the source in the list given.
72
+ * @param message - What is wrong with it.
73
+ */
74
+ constructor(index, message) {
75
+ super(`source ${index + 1}: ${message}`);
76
+ this.index = index;
77
+ this.name = 'MergeSourceError';
78
+ }
79
+ }
80
+
81
+ /** A PDF may carry junk before its header; readers look for it in the first kilobyte. */
82
+ const PDF_HEADER = [0x25, 0x50, 0x44, 0x46, 0x2D]; // %PDF-
83
+ const HEADER_WINDOW = 1024;
84
+ async function readSource(source, index) {
85
+ if (isRaster(source)) return {
86
+ kind: 'raster',
87
+ raster: source,
88
+ dpi: null,
89
+ bytes: null
90
+ };
91
+ if (isImageWithResolution(source)) return {
92
+ kind: 'raster',
93
+ raster: source.raster,
94
+ dpi: source.dpi ?? null,
95
+ bytes: source.image ?? null
96
+ };
97
+ const bytes = await readBytes(source, index);
98
+ if (bytes.byteLength === 0) throw new MergeSourceError(index, 'it is empty');
99
+ return isPdf(bytes) ? {
100
+ kind: 'pdf',
101
+ bytes
102
+ } : {
103
+ kind: 'image',
104
+ bytes,
105
+ dpi: null
106
+ };
107
+ }
108
+ function isPdf(bytes) {
109
+ const end = Math.min(bytes.length - PDF_HEADER.length, HEADER_WINDOW);
110
+ for (let start = 0; start <= end; start++) if (PDF_HEADER.every((b, i) => bytes[start + i] === b)) return true;
111
+ return false;
112
+ }
113
+ function isImageWithResolution(source) {
114
+ return typeof source === 'object' && source !== null && 'raster' in source && isRaster(source.raster);
115
+ }
116
+ async function readBytes(source, index) {
117
+ try {
118
+ if (typeof source === 'string') return new Uint8Array(await readFile(source));
119
+ if (source instanceof URL) return new Uint8Array(await readFile(fileURLToPath(source)));
120
+ if (source instanceof ArrayBuffer) return new Uint8Array(source);
121
+ if (ArrayBuffer.isView(source)) return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
122
+ } catch (error) {
123
+ throw new MergeSourceError(index, `it could not be read (${error instanceof Error ? error.message : String(error)})`);
124
+ }
125
+ throw new MergeSourceError(index, 'expected a path, a file URL, bytes, a raster or { raster, dpi }');
126
+ }
127
+
128
+ /**
129
+ * Many files in, one PDF out: PDFs, images and rasters, in the order given,
130
+ * mixed freely.
131
+ *
132
+ * A PDF's pages are copied, not re-rendered, so their text layer, vectors and
133
+ * any signatures stay as they were. A JPEG with no EXIF rotation, in RGB or
134
+ * grey, is embedded as its own bytes, so a scan is not compressed a second
135
+ * time; a PNG's pixels are carried over losslessly (the PDF holds them deflated,
136
+ * not as the PNG file). Anything else is decoded - EXIF rotation applied,
137
+ * transparency flattened onto white, every page of a multi-page TIFF - and
138
+ * encoded once. The upload a person scanned page by page
139
+ * becomes one document in upload order, and a pipeline's aligned or enhanced
140
+ * pages become one evidence file.
141
+ */
142
+ async function mergeDocuments(sources, options = {}) {
143
+ const {
144
+ pageSize = 'image',
145
+ margin = 0,
146
+ imageDpi = 150,
147
+ encoding = 'png',
148
+ quality = 92,
149
+ passThrough = true,
150
+ metadata,
151
+ onProgress
152
+ } = options;
153
+ if (sources.length === 0) throw new RangeError('nothing to merge');
154
+ const resolved = [];
155
+ for (const [index, source] of sources.entries()) resolved.push(await readSource(source, index));
156
+ const context = {
157
+ merged: await PDFDocument.create(),
158
+ pages: [],
159
+ pageSize,
160
+ margin,
161
+ imageDpi,
162
+ encoding,
163
+ quality
164
+ };
165
+ for (const [index, source] of resolved.entries()) {
166
+ const started = Date.now();
167
+ onProgress?.({
168
+ stage: 'merge',
169
+ phase: 'start',
170
+ page: index + 1,
171
+ index: index + 1,
172
+ total: resolved.length
173
+ });
174
+ const before = context.pages.length;
175
+ if (source.kind === 'pdf') await appendPdf(context, source.bytes, index);else if (source.kind === 'image') await appendImageFile(context, source.bytes, index);else await appendRaster(context, source, index);
176
+ onProgress?.({
177
+ stage: 'merge',
178
+ phase: 'done',
179
+ page: index + 1,
180
+ index: index + 1,
181
+ total: resolved.length,
182
+ durationMs: Date.now() - started,
183
+ detail: {
184
+ kind: source.kind,
185
+ pages: context.pages.length - before
186
+ }
187
+ });
188
+ }
189
+ const [first] = resolved;
190
+ if (passThrough && metadata === undefined && resolved.length === 1 && first.kind === 'pdf') return {
191
+ pdf: first.bytes,
192
+ pageCount: context.pages.length,
193
+ pages: context.pages,
194
+ passedThrough: true
195
+ };
196
+ const {
197
+ merged
198
+ } = context;
199
+ merged.setProducer('@scanmate/merge');
200
+ if (metadata?.title !== undefined) merged.setTitle(metadata.title);
201
+ if (metadata?.author !== undefined) merged.setAuthor(metadata.author);
202
+ if (metadata?.subject !== undefined) merged.setSubject(metadata.subject);
203
+ if (metadata?.keywords !== undefined) merged.setKeywords(metadata.keywords);
204
+ if (metadata?.creator !== undefined) merged.setCreator(metadata.creator);
205
+ return {
206
+ pdf: await merged.save(),
207
+ pageCount: context.pages.length,
208
+ pages: context.pages,
209
+ passedThrough: false
210
+ };
211
+ }
212
+ async function appendPdf(context, bytes, index) {
213
+ const source = await loadPdf(bytes, index);
214
+ const copied = await context.merged.copyPages(source, source.getPageIndices());
215
+ for (const [i, page] of copied.entries()) {
216
+ context.merged.addPage(page);
217
+ const {
218
+ width,
219
+ height
220
+ } = page.getSize();
221
+ context.pages.push({
222
+ page: context.pages.length + 1,
223
+ source: index + 1,
224
+ sourcePage: i + 1,
225
+ kind: 'pdf',
226
+ embedding: 'pdf-page',
227
+ width,
228
+ height,
229
+ dpi: null
230
+ });
231
+ }
232
+ }
233
+ async function loadPdf(bytes, index) {
234
+ try {
235
+ return await PDFDocument.load(bytes, {
236
+ updateMetadata: false
237
+ });
238
+ } catch (error) {
239
+ if (error instanceof EncryptedPDFError) throw new MergeSourceError(index, 'it is an encrypted PDF, which cannot be copied without its password');
240
+ throw new MergeSourceError(index, `it is not a readable PDF (${error instanceof Error ? error.message : String(error)})`);
241
+ }
242
+ }
243
+ async function appendImageFile(context, bytes, index) {
244
+ const meta = await imageMetadata(bytes, index);
245
+ const dpi = resolveDpi(undefined, meta.density, context.imageDpi);
246
+ const upright = (meta.orientation ?? 1) === 1;
247
+ const asJpeg = meta.format === 'jpeg' && upright && (meta.space === 'srgb' || meta.space === 'b-w');
248
+ const asPng = meta.format === 'png' && upright;
249
+ if (meta.pages === 1 && (asJpeg || asPng)) {
250
+ const image = asJpeg ? await context.merged.embedJpg(bytes) : await context.merged.embedPng(bytes);
251
+ place(context, image, meta.width, meta.height, dpi, {
252
+ index,
253
+ sourcePage: 1,
254
+ kind: 'image',
255
+ embedding: asJpeg ? 'jpeg' : 'png'
256
+ });
257
+ return;
258
+ }
259
+ for (let page = 0; page < meta.pages; page++) {
260
+ const raster = await decodeImage(bytes, {
261
+ page
262
+ });
263
+ const {
264
+ image,
265
+ embedding
266
+ } = await encodeAndEmbed(context, raster);
267
+ place(context, image, raster.width, raster.height, dpi, {
268
+ index,
269
+ sourcePage: page + 1,
270
+ kind: 'image',
271
+ embedding
272
+ });
273
+ }
274
+ }
275
+ async function imageMetadata(bytes, index) {
276
+ try {
277
+ return await readImageMetadata(bytes);
278
+ } catch {
279
+ throw new MergeSourceError(index, 'it is neither a PDF nor an image that can be read');
280
+ }
281
+ }
282
+ async function appendRaster(context, source, index) {
283
+ const {
284
+ raster,
285
+ bytes
286
+ } = source;
287
+ const dpi = resolveDpi(source.dpi, null, context.imageDpi);
288
+ const format = bytes === null ? null : embeddableFormat(bytes);
289
+ if (bytes !== null && format !== null) {
290
+ const image = format === 'jpeg' ? await context.merged.embedJpg(bytes) : await context.merged.embedPng(bytes);
291
+ place(context, image, raster.width, raster.height, dpi, {
292
+ index,
293
+ sourcePage: 1,
294
+ kind: 'raster',
295
+ embedding: format
296
+ });
297
+ return;
298
+ }
299
+ const {
300
+ image,
301
+ embedding
302
+ } = await encodeAndEmbed(context, raster);
303
+ place(context, image, raster.width, raster.height, dpi, {
304
+ index,
305
+ sourcePage: 1,
306
+ kind: 'raster',
307
+ embedding
308
+ });
309
+ }
310
+ async function encodeAndEmbed(context, raster) {
311
+ if (context.encoding === 'jpeg') {
312
+ const jpeg = await encodeImage(raster, {
313
+ format: 'jpeg',
314
+ quality: context.quality
315
+ });
316
+ return {
317
+ image: await context.merged.embedJpg(jpeg),
318
+ embedding: 'encoded-jpeg'
319
+ };
320
+ }
321
+ const png = await encodeImage(raster, {
322
+ format: 'png'
323
+ });
324
+ return {
325
+ image: await context.merged.embedPng(png),
326
+ embedding: 'encoded-png'
327
+ };
328
+ }
329
+ function place(context, image, pixelWidth, pixelHeight, dpi, origin) {
330
+ const placement = placeImage(pixelWidth, pixelHeight, dpi, context.pageSize, context.margin);
331
+ const page = context.merged.addPage([placement.pageWidth, placement.pageHeight]);
332
+ page.drawImage(image, {
333
+ x: placement.x,
334
+ y: placement.y,
335
+ width: placement.width,
336
+ height: placement.height
337
+ });
338
+ context.pages.push({
339
+ page: context.pages.length + 1,
340
+ source: origin.index + 1,
341
+ sourcePage: origin.sourcePage,
342
+ kind: origin.kind,
343
+ embedding: origin.embedding,
344
+ width: placement.pageWidth,
345
+ height: placement.pageHeight,
346
+ dpi
347
+ });
348
+ }
349
+ /** PNG or JPEG by signature - the two formats a PDF takes as they are. */
350
+ function embeddableFormat(bytes) {
351
+ if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47) return 'png';
352
+ if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) return 'jpeg';
353
+ return null;
354
+ }
355
+
356
+ export { MIN_RECORDED_DPI, MergeSourceError, PAPER, isPdf, mergeDocuments, placeImage, readSource, resolveDpi };
357
+ //# sourceMappingURL=index.esm.js.map
@@ -0,0 +1,4 @@
1
+ /** Many PDFs, images and rasters into one PDF. */
2
+ export { mergeDocuments } from './merge-documents.use-case.js';
3
+ export type { Embedding, MergedPage, MergeOptions, MergeResult } from './merge-result.contract.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,18 @@
1
+ import type { MergeSource } from '../source-reading/index.js';
2
+ import type { MergeOptions, MergeResult } from './merge-result.contract.js';
3
+ /**
4
+ * Many files in, one PDF out: PDFs, images and rasters, in the order given,
5
+ * mixed freely.
6
+ *
7
+ * A PDF's pages are copied, not re-rendered, so their text layer, vectors and
8
+ * any signatures stay as they were. A JPEG with no EXIF rotation, in RGB or
9
+ * grey, is embedded as its own bytes, so a scan is not compressed a second
10
+ * time; a PNG's pixels are carried over losslessly (the PDF holds them deflated,
11
+ * not as the PNG file). Anything else is decoded - EXIF rotation applied,
12
+ * transparency flattened onto white, every page of a multi-page TIFF - and
13
+ * encoded once. The upload a person scanned page by page
14
+ * becomes one document in upload order, and a pipeline's aligned or enhanced
15
+ * pages become one evidence file.
16
+ */
17
+ export declare function mergeDocuments(sources: readonly MergeSource[], options?: MergeOptions): Promise<MergeResult>;
18
+ //# sourceMappingURL=merge-documents.use-case.d.ts.map
@@ -0,0 +1,64 @@
1
+ import type { ProgressCallback } from '@scanmate/ink';
2
+ import type { PageSize } from '../page-placement/index.js';
3
+ import type { SourceKind } from '../source-reading/index.js';
4
+ export interface MergeOptions {
5
+ /** Size of each image page: the image at its resolution (`'image'`, the default), or a paper size to fit it in. */
6
+ pageSize?: PageSize;
7
+ /** Points of white kept around an image on a paper-size page. Default `0`. */
8
+ margin?: number;
9
+ /** Resolution for an image that does not say, or says 72 or 96. Default `150`. */
10
+ imageDpi?: number;
11
+ /**
12
+ * How to encode an image that cannot be embedded as it is - any format but
13
+ * JPEG and PNG, a JPEG that needs its EXIF rotation applied, a raster.
14
+ * `'png'` (the default) is lossless: a scan should not gain compression
15
+ * artefacts on its way into evidence. `'jpeg'` is a fraction of the size.
16
+ */
17
+ encoding?: 'png' | 'jpeg';
18
+ /** JPEG quality, 1-100, when `encoding` is `'jpeg'`. Default `92`. */
19
+ quality?: number;
20
+ /**
21
+ * A single PDF on its own comes back byte for byte - stored exactly as
22
+ * scanned, signatures and all. Default `true`. Setting `metadata` turns it
23
+ * off, since that means writing a new file.
24
+ */
25
+ passThrough?: boolean;
26
+ /** Information dictionary for the merged file. The producer is always `@scanmate/merge`. */
27
+ metadata?: {
28
+ title?: string;
29
+ author?: string;
30
+ subject?: string;
31
+ keywords?: string[];
32
+ creator?: string;
33
+ };
34
+ onProgress?: ProgressCallback;
35
+ }
36
+ /** How a page got into the merged file. */
37
+ export type Embedding = 'pdf-page' | 'jpeg' | 'png' | 'encoded-png' | 'encoded-jpeg';
38
+ export interface MergedPage {
39
+ /** One-based, in the merged file. */
40
+ page: number;
41
+ /** One-based position of the source it came from, in the list given. */
42
+ source: number;
43
+ /** One-based page within that source - above one for a PDF or a multi-page TIFF. */
44
+ sourcePage: number;
45
+ kind: SourceKind;
46
+ /**
47
+ * `'jpeg'` is the source's own bytes and `'png'` its pixels, losslessly; the
48
+ * `encoded-` ones were decoded and encoded again.
49
+ */
50
+ embedding: Embedding;
51
+ /** Page size in points. */
52
+ width: number;
53
+ height: number;
54
+ /** Resolution the image was placed at; `null` for a PDF page. */
55
+ dpi: number | null;
56
+ }
57
+ export interface MergeResult {
58
+ pdf: Uint8Array;
59
+ pageCount: number;
60
+ pages: MergedPage[];
61
+ /** The single PDF given was returned unchanged. */
62
+ passedThrough: boolean;
63
+ }
64
+ //# sourceMappingURL=merge-result.contract.d.ts.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `@scanmate/merge` - PDFs, images and rasters into one PDF.
3
+ *
4
+ * ```ts
5
+ * import { mergeDocuments } from '@scanmate/merge'
6
+ *
7
+ * // What a person uploaded, page by page, as one document:
8
+ * const { pdf } = await mergeDocuments(['page-1.jpg', 'page-2.jpg', 'annex.pdf'])
9
+ *
10
+ * // A pipeline's aligned pages as one evidence file:
11
+ * const evidence = await mergeDocuments(aligned.map(p => ({ raster: p.aligned.raster, dpi: p.original.dpi })))
12
+ * ```
13
+ *
14
+ * PDF pages are copied, never re-rendered. A JPEG goes in as its own bytes and
15
+ * a PNG's pixels losslessly; everything else - TIFF (every page), WebP, HEIF,
16
+ * AVIF, rasters - is decoded and encoded once, losslessly by default. A single
17
+ * PDF on its own comes back byte for byte.
18
+ *
19
+ * PDF writing is `@cantoo/pdf-lib`, the maintained fork of pdf-lib: pure
20
+ * JavaScript, nothing to install on the host. Image decoding is `@scanmate/ink`.
21
+ */
22
+ export { mergeDocuments } from './document-merge/index.js';
23
+ export type { Embedding, MergedPage, MergeOptions, MergeResult } from './document-merge/index.js';
24
+ export { MergeSourceError } from './source-reading/index.js';
25
+ export type { ImageWithResolution, MergeSource, SourceKind } from './source-reading/index.js';
26
+ export type { PageSize } from './page-placement/index.js';
27
+ export { MIN_RECORDED_DPI, PAPER, placeImage, resolveDpi } from './page-placement/index.js';
28
+ export type { Placement } from './page-placement/index.js';
29
+ export { isPdf, readSource } from './source-reading/index.js';
30
+ export type { ResolvedSource } from './source-reading/index.js';
31
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ /** Page size and image position for each image page. */
2
+ export { MIN_RECORDED_DPI, PAPER, placeImage, resolveDpi } from './page-placement.policy.js';
3
+ export type { PageSize, Placement } from './page-placement.policy.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,44 @@
1
+ /**
2
+ * How big an image's page is, and where on it the image goes.
3
+ *
4
+ * By default a page is the image at its resolution: a 2480 x 3508 scan at 300
5
+ * dpi becomes an A4 page, and a downstream reader that divides pixels by page
6
+ * inches gets the scan's real resolution back - which is exactly what
7
+ * `@scanmate/extract` does to decide how to render. A fixed paper size instead
8
+ * fits the image inside it, centred, turning the page landscape for a landscape
9
+ * image.
10
+ *
11
+ * Resolution comes from, in order: what the caller says; what the file records,
12
+ * if it is at least {@link MIN_RECORDED_DPI}; the `imageDpi` fallback. The floor
13
+ * is there because 72 and 96 are what cameras and editors write when they know
14
+ * nothing - a phone photo "at 72 dpi" would make a page over a metre tall.
15
+ */
16
+ /** `'image'`: the image's own size at its resolution. Otherwise a paper size, or `{ width, height }` in points. */
17
+ export type PageSize = 'image' | 'a4' | 'letter' | {
18
+ width: number;
19
+ height: number;
20
+ };
21
+ export declare const PAPER: {
22
+ readonly a4: {
23
+ readonly width: 595.28;
24
+ readonly height: 841.89;
25
+ };
26
+ readonly letter: {
27
+ readonly width: 612;
28
+ readonly height: 792;
29
+ };
30
+ };
31
+ /** Recorded densities below this are software defaults, not a scan's resolution. */
32
+ export declare const MIN_RECORDED_DPI = 100;
33
+ /** In PDF points, from the page's bottom-left corner - PDF's own frame. */
34
+ export interface Placement {
35
+ pageWidth: number;
36
+ pageHeight: number;
37
+ x: number;
38
+ y: number;
39
+ width: number;
40
+ height: number;
41
+ }
42
+ export declare function resolveDpi(given: number | null | undefined, recorded: number | null, fallback: number): number;
43
+ export declare function placeImage(pixelWidth: number, pixelHeight: number, dpi: number, pageSize: PageSize, margin?: number): Placement;
44
+ //# sourceMappingURL=page-placement.policy.d.ts.map
@@ -0,0 +1,6 @@
1
+ /** Turning each thing handed to merge - a path, bytes, pixels - into a PDF, an image or a raster. */
2
+ export { MergeSourceError } from './merge-source.contract.js';
3
+ export type { ImageWithResolution, MergeSource, SourceKind } from './merge-source.contract.js';
4
+ export { isPdf, readSource } from './read-source.use-case.js';
5
+ export type { ResolvedSource } from './read-source.use-case.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,32 @@
1
+ import type { Raster } from '@scanmate/ink';
2
+ /**
3
+ * Anything that can become pages of the merged PDF.
4
+ *
5
+ * - a path (`string` or file `URL`) or bytes (`Uint8Array`, `ArrayBuffer`) -
6
+ * a PDF or any image libvips reads, told apart by content, not by name;
7
+ * - a decoded {@link Raster};
8
+ * - an image with its resolution, `{ raster, dpi }` - which is what every
9
+ * `PageImage` in the pipeline is, so an aligned or enhanced page goes in as it
10
+ * is. When it also carries its encoded `image`, those bytes are embedded
11
+ * rather than encoding the raster again.
12
+ */
13
+ export type MergeSource = string | URL | Uint8Array | ArrayBuffer | Raster | ImageWithResolution;
14
+ export interface ImageWithResolution {
15
+ raster: Raster;
16
+ /** Pixels per inch, which sets the page size; `null` or absent when unknown. */
17
+ dpi?: number | null;
18
+ /** The raster already encoded - PNG or JPEG bytes are embedded as they are. */
19
+ image?: Uint8Array | null;
20
+ }
21
+ /** What a source turned out to be. */
22
+ export type SourceKind = 'pdf' | 'image' | 'raster';
23
+ /** A source that could not be used, and which one it was. */
24
+ export declare class MergeSourceError extends Error {
25
+ readonly index: number;
26
+ /**
27
+ * @param index - Zero-based position of the source in the list given.
28
+ * @param message - What is wrong with it.
29
+ */
30
+ constructor(index: number, message: string);
31
+ }
32
+ //# sourceMappingURL=merge-source.contract.d.ts.map
@@ -0,0 +1,23 @@
1
+ import type { Raster } from '@scanmate/ink';
2
+ import type { MergeSource } from './merge-source.contract.js';
3
+ /**
4
+ * A source resolved to what merging needs: its bytes and what they are, or its
5
+ * pixels. Content decides, never the file name - a scanner that saves a PDF as
6
+ * `scan.jpg` still gives a PDF.
7
+ */
8
+ export type ResolvedSource = {
9
+ kind: 'pdf';
10
+ bytes: Uint8Array;
11
+ } | {
12
+ kind: 'image';
13
+ bytes: Uint8Array;
14
+ dpi: null;
15
+ } | {
16
+ kind: 'raster';
17
+ raster: Raster;
18
+ dpi: number | null;
19
+ bytes: Uint8Array | null;
20
+ };
21
+ export declare function readSource(source: MergeSource, index: number): Promise<ResolvedSource>;
22
+ export declare function isPdf(bytes: Uint8Array): boolean;
23
+ //# sourceMappingURL=read-source.use-case.d.ts.map
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@scanmate/merge",
3
+ "version": "0.0.2",
4
+ "description": "Merge PDFs, images and rasters into one PDF: PDF pages copied as they are, JPEGs embedded without recompression, every page of a TIFF.",
5
+ "license": "MIT",
6
+ "author": "Eduardo Russo",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/russoedu/scanmate.git",
10
+ "directory": "packages/merge"
11
+ },
12
+ "homepage": "https://github.com/russoedu/scanmate/tree/main/packages/merge#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/russoedu/scanmate/issues"
15
+ },
16
+ "keywords": [
17
+ "pdf",
18
+ "merge",
19
+ "images",
20
+ "tiff",
21
+ "scan",
22
+ "document"
23
+ ],
24
+ "type": "module",
25
+ "main": "./dist/index.esm.js",
26
+ "module": "./dist/index.esm.js",
27
+ "types": "./dist/src/index.d.ts",
28
+ "exports": {
29
+ "./package.json": "./package.json",
30
+ ".": {
31
+ "types": "./dist/src/index.d.ts",
32
+ "import": "./dist/index.esm.js",
33
+ "default": "./dist/index.esm.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist",
38
+ "!**/*.tsbuildinfo",
39
+ "!**/*.d.ts.map",
40
+ "!**/*.js.map"
41
+ ],
42
+ "dependencies": {
43
+ "@cantoo/pdf-lib": "^2.11.1",
44
+ "@scanmate/ink": "^0.0.2"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "devDependencies": {
50
+ "sharp": "^0.35.4"
51
+ }
52
+ }