noupload 1.0.0
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/.github/workflows/release.yml +73 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/biome.json +34 -0
- package/bunfig.toml +7 -0
- package/dist/index.js +192 -0
- package/install.sh +68 -0
- package/package.json +47 -0
- package/scripts/inspect-help.ts +15 -0
- package/site/index.html +112 -0
- package/src/cli.ts +24 -0
- package/src/commands/audio/convert.ts +107 -0
- package/src/commands/audio/extract.ts +84 -0
- package/src/commands/audio/fade.ts +128 -0
- package/src/commands/audio/index.ts +35 -0
- package/src/commands/audio/merge.ts +109 -0
- package/src/commands/audio/normalize.ts +110 -0
- package/src/commands/audio/reverse.ts +64 -0
- package/src/commands/audio/speed.ts +101 -0
- package/src/commands/audio/trim.ts +98 -0
- package/src/commands/audio/volume.ts +91 -0
- package/src/commands/audio/waveform.ts +117 -0
- package/src/commands/doctor.ts +125 -0
- package/src/commands/image/adjust.ts +129 -0
- package/src/commands/image/border.ts +94 -0
- package/src/commands/image/bulk-compress.ts +111 -0
- package/src/commands/image/bulk-convert.ts +114 -0
- package/src/commands/image/bulk-resize.ts +112 -0
- package/src/commands/image/compress.ts +95 -0
- package/src/commands/image/convert.ts +116 -0
- package/src/commands/image/crop.ts +96 -0
- package/src/commands/image/favicon.ts +89 -0
- package/src/commands/image/filters.ts +108 -0
- package/src/commands/image/index.ts +49 -0
- package/src/commands/image/resize.ts +110 -0
- package/src/commands/image/rotate.ts +90 -0
- package/src/commands/image/strip-metadata.ts +60 -0
- package/src/commands/image/to-base64.ts +72 -0
- package/src/commands/image/watermark.ts +141 -0
- package/src/commands/pdf/compress.ts +157 -0
- package/src/commands/pdf/decrypt.ts +102 -0
- package/src/commands/pdf/delete-pages.ts +112 -0
- package/src/commands/pdf/duplicate.ts +119 -0
- package/src/commands/pdf/encrypt.ts +161 -0
- package/src/commands/pdf/from-images.ts +104 -0
- package/src/commands/pdf/index.ts +55 -0
- package/src/commands/pdf/merge.ts +84 -0
- package/src/commands/pdf/ocr.ts +270 -0
- package/src/commands/pdf/organize.ts +88 -0
- package/src/commands/pdf/page-numbers.ts +152 -0
- package/src/commands/pdf/reverse.ts +71 -0
- package/src/commands/pdf/rotate.ts +116 -0
- package/src/commands/pdf/sanitize.ts +77 -0
- package/src/commands/pdf/sign.ts +156 -0
- package/src/commands/pdf/split.ts +148 -0
- package/src/commands/pdf/to-images.ts +84 -0
- package/src/commands/pdf/to-text.ts +51 -0
- package/src/commands/pdf/watermark.ts +179 -0
- package/src/commands/qr/bulk-generate.ts +136 -0
- package/src/commands/qr/generate.ts +128 -0
- package/src/commands/qr/index.ts +16 -0
- package/src/commands/qr/scan.ts +114 -0
- package/src/commands/setup.ts +156 -0
- package/src/index.ts +42 -0
- package/src/lib/audio/ffmpeg.ts +93 -0
- package/src/utils/colors.ts +41 -0
- package/src/utils/detect.ts +222 -0
- package/src/utils/errors.ts +89 -0
- package/src/utils/files.ts +148 -0
- package/src/utils/logger.ts +90 -0
- package/src/utils/pdf-tools.ts +220 -0
- package/src/utils/progress.ts +142 -0
- package/src/utils/style.ts +38 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { PDFDocument } from 'pdf-lib';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
function parsePages(pagesStr: string, totalPages: number): Set<number> {
|
|
10
|
+
const pages: Set<number> = new Set();
|
|
11
|
+
const parts = pagesStr.split(',').map((s) => s.trim());
|
|
12
|
+
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
if (part.includes('-')) {
|
|
15
|
+
const [start, end] = part.split('-').map((s) => Number.parseInt(s.trim(), 10));
|
|
16
|
+
for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) {
|
|
17
|
+
pages.add(i - 1);
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
const page = Number.parseInt(part, 10);
|
|
21
|
+
if (page >= 1 && page <= totalPages) {
|
|
22
|
+
pages.add(page - 1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return pages;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const deletePages = defineCommand({
|
|
31
|
+
meta: {
|
|
32
|
+
name: 'delete-pages',
|
|
33
|
+
description: 'Remove specific pages from a PDF',
|
|
34
|
+
},
|
|
35
|
+
args: {
|
|
36
|
+
input: {
|
|
37
|
+
type: 'positional',
|
|
38
|
+
description: 'Input PDF file',
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
output: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
alias: 'o',
|
|
44
|
+
description: 'Output file path',
|
|
45
|
+
},
|
|
46
|
+
pages: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
alias: 'p',
|
|
49
|
+
description: 'Pages to delete (e.g., "2,4,6" or "5-10")',
|
|
50
|
+
required: true,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
async run({ args }) {
|
|
54
|
+
try {
|
|
55
|
+
const input = requireArg(args.input, 'input');
|
|
56
|
+
const pages = requireArg(args.pages, 'pages');
|
|
57
|
+
|
|
58
|
+
const inputPath = resolvePath(input as string);
|
|
59
|
+
if (!existsSync(inputPath)) {
|
|
60
|
+
throw new FileNotFoundError(input as string);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-trimmed');
|
|
64
|
+
ensureDir(outputPath);
|
|
65
|
+
|
|
66
|
+
const inputSize = getFileSize(inputPath);
|
|
67
|
+
|
|
68
|
+
await withSpinner(
|
|
69
|
+
'Deleting pages...',
|
|
70
|
+
async () => {
|
|
71
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
72
|
+
const sourcePdf = await PDFDocument.load(pdfBytes);
|
|
73
|
+
const newPdf = await PDFDocument.create();
|
|
74
|
+
|
|
75
|
+
const totalPages = sourcePdf.getPageCount();
|
|
76
|
+
const pagesToDelete = parsePages(pages, totalPages);
|
|
77
|
+
|
|
78
|
+
// Get pages to keep (all except deleted)
|
|
79
|
+
const pagesToKeep: number[] = [];
|
|
80
|
+
for (let i = 0; i < totalPages; i++) {
|
|
81
|
+
if (!pagesToDelete.has(i)) {
|
|
82
|
+
pagesToKeep.push(i);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (pagesToKeep.length === 0) {
|
|
87
|
+
throw new Error('Cannot delete all pages from a PDF');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const copiedPages = await newPdf.copyPages(sourcePdf, pagesToKeep);
|
|
91
|
+
for (const page of copiedPages) {
|
|
92
|
+
newPdf.addPage(page);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const trimmedBytes = await newPdf.save();
|
|
96
|
+
await Bun.write(outputPath, trimmedBytes);
|
|
97
|
+
},
|
|
98
|
+
'Pages deleted successfully'
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
fileResult(inputPath, outputPath, {
|
|
102
|
+
before: inputSize,
|
|
103
|
+
after: getFileSize(outputPath),
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
info(`Deleted pages: ${pages}`);
|
|
107
|
+
success('PDF pages deleted');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
handleError(err);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { PDFDocument } from 'pdf-lib';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
function parsePages(pagesStr: string, totalPages: number): number[] {
|
|
10
|
+
const pages: Set<number> = new Set();
|
|
11
|
+
const parts = pagesStr.split(',').map((s) => s.trim());
|
|
12
|
+
|
|
13
|
+
for (const part of parts) {
|
|
14
|
+
if (part.includes('-')) {
|
|
15
|
+
const [start, end] = part.split('-').map((s) => Number.parseInt(s.trim(), 10));
|
|
16
|
+
for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) {
|
|
17
|
+
pages.add(i - 1);
|
|
18
|
+
}
|
|
19
|
+
} else {
|
|
20
|
+
const page = Number.parseInt(part, 10);
|
|
21
|
+
if (page >= 1 && page <= totalPages) {
|
|
22
|
+
pages.add(page - 1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return Array.from(pages);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const duplicate = defineCommand({
|
|
31
|
+
meta: {
|
|
32
|
+
name: 'duplicate',
|
|
33
|
+
description: 'Duplicate specific pages in a PDF',
|
|
34
|
+
},
|
|
35
|
+
args: {
|
|
36
|
+
input: {
|
|
37
|
+
type: 'positional',
|
|
38
|
+
description: 'Input PDF file',
|
|
39
|
+
required: true,
|
|
40
|
+
},
|
|
41
|
+
output: {
|
|
42
|
+
type: 'string',
|
|
43
|
+
alias: 'o',
|
|
44
|
+
description: 'Output file path',
|
|
45
|
+
},
|
|
46
|
+
pages: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
alias: 'p',
|
|
49
|
+
description: 'Pages to duplicate (e.g., "1,3,5" or "1-5")',
|
|
50
|
+
required: true,
|
|
51
|
+
},
|
|
52
|
+
times: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
alias: 't',
|
|
55
|
+
description: 'Number of times to duplicate',
|
|
56
|
+
default: '1',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
async run({ args }) {
|
|
60
|
+
try {
|
|
61
|
+
const input = requireArg(args.input, 'input');
|
|
62
|
+
const pages = requireArg(args.pages, 'pages');
|
|
63
|
+
|
|
64
|
+
const inputPath = resolvePath(input as string);
|
|
65
|
+
if (!existsSync(inputPath)) {
|
|
66
|
+
throw new FileNotFoundError(input as string);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-duplicated');
|
|
70
|
+
ensureDir(outputPath);
|
|
71
|
+
|
|
72
|
+
const inputSize = getFileSize(inputPath);
|
|
73
|
+
const times = Number.parseInt(args.times || '1', 10);
|
|
74
|
+
|
|
75
|
+
await withSpinner(
|
|
76
|
+
'Duplicating pages...',
|
|
77
|
+
async () => {
|
|
78
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
79
|
+
const sourcePdf = await PDFDocument.load(pdfBytes);
|
|
80
|
+
const newPdf = await PDFDocument.create();
|
|
81
|
+
|
|
82
|
+
const totalPages = sourcePdf.getPageCount();
|
|
83
|
+
const pagesToDuplicate = parsePages(pages, totalPages);
|
|
84
|
+
|
|
85
|
+
// First, copy all original pages
|
|
86
|
+
const allPages = await newPdf.copyPages(
|
|
87
|
+
sourcePdf,
|
|
88
|
+
Array.from({ length: totalPages }, (_, i) => i)
|
|
89
|
+
);
|
|
90
|
+
for (const page of allPages) {
|
|
91
|
+
newPdf.addPage(page);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Then, add duplicates at the end
|
|
95
|
+
for (let i = 0; i < times; i++) {
|
|
96
|
+
const duplicates = await newPdf.copyPages(sourcePdf, pagesToDuplicate);
|
|
97
|
+
for (const page of duplicates) {
|
|
98
|
+
newPdf.addPage(page);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const duplicatedBytes = await newPdf.save();
|
|
103
|
+
await Bun.write(outputPath, duplicatedBytes);
|
|
104
|
+
},
|
|
105
|
+
'Pages duplicated successfully'
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
fileResult(inputPath, outputPath, {
|
|
109
|
+
before: inputSize,
|
|
110
|
+
after: getFileSize(outputPath),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
info(`Duplicated pages ${pages} ${times} time(s)`);
|
|
114
|
+
success('PDF pages duplicated');
|
|
115
|
+
} catch (err) {
|
|
116
|
+
handleError(err);
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { defineCommand } from 'citty';
|
|
4
|
+
import { detectQpdf, getInstallCommand } from '../../utils/detect';
|
|
5
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
6
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
7
|
+
import { fileResult, info, success, warn } from '../../utils/logger';
|
|
8
|
+
import { withSpinner } from '../../utils/progress';
|
|
9
|
+
|
|
10
|
+
export const encrypt = defineCommand({
|
|
11
|
+
meta: {
|
|
12
|
+
name: 'encrypt',
|
|
13
|
+
description: 'Add password protection to a PDF using qpdf',
|
|
14
|
+
},
|
|
15
|
+
args: {
|
|
16
|
+
input: {
|
|
17
|
+
type: 'positional',
|
|
18
|
+
description: 'Input PDF file',
|
|
19
|
+
required: true,
|
|
20
|
+
},
|
|
21
|
+
output: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
alias: 'o',
|
|
24
|
+
description: 'Output file path',
|
|
25
|
+
},
|
|
26
|
+
password: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
alias: 'p',
|
|
29
|
+
description: 'User password (required to open the PDF)',
|
|
30
|
+
required: true,
|
|
31
|
+
},
|
|
32
|
+
ownerPassword: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
alias: 'O',
|
|
35
|
+
description: 'Owner password (for editing permissions, defaults to user password)',
|
|
36
|
+
},
|
|
37
|
+
keyLength: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
alias: 'k',
|
|
40
|
+
description: 'Encryption key length: 40, 128, 256 (default: 256)',
|
|
41
|
+
default: '256',
|
|
42
|
+
},
|
|
43
|
+
print: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
description: 'Print permission: none, low, full (default: full)',
|
|
46
|
+
default: 'full',
|
|
47
|
+
},
|
|
48
|
+
modify: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Modify permission: none, assembly, form, annotate, all (default: none)',
|
|
51
|
+
default: 'none',
|
|
52
|
+
},
|
|
53
|
+
extract: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Allow text/graphics extraction (default: false)',
|
|
56
|
+
default: false,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
async run({ args }) {
|
|
60
|
+
try {
|
|
61
|
+
const input = requireArg(args.input, 'input');
|
|
62
|
+
const userPassword = requireArg(args.password, 'password');
|
|
63
|
+
const ownerPassword = args.ownerPassword || userPassword;
|
|
64
|
+
|
|
65
|
+
const inputPath = resolvePath(input as string);
|
|
66
|
+
if (!existsSync(inputPath)) {
|
|
67
|
+
throw new FileNotFoundError(input as string);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Check for qpdf
|
|
71
|
+
const qpdf = await detectQpdf();
|
|
72
|
+
if (!qpdf.available || !qpdf.path) {
|
|
73
|
+
warn('qpdf is required for PDF encryption');
|
|
74
|
+
const installCmd = getInstallCommand('qpdf');
|
|
75
|
+
if (installCmd) {
|
|
76
|
+
info(`Install: ${installCmd}`);
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-encrypted');
|
|
82
|
+
ensureDir(outputPath);
|
|
83
|
+
|
|
84
|
+
const inputSize = getFileSize(inputPath);
|
|
85
|
+
const keyLength = args.keyLength || '256';
|
|
86
|
+
|
|
87
|
+
// Build qpdf arguments
|
|
88
|
+
const qpdfArgs = [
|
|
89
|
+
'--encrypt',
|
|
90
|
+
userPassword,
|
|
91
|
+
ownerPassword,
|
|
92
|
+
keyLength,
|
|
93
|
+
'--',
|
|
94
|
+
inputPath,
|
|
95
|
+
outputPath,
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
// Add permission flags for 256-bit encryption
|
|
99
|
+
if (keyLength === '256' || keyLength === '128') {
|
|
100
|
+
// Print permission
|
|
101
|
+
if (args.print === 'none') {
|
|
102
|
+
qpdfArgs.splice(4, 0, '--print=none');
|
|
103
|
+
} else if (args.print === 'low') {
|
|
104
|
+
qpdfArgs.splice(4, 0, '--print=low');
|
|
105
|
+
}
|
|
106
|
+
// Modify permission
|
|
107
|
+
if (args.modify && args.modify !== 'all') {
|
|
108
|
+
qpdfArgs.splice(4, 0, `--modify=${args.modify}`);
|
|
109
|
+
}
|
|
110
|
+
// Extract permission
|
|
111
|
+
if (!args.extract) {
|
|
112
|
+
qpdfArgs.splice(4, 0, '--extract=n');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const qpdfPath = qpdf.path;
|
|
117
|
+
|
|
118
|
+
await withSpinner(
|
|
119
|
+
'Encrypting PDF with qpdf...',
|
|
120
|
+
async () => {
|
|
121
|
+
return new Promise<void>((resolve, reject) => {
|
|
122
|
+
const proc = spawn(qpdfPath, qpdfArgs, {
|
|
123
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
let stderr = '';
|
|
127
|
+
proc.stderr?.on('data', (data) => {
|
|
128
|
+
stderr += data.toString();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
proc.on('close', (code) => {
|
|
132
|
+
if (code === 0) {
|
|
133
|
+
resolve();
|
|
134
|
+
} else {
|
|
135
|
+
reject(new Error(`qpdf failed: ${stderr}`));
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
proc.on('error', reject);
|
|
140
|
+
});
|
|
141
|
+
},
|
|
142
|
+
'PDF encrypted successfully'
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
fileResult(inputPath, outputPath, {
|
|
146
|
+
before: inputSize,
|
|
147
|
+
after: getFileSize(outputPath),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
info(`Encryption: AES-${keyLength}`);
|
|
151
|
+
info(`User password: ${userPassword.replace(/./g, '*')}`);
|
|
152
|
+
if (args.ownerPassword) {
|
|
153
|
+
info(`Owner password: ${ownerPassword.replace(/./g, '*')}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
success('PDF encrypted with password protection');
|
|
157
|
+
} catch (err) {
|
|
158
|
+
handleError(err);
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import sharp from 'sharp';
|
|
3
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
4
|
+
import { ensureDir, getFileSize, globFiles, resolvePath } from '../../utils/files';
|
|
5
|
+
import { fileResult, info, success } from '../../utils/logger';
|
|
6
|
+
import { withSpinner } from '../../utils/progress';
|
|
7
|
+
|
|
8
|
+
export const fromImages = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'from-images',
|
|
11
|
+
description: 'Create a PDF from images',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
images: {
|
|
15
|
+
type: 'positional',
|
|
16
|
+
description: 'Image files or glob pattern',
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
alias: 'o',
|
|
22
|
+
description: 'Output PDF file',
|
|
23
|
+
required: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async run({ args }) {
|
|
27
|
+
try {
|
|
28
|
+
const images = requireArg(args.images, 'images');
|
|
29
|
+
const output = requireArg(args.output, 'output');
|
|
30
|
+
|
|
31
|
+
// Get image files
|
|
32
|
+
const files = args._ as string[];
|
|
33
|
+
let imageFiles: string[] = [];
|
|
34
|
+
|
|
35
|
+
if (files && files.length > 0) {
|
|
36
|
+
imageFiles = files.map(resolvePath);
|
|
37
|
+
} else {
|
|
38
|
+
// Try as glob pattern
|
|
39
|
+
imageFiles = await globFiles(images as string);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (imageFiles.length === 0) {
|
|
43
|
+
throw new Error('No image files found');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Sort files naturally
|
|
47
|
+
imageFiles.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
|
|
48
|
+
|
|
49
|
+
const outputPath = resolvePath(output);
|
|
50
|
+
ensureDir(outputPath);
|
|
51
|
+
|
|
52
|
+
info(`Found ${imageFiles.length} images`);
|
|
53
|
+
|
|
54
|
+
const { PDFDocument } = await import('pdf-lib');
|
|
55
|
+
|
|
56
|
+
await withSpinner(
|
|
57
|
+
`Creating PDF from ${imageFiles.length} images...`,
|
|
58
|
+
async () => {
|
|
59
|
+
const pdf = await PDFDocument.create();
|
|
60
|
+
|
|
61
|
+
for (const imagePath of imageFiles) {
|
|
62
|
+
const imageBuffer = await Bun.file(imagePath).arrayBuffer();
|
|
63
|
+
const imageBytes = new Uint8Array(imageBuffer);
|
|
64
|
+
|
|
65
|
+
// Determine image type and convert if necessary
|
|
66
|
+
const metadata = await sharp(Buffer.from(imageBytes)).metadata();
|
|
67
|
+
// biome-ignore lint/suspicious/noExplicitAny: complex PDFImage type from lazy load
|
|
68
|
+
let embeddedImage: any;
|
|
69
|
+
|
|
70
|
+
if (metadata.format === 'png') {
|
|
71
|
+
embeddedImage = await pdf.embedPng(imageBytes);
|
|
72
|
+
} else {
|
|
73
|
+
// Convert to JPEG for other formats
|
|
74
|
+
const jpegBuffer = await sharp(Buffer.from(imageBytes))
|
|
75
|
+
.jpeg({ quality: 90 })
|
|
76
|
+
.toBuffer();
|
|
77
|
+
embeddedImage = await pdf.embedJpg(jpegBuffer);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Create page with image dimensions
|
|
81
|
+
const page = pdf.addPage([embeddedImage.width, embeddedImage.height]);
|
|
82
|
+
page.drawImage(embeddedImage, {
|
|
83
|
+
x: 0,
|
|
84
|
+
y: 0,
|
|
85
|
+
width: embeddedImage.width,
|
|
86
|
+
height: embeddedImage.height,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const pdfBytes = await pdf.save();
|
|
91
|
+
await Bun.write(outputPath, pdfBytes);
|
|
92
|
+
},
|
|
93
|
+
'PDF created successfully'
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const outputSize = getFileSize(outputPath);
|
|
97
|
+
fileResult(`${imageFiles.length} images`, outputPath, { before: 0, after: outputSize });
|
|
98
|
+
|
|
99
|
+
success(`Created PDF with ${imageFiles.length} pages`);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
handleError(err);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { compress } from './compress';
|
|
3
|
+
import { decrypt } from './decrypt';
|
|
4
|
+
import { deletePages } from './delete-pages';
|
|
5
|
+
import { duplicate } from './duplicate';
|
|
6
|
+
import { encrypt } from './encrypt';
|
|
7
|
+
import { fromImages } from './from-images';
|
|
8
|
+
import { merge } from './merge';
|
|
9
|
+
import { ocr } from './ocr';
|
|
10
|
+
import { organize } from './organize';
|
|
11
|
+
import { pageNumbers } from './page-numbers';
|
|
12
|
+
import { reverse } from './reverse';
|
|
13
|
+
import { rotate } from './rotate';
|
|
14
|
+
import { sanitize } from './sanitize';
|
|
15
|
+
import { sign } from './sign';
|
|
16
|
+
import { split } from './split';
|
|
17
|
+
import { toImages } from './to-images';
|
|
18
|
+
import { toText } from './to-text';
|
|
19
|
+
import { watermark } from './watermark';
|
|
20
|
+
|
|
21
|
+
export const pdf = defineCommand({
|
|
22
|
+
meta: {
|
|
23
|
+
name: 'pdf',
|
|
24
|
+
description: 'PDF manipulation tools',
|
|
25
|
+
},
|
|
26
|
+
subCommands: {
|
|
27
|
+
// Essentials
|
|
28
|
+
merge,
|
|
29
|
+
split,
|
|
30
|
+
compress,
|
|
31
|
+
organize,
|
|
32
|
+
|
|
33
|
+
// Page Operations
|
|
34
|
+
rotate,
|
|
35
|
+
'delete-pages': deletePages,
|
|
36
|
+
duplicate,
|
|
37
|
+
reverse,
|
|
38
|
+
|
|
39
|
+
// Content & Modification
|
|
40
|
+
watermark,
|
|
41
|
+
'page-numbers': pageNumbers,
|
|
42
|
+
sign,
|
|
43
|
+
sanitize,
|
|
44
|
+
|
|
45
|
+
// Security
|
|
46
|
+
encrypt,
|
|
47
|
+
decrypt,
|
|
48
|
+
|
|
49
|
+
// Conversion & OCR
|
|
50
|
+
'to-images': toImages,
|
|
51
|
+
'from-images': fromImages,
|
|
52
|
+
'to-text': toText,
|
|
53
|
+
ocr,
|
|
54
|
+
},
|
|
55
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
4
|
+
import { ensureDir, getFileSize, resolvePath } from '../../utils/files';
|
|
5
|
+
import { fileResult, success } from '../../utils/logger';
|
|
6
|
+
import { withSpinner } from '../../utils/progress';
|
|
7
|
+
|
|
8
|
+
export const merge = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'merge',
|
|
11
|
+
description: 'Merge multiple PDF files into one',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
files: {
|
|
15
|
+
type: 'positional',
|
|
16
|
+
description: 'PDF files to merge (at least 2)',
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
output: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
alias: 'o',
|
|
22
|
+
description: 'Output file path',
|
|
23
|
+
required: true,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async run({ args }) {
|
|
27
|
+
try {
|
|
28
|
+
const output = requireArg(args.output, 'output');
|
|
29
|
+
const files = args._ as string[];
|
|
30
|
+
|
|
31
|
+
if (!files || files.length < 2) {
|
|
32
|
+
throw new Error('At least 2 PDF files are required to merge');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate all files exist
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const resolved = resolvePath(file);
|
|
38
|
+
if (!existsSync(resolved)) {
|
|
39
|
+
throw new FileNotFoundError(file);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const outputPath = resolvePath(output);
|
|
44
|
+
ensureDir(outputPath);
|
|
45
|
+
|
|
46
|
+
const { PDFDocument } = await import('pdf-lib');
|
|
47
|
+
|
|
48
|
+
await withSpinner(
|
|
49
|
+
`Merging ${files.length} PDF files...`,
|
|
50
|
+
async () => {
|
|
51
|
+
const mergedPdf = await PDFDocument.create();
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const filePath = resolvePath(file);
|
|
55
|
+
const pdfBytes = await Bun.file(filePath).arrayBuffer();
|
|
56
|
+
const pdf = await PDFDocument.load(pdfBytes);
|
|
57
|
+
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
|
|
58
|
+
|
|
59
|
+
for (const page of pages) {
|
|
60
|
+
mergedPdf.addPage(page);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const mergedBytes = await mergedPdf.save();
|
|
65
|
+
await Bun.write(outputPath, mergedBytes);
|
|
66
|
+
},
|
|
67
|
+
'PDFs merged successfully'
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const totalInputSize = files.reduce((sum, file) => {
|
|
71
|
+
return sum + getFileSize(resolvePath(file));
|
|
72
|
+
}, 0);
|
|
73
|
+
|
|
74
|
+
fileResult(`${files.length} files`, outputPath, {
|
|
75
|
+
before: totalInputSize,
|
|
76
|
+
after: getFileSize(outputPath),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
success(`Merged ${files.length} PDFs into ${output}`);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
handleError(err);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
});
|