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,270 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { basename, dirname, join } from 'node:path';
|
|
5
|
+
import { defineCommand } from 'citty';
|
|
6
|
+
import Tesseract from 'tesseract.js';
|
|
7
|
+
import { detectMutool, detectTool, getInstallCommand } from '../../utils/detect';
|
|
8
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
9
|
+
import {
|
|
10
|
+
ensureDir,
|
|
11
|
+
generateOutputPath,
|
|
12
|
+
getBasename,
|
|
13
|
+
getExtension,
|
|
14
|
+
resolvePath,
|
|
15
|
+
} from '../../utils/files';
|
|
16
|
+
import { info, success, warn } from '../../utils/logger';
|
|
17
|
+
import { withSpinner } from '../../utils/progress';
|
|
18
|
+
|
|
19
|
+
// Convert PDF to images using available tools
|
|
20
|
+
async function pdfToImages(pdfPath: string, outputDir: string): Promise<string[]> {
|
|
21
|
+
// Try mutool first
|
|
22
|
+
const mutool = await detectMutool();
|
|
23
|
+
if (mutool.available && mutool.path) {
|
|
24
|
+
return await convertWithMutool(mutool.path, pdfPath, outputDir);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Try pdftocairo (poppler)
|
|
28
|
+
const pdftocairo = await detectTool('pdftocairo');
|
|
29
|
+
if (pdftocairo.available && pdftocairo.path) {
|
|
30
|
+
return await convertWithPdftocairo(pdftocairo.path, pdfPath, outputDir);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Try pdftoppm (poppler alternative)
|
|
34
|
+
const pdftoppm = await detectTool('pdftoppm');
|
|
35
|
+
if (pdftoppm.available && pdftoppm.path) {
|
|
36
|
+
return await convertWithPdftoppm(pdftoppm.path, pdfPath, outputDir);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(
|
|
40
|
+
'No PDF-to-image converter found.\n\n' +
|
|
41
|
+
'Install one of these:\n' +
|
|
42
|
+
' • mupdf-tools: brew install mupdf-tools (macOS) | apt install mupdf-tools (Linux)\n' +
|
|
43
|
+
' • poppler: brew install poppler (macOS) | apt install poppler-utils (Linux)'
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function convertWithMutool(
|
|
48
|
+
mutoolPath: string,
|
|
49
|
+
pdfPath: string,
|
|
50
|
+
outputDir: string
|
|
51
|
+
): Promise<string[]> {
|
|
52
|
+
const outputPattern = join(outputDir, 'page-%d.png');
|
|
53
|
+
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
const proc = spawn(mutoolPath, ['draw', '-o', outputPattern, '-r', '300', pdfPath], {
|
|
56
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let stderr = '';
|
|
60
|
+
proc.stderr?.on('data', (data) => {
|
|
61
|
+
stderr += data.toString();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
proc.on('close', (code) => {
|
|
65
|
+
if (code === 0) {
|
|
66
|
+
const files = readdirSync(outputDir)
|
|
67
|
+
.filter((f) => f.startsWith('page-') && f.endsWith('.png'))
|
|
68
|
+
.sort((a, b) => {
|
|
69
|
+
const numA = Number.parseInt(a.match(/\d+/)?.[0] || '0', 10);
|
|
70
|
+
const numB = Number.parseInt(b.match(/\d+/)?.[0] || '0', 10);
|
|
71
|
+
return numA - numB;
|
|
72
|
+
})
|
|
73
|
+
.map((f) => join(outputDir, f));
|
|
74
|
+
resolve(files);
|
|
75
|
+
} else {
|
|
76
|
+
reject(new Error(`mutool failed: ${stderr}`));
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
proc.on('error', reject);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function convertWithPdftocairo(
|
|
85
|
+
toolPath: string,
|
|
86
|
+
pdfPath: string,
|
|
87
|
+
outputDir: string
|
|
88
|
+
): Promise<string[]> {
|
|
89
|
+
const outputPrefix = join(outputDir, 'page');
|
|
90
|
+
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const proc = spawn(toolPath, ['-png', '-r', '300', pdfPath, outputPrefix], {
|
|
93
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let stderr = '';
|
|
97
|
+
proc.stderr?.on('data', (data) => {
|
|
98
|
+
stderr += data.toString();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
proc.on('close', (code) => {
|
|
102
|
+
if (code === 0) {
|
|
103
|
+
const files = readdirSync(outputDir)
|
|
104
|
+
.filter((f) => f.startsWith('page') && f.endsWith('.png'))
|
|
105
|
+
.sort()
|
|
106
|
+
.map((f) => join(outputDir, f));
|
|
107
|
+
resolve(files);
|
|
108
|
+
} else {
|
|
109
|
+
reject(new Error(`pdftocairo failed: ${stderr}`));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
proc.on('error', reject);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function convertWithPdftoppm(
|
|
118
|
+
toolPath: string,
|
|
119
|
+
pdfPath: string,
|
|
120
|
+
outputDir: string
|
|
121
|
+
): Promise<string[]> {
|
|
122
|
+
const outputPrefix = join(outputDir, 'page');
|
|
123
|
+
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const proc = spawn(toolPath, ['-png', '-r', '300', pdfPath, outputPrefix], {
|
|
126
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
let stderr = '';
|
|
130
|
+
proc.stderr?.on('data', (data) => {
|
|
131
|
+
stderr += data.toString();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
proc.on('close', (code) => {
|
|
135
|
+
if (code === 0) {
|
|
136
|
+
const files = readdirSync(outputDir)
|
|
137
|
+
.filter((f) => f.startsWith('page') && f.endsWith('.png'))
|
|
138
|
+
.sort()
|
|
139
|
+
.map((f) => join(outputDir, f));
|
|
140
|
+
resolve(files);
|
|
141
|
+
} else {
|
|
142
|
+
reject(new Error(`pdftoppm failed: ${stderr}`));
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
proc.on('error', reject);
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Clean up temp directory
|
|
151
|
+
function cleanupTempDir(dir: string) {
|
|
152
|
+
try {
|
|
153
|
+
const files = readdirSync(dir);
|
|
154
|
+
for (const file of files) {
|
|
155
|
+
unlinkSync(join(dir, file));
|
|
156
|
+
}
|
|
157
|
+
rmdirSync(dir);
|
|
158
|
+
} catch {
|
|
159
|
+
// Ignore cleanup errors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const ocr = defineCommand({
|
|
164
|
+
meta: {
|
|
165
|
+
name: 'ocr',
|
|
166
|
+
description: 'Perform OCR on a PDF or image to extract text',
|
|
167
|
+
},
|
|
168
|
+
args: {
|
|
169
|
+
input: {
|
|
170
|
+
type: 'positional',
|
|
171
|
+
description: 'Input PDF or image file',
|
|
172
|
+
required: true,
|
|
173
|
+
},
|
|
174
|
+
output: {
|
|
175
|
+
type: 'string',
|
|
176
|
+
alias: 'o',
|
|
177
|
+
description: 'Output text file path',
|
|
178
|
+
},
|
|
179
|
+
lang: {
|
|
180
|
+
type: 'string',
|
|
181
|
+
alias: 'l',
|
|
182
|
+
description: 'Language(s) for OCR (e.g., "eng", "eng+fra")',
|
|
183
|
+
default: 'eng',
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
async run({ args }) {
|
|
187
|
+
try {
|
|
188
|
+
const input = requireArg(args.input, 'input');
|
|
189
|
+
|
|
190
|
+
const inputPath = resolvePath(input as string);
|
|
191
|
+
if (!existsSync(inputPath)) {
|
|
192
|
+
throw new FileNotFoundError(input as string);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const ext = getExtension(inputPath).toLowerCase();
|
|
196
|
+
const lang = args.lang || 'eng';
|
|
197
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-ocr', 'txt');
|
|
198
|
+
ensureDir(outputPath);
|
|
199
|
+
|
|
200
|
+
let imagePaths: string[] = [];
|
|
201
|
+
let tempDir: string | null = null;
|
|
202
|
+
|
|
203
|
+
// If PDF, convert to images first
|
|
204
|
+
if (ext === 'pdf') {
|
|
205
|
+
tempDir = join(tmpdir(), `noupload-ocr-${Date.now()}`);
|
|
206
|
+
mkdirSync(tempDir, { recursive: true });
|
|
207
|
+
|
|
208
|
+
const tempDirPath = tempDir;
|
|
209
|
+
imagePaths = await withSpinner(
|
|
210
|
+
'Converting PDF to images...',
|
|
211
|
+
async () => await pdfToImages(inputPath, tempDirPath),
|
|
212
|
+
'PDF converted to images'
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
info(`Processing ${imagePaths.length} pages...`);
|
|
216
|
+
} else {
|
|
217
|
+
imagePaths = [inputPath];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
info(`Starting OCR with language: ${lang}`);
|
|
221
|
+
|
|
222
|
+
const allText: string[] = [];
|
|
223
|
+
let totalWords = 0;
|
|
224
|
+
let avgConfidence = 0;
|
|
225
|
+
|
|
226
|
+
// Process each image
|
|
227
|
+
for (let i = 0; i < imagePaths.length; i++) {
|
|
228
|
+
const imagePath = imagePaths[i];
|
|
229
|
+
const pageNum = i + 1;
|
|
230
|
+
|
|
231
|
+
const result = await withSpinner(
|
|
232
|
+
`OCR page ${pageNum}/${imagePaths.length}...`,
|
|
233
|
+
async () => {
|
|
234
|
+
return await Tesseract.recognize(imagePath, lang);
|
|
235
|
+
},
|
|
236
|
+
`Page ${pageNum} complete`
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
allText.push(result.data.text);
|
|
240
|
+
totalWords += result.data.words.length;
|
|
241
|
+
avgConfidence += result.data.confidence;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
avgConfidence = avgConfidence / imagePaths.length;
|
|
245
|
+
|
|
246
|
+
// Combine and save text
|
|
247
|
+
const combinedText = allText.join('\n\n--- Page Break ---\n\n');
|
|
248
|
+
await Bun.write(outputPath, combinedText);
|
|
249
|
+
|
|
250
|
+
// Cleanup temp files
|
|
251
|
+
if (tempDir) {
|
|
252
|
+
cleanupTempDir(tempDir);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
console.log();
|
|
256
|
+
info(`Pages processed: ${imagePaths.length}`);
|
|
257
|
+
info(`Average confidence: ${avgConfidence.toFixed(1)}%`);
|
|
258
|
+
info(`Total words found: ${totalWords}`);
|
|
259
|
+
|
|
260
|
+
if (avgConfidence < 70) {
|
|
261
|
+
warn('Low confidence - the scan quality may be poor or try a different language.');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
console.log();
|
|
265
|
+
success(`OCR complete. Text saved to: ${outputPath}`);
|
|
266
|
+
} catch (err) {
|
|
267
|
+
handleError(err);
|
|
268
|
+
}
|
|
269
|
+
},
|
|
270
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
export const organize = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'organize',
|
|
12
|
+
description: 'Reorder pages in a PDF',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input PDF file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
order: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'New page order (comma-separated, 1-indexed). e.g., "3,1,2,5,4"',
|
|
28
|
+
required: true,
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
async run({ args }) {
|
|
32
|
+
try {
|
|
33
|
+
const input = requireArg(args.input, 'input');
|
|
34
|
+
const order = requireArg(args.order, 'order');
|
|
35
|
+
|
|
36
|
+
const inputPath = resolvePath(input as string);
|
|
37
|
+
if (!existsSync(inputPath)) {
|
|
38
|
+
throw new FileNotFoundError(input as string);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-organized');
|
|
42
|
+
ensureDir(outputPath);
|
|
43
|
+
|
|
44
|
+
const inputSize = getFileSize(inputPath);
|
|
45
|
+
|
|
46
|
+
// Parse page order
|
|
47
|
+
const pageOrder = order.split(',').map((s) => Number.parseInt(s.trim(), 10) - 1);
|
|
48
|
+
|
|
49
|
+
await withSpinner(
|
|
50
|
+
'Reorganizing pages...',
|
|
51
|
+
async () => {
|
|
52
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
53
|
+
const sourcePdf = await PDFDocument.load(pdfBytes);
|
|
54
|
+
const newPdf = await PDFDocument.create();
|
|
55
|
+
|
|
56
|
+
const totalPages = sourcePdf.getPageCount();
|
|
57
|
+
|
|
58
|
+
// Validate page numbers
|
|
59
|
+
for (const pageNum of pageOrder) {
|
|
60
|
+
if (pageNum < 0 || pageNum >= totalPages) {
|
|
61
|
+
throw new Error(`Invalid page number: ${pageNum + 1}. PDF has ${totalPages} pages.`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Copy pages in new order
|
|
66
|
+
const copiedPages = await newPdf.copyPages(sourcePdf, pageOrder);
|
|
67
|
+
for (const page of copiedPages) {
|
|
68
|
+
newPdf.addPage(page);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const organizedBytes = await newPdf.save();
|
|
72
|
+
await Bun.write(outputPath, organizedBytes);
|
|
73
|
+
},
|
|
74
|
+
'Pages reorganized successfully'
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
fileResult(inputPath, outputPath, {
|
|
78
|
+
before: inputSize,
|
|
79
|
+
after: getFileSize(outputPath),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
info(`New page order: ${order}`);
|
|
83
|
+
success('PDF pages reorganized');
|
|
84
|
+
} catch (err) {
|
|
85
|
+
handleError(err);
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { PDFDocument, StandardFonts, rgb } from 'pdf-lib';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
type PagePosition = 'top' | 'bottom';
|
|
10
|
+
type HorizontalAlign = 'left' | 'center' | 'right';
|
|
11
|
+
|
|
12
|
+
export const pageNumbers = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: 'page-numbers',
|
|
15
|
+
description: 'Add page numbers to a PDF',
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
input: {
|
|
19
|
+
type: 'positional',
|
|
20
|
+
description: 'Input PDF file',
|
|
21
|
+
required: true,
|
|
22
|
+
},
|
|
23
|
+
output: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
alias: 'o',
|
|
26
|
+
description: 'Output file path',
|
|
27
|
+
},
|
|
28
|
+
format: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
alias: 'f',
|
|
31
|
+
description: 'Number format. Use {n} for current page, {total} for total pages',
|
|
32
|
+
default: 'Page {n} of {total}',
|
|
33
|
+
},
|
|
34
|
+
position: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
alias: 'p',
|
|
37
|
+
description: 'Position: top, bottom',
|
|
38
|
+
default: 'bottom',
|
|
39
|
+
},
|
|
40
|
+
align: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
alias: 'a',
|
|
43
|
+
description: 'Alignment: left, center, right',
|
|
44
|
+
default: 'center',
|
|
45
|
+
},
|
|
46
|
+
size: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
alias: 's',
|
|
49
|
+
description: 'Font size',
|
|
50
|
+
default: '12',
|
|
51
|
+
},
|
|
52
|
+
start: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
description: 'Starting page number',
|
|
55
|
+
default: '1',
|
|
56
|
+
},
|
|
57
|
+
x: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'Custom X coordinate (overrides position/align)',
|
|
60
|
+
},
|
|
61
|
+
y: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
description: 'Custom Y coordinate (overrides position)',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
async run({ args }) {
|
|
67
|
+
try {
|
|
68
|
+
const input = requireArg(args.input, 'input');
|
|
69
|
+
|
|
70
|
+
const inputPath = resolvePath(input as string);
|
|
71
|
+
if (!existsSync(inputPath)) {
|
|
72
|
+
throw new FileNotFoundError(input as string);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-numbered');
|
|
76
|
+
ensureDir(outputPath);
|
|
77
|
+
|
|
78
|
+
const inputSize = getFileSize(inputPath);
|
|
79
|
+
const format = args.format || 'Page {n} of {total}';
|
|
80
|
+
const position = (args.position || 'bottom') as PagePosition;
|
|
81
|
+
const align = (args.align || 'center') as HorizontalAlign;
|
|
82
|
+
const fontSize = Number.parseInt(args.size || '12', 10);
|
|
83
|
+
const startNumber = Number.parseInt(args.start || '1', 10);
|
|
84
|
+
const customX = args.x ? Number.parseInt(args.x, 10) : undefined;
|
|
85
|
+
const customY = args.y ? Number.parseInt(args.y, 10) : undefined;
|
|
86
|
+
|
|
87
|
+
await withSpinner(
|
|
88
|
+
'Adding page numbers...',
|
|
89
|
+
async () => {
|
|
90
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
91
|
+
const pdf = await PDFDocument.load(pdfBytes);
|
|
92
|
+
const font = await pdf.embedFont(StandardFonts.Helvetica);
|
|
93
|
+
const pages = pdf.getPages();
|
|
94
|
+
const totalPages = pages.length;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < pages.length; i++) {
|
|
97
|
+
const page = pages[i];
|
|
98
|
+
const { width, height } = page.getSize();
|
|
99
|
+
const pageNum = startNumber + i;
|
|
100
|
+
|
|
101
|
+
const text = format
|
|
102
|
+
.replace('{n}', String(pageNum))
|
|
103
|
+
.replace('{total}', String(totalPages + startNumber - 1));
|
|
104
|
+
|
|
105
|
+
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
|
106
|
+
|
|
107
|
+
let x: number;
|
|
108
|
+
let y: number;
|
|
109
|
+
|
|
110
|
+
if (customX !== undefined && customY !== undefined) {
|
|
111
|
+
x = customX;
|
|
112
|
+
y = customY;
|
|
113
|
+
} else {
|
|
114
|
+
switch (align) {
|
|
115
|
+
case 'left':
|
|
116
|
+
x = 50;
|
|
117
|
+
break;
|
|
118
|
+
case 'right':
|
|
119
|
+
x = width - textWidth - 50;
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
x = (width - textWidth) / 2;
|
|
123
|
+
}
|
|
124
|
+
y = position === 'top' ? height - 30 : 30;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
page.drawText(text, {
|
|
128
|
+
x,
|
|
129
|
+
y,
|
|
130
|
+
size: fontSize,
|
|
131
|
+
font,
|
|
132
|
+
color: rgb(0, 0, 0),
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const numberedBytes = await pdf.save();
|
|
137
|
+
await Bun.write(outputPath, numberedBytes);
|
|
138
|
+
},
|
|
139
|
+
'Page numbers added successfully'
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
fileResult(inputPath, outputPath, {
|
|
143
|
+
before: inputSize,
|
|
144
|
+
after: getFileSize(outputPath),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
success('Added page numbers to PDF');
|
|
148
|
+
} catch (err) {
|
|
149
|
+
handleError(err);
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
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, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
export const reverse = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'reverse',
|
|
12
|
+
description: 'Reverse the order of all pages in a PDF',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
input: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Input PDF file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
output: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
alias: 'o',
|
|
23
|
+
description: 'Output file path',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
async run({ args }) {
|
|
27
|
+
try {
|
|
28
|
+
const input = requireArg(args.input, 'input');
|
|
29
|
+
|
|
30
|
+
const inputPath = resolvePath(input as string);
|
|
31
|
+
if (!existsSync(inputPath)) {
|
|
32
|
+
throw new FileNotFoundError(input as string);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-reversed');
|
|
36
|
+
ensureDir(outputPath);
|
|
37
|
+
|
|
38
|
+
const inputSize = getFileSize(inputPath);
|
|
39
|
+
|
|
40
|
+
await withSpinner(
|
|
41
|
+
'Reversing page order...',
|
|
42
|
+
async () => {
|
|
43
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
44
|
+
const sourcePdf = await PDFDocument.load(pdfBytes);
|
|
45
|
+
const newPdf = await PDFDocument.create();
|
|
46
|
+
|
|
47
|
+
const totalPages = sourcePdf.getPageCount();
|
|
48
|
+
const reversedOrder = Array.from({ length: totalPages }, (_, i) => totalPages - 1 - i);
|
|
49
|
+
|
|
50
|
+
const copiedPages = await newPdf.copyPages(sourcePdf, reversedOrder);
|
|
51
|
+
for (const page of copiedPages) {
|
|
52
|
+
newPdf.addPage(page);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const reversedBytes = await newPdf.save();
|
|
56
|
+
await Bun.write(outputPath, reversedBytes);
|
|
57
|
+
},
|
|
58
|
+
'Page order reversed successfully'
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
fileResult(inputPath, outputPath, {
|
|
62
|
+
before: inputSize,
|
|
63
|
+
after: getFileSize(outputPath),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
success('PDF page order reversed');
|
|
67
|
+
} catch (err) {
|
|
68
|
+
handleError(err);
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { defineCommand } from 'citty';
|
|
3
|
+
import { PDFDocument, degrees } from 'pdf-lib';
|
|
4
|
+
import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
|
|
5
|
+
import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
|
|
6
|
+
import { fileResult, success } from '../../utils/logger';
|
|
7
|
+
import { withSpinner } from '../../utils/progress';
|
|
8
|
+
|
|
9
|
+
function parsePages(pagesStr: string, totalPages: number): number[] {
|
|
10
|
+
if (!pagesStr) {
|
|
11
|
+
return Array.from({ length: totalPages }, (_, i) => i);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const pages: Set<number> = new Set();
|
|
15
|
+
const parts = pagesStr.split(',').map((s) => s.trim());
|
|
16
|
+
|
|
17
|
+
for (const part of parts) {
|
|
18
|
+
if (part.includes('-')) {
|
|
19
|
+
const [start, end] = part.split('-').map((s) => Number.parseInt(s.trim(), 10));
|
|
20
|
+
for (let i = Math.max(1, start); i <= Math.min(totalPages, end); i++) {
|
|
21
|
+
pages.add(i - 1);
|
|
22
|
+
}
|
|
23
|
+
} else {
|
|
24
|
+
const page = Number.parseInt(part, 10);
|
|
25
|
+
if (page >= 1 && page <= totalPages) {
|
|
26
|
+
pages.add(page - 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return Array.from(pages);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const rotate = defineCommand({
|
|
35
|
+
meta: {
|
|
36
|
+
name: 'rotate',
|
|
37
|
+
description: 'Rotate pages in a PDF',
|
|
38
|
+
},
|
|
39
|
+
args: {
|
|
40
|
+
input: {
|
|
41
|
+
type: 'positional',
|
|
42
|
+
description: 'Input PDF file',
|
|
43
|
+
required: true,
|
|
44
|
+
},
|
|
45
|
+
output: {
|
|
46
|
+
type: 'string',
|
|
47
|
+
alias: 'o',
|
|
48
|
+
description: 'Output file path',
|
|
49
|
+
},
|
|
50
|
+
angle: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
alias: 'a',
|
|
53
|
+
description: 'Rotation angle: 90, 180, 270, -90',
|
|
54
|
+
required: true,
|
|
55
|
+
},
|
|
56
|
+
pages: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
alias: 'p',
|
|
59
|
+
description: 'Pages to rotate (e.g., "1,3,5" or "1-5"). Default: all pages',
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
async run({ args }) {
|
|
63
|
+
try {
|
|
64
|
+
const input = requireArg(args.input, 'input');
|
|
65
|
+
const angle = requireArg(args.angle, 'angle');
|
|
66
|
+
|
|
67
|
+
const inputPath = resolvePath(input as string);
|
|
68
|
+
if (!existsSync(inputPath)) {
|
|
69
|
+
throw new FileNotFoundError(input as string);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const rotationAngle = Number.parseInt(angle, 10);
|
|
73
|
+
if (![90, 180, 270, -90, -180, -270].includes(rotationAngle)) {
|
|
74
|
+
throw new Error('Rotation angle must be 90, 180, 270, or their negative values');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const outputPath = generateOutputPath(inputPath, args.output, '-rotated');
|
|
78
|
+
ensureDir(outputPath);
|
|
79
|
+
|
|
80
|
+
const inputSize = getFileSize(inputPath);
|
|
81
|
+
|
|
82
|
+
await withSpinner(
|
|
83
|
+
`Rotating pages by ${rotationAngle}°...`,
|
|
84
|
+
async () => {
|
|
85
|
+
const pdfBytes = await Bun.file(inputPath).arrayBuffer();
|
|
86
|
+
const pdf = await PDFDocument.load(pdfBytes);
|
|
87
|
+
const totalPages = pdf.getPageCount();
|
|
88
|
+
|
|
89
|
+
const pagesToRotate = parsePages(args.pages || '', totalPages);
|
|
90
|
+
const allPages = pdf.getPages();
|
|
91
|
+
|
|
92
|
+
for (const pageIndex of pagesToRotate) {
|
|
93
|
+
const page = allPages[pageIndex];
|
|
94
|
+
if (page) {
|
|
95
|
+
const currentRotation = page.getRotation().angle;
|
|
96
|
+
page.setRotation(degrees(currentRotation + rotationAngle));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const rotatedBytes = await pdf.save();
|
|
101
|
+
await Bun.write(outputPath, rotatedBytes);
|
|
102
|
+
},
|
|
103
|
+
'PDF rotated successfully'
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
fileResult(inputPath, outputPath, {
|
|
107
|
+
before: inputSize,
|
|
108
|
+
after: getFileSize(outputPath),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
success(`Rotated PDF pages by ${rotationAngle}°`);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
handleError(err);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
});
|