@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 +21 -0
- package/README.md +49 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.esm.js +357 -0
- package/dist/src/document-merge/index.d.ts +4 -0
- package/dist/src/document-merge/merge-documents.use-case.d.ts +18 -0
- package/dist/src/document-merge/merge-result.contract.d.ts +64 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/page-placement/index.d.ts +4 -0
- package/dist/src/page-placement/page-placement.policy.d.ts +44 -0
- package/dist/src/source-reading/index.d.ts +6 -0
- package/dist/src/source-reading/merge-source.contract.d.ts +32 -0
- package/dist/src/source-reading/read-source.use-case.d.ts +23 -0
- package/package.json +52 -0
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
|
+

|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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,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,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
|
+
}
|