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.
Files changed (74) hide show
  1. package/.github/workflows/release.yml +73 -0
  2. package/LICENSE +21 -0
  3. package/README.md +118 -0
  4. package/biome.json +34 -0
  5. package/bunfig.toml +7 -0
  6. package/dist/index.js +192 -0
  7. package/install.sh +68 -0
  8. package/package.json +47 -0
  9. package/scripts/inspect-help.ts +15 -0
  10. package/site/index.html +112 -0
  11. package/src/cli.ts +24 -0
  12. package/src/commands/audio/convert.ts +107 -0
  13. package/src/commands/audio/extract.ts +84 -0
  14. package/src/commands/audio/fade.ts +128 -0
  15. package/src/commands/audio/index.ts +35 -0
  16. package/src/commands/audio/merge.ts +109 -0
  17. package/src/commands/audio/normalize.ts +110 -0
  18. package/src/commands/audio/reverse.ts +64 -0
  19. package/src/commands/audio/speed.ts +101 -0
  20. package/src/commands/audio/trim.ts +98 -0
  21. package/src/commands/audio/volume.ts +91 -0
  22. package/src/commands/audio/waveform.ts +117 -0
  23. package/src/commands/doctor.ts +125 -0
  24. package/src/commands/image/adjust.ts +129 -0
  25. package/src/commands/image/border.ts +94 -0
  26. package/src/commands/image/bulk-compress.ts +111 -0
  27. package/src/commands/image/bulk-convert.ts +114 -0
  28. package/src/commands/image/bulk-resize.ts +112 -0
  29. package/src/commands/image/compress.ts +95 -0
  30. package/src/commands/image/convert.ts +116 -0
  31. package/src/commands/image/crop.ts +96 -0
  32. package/src/commands/image/favicon.ts +89 -0
  33. package/src/commands/image/filters.ts +108 -0
  34. package/src/commands/image/index.ts +49 -0
  35. package/src/commands/image/resize.ts +110 -0
  36. package/src/commands/image/rotate.ts +90 -0
  37. package/src/commands/image/strip-metadata.ts +60 -0
  38. package/src/commands/image/to-base64.ts +72 -0
  39. package/src/commands/image/watermark.ts +141 -0
  40. package/src/commands/pdf/compress.ts +157 -0
  41. package/src/commands/pdf/decrypt.ts +102 -0
  42. package/src/commands/pdf/delete-pages.ts +112 -0
  43. package/src/commands/pdf/duplicate.ts +119 -0
  44. package/src/commands/pdf/encrypt.ts +161 -0
  45. package/src/commands/pdf/from-images.ts +104 -0
  46. package/src/commands/pdf/index.ts +55 -0
  47. package/src/commands/pdf/merge.ts +84 -0
  48. package/src/commands/pdf/ocr.ts +270 -0
  49. package/src/commands/pdf/organize.ts +88 -0
  50. package/src/commands/pdf/page-numbers.ts +152 -0
  51. package/src/commands/pdf/reverse.ts +71 -0
  52. package/src/commands/pdf/rotate.ts +116 -0
  53. package/src/commands/pdf/sanitize.ts +77 -0
  54. package/src/commands/pdf/sign.ts +156 -0
  55. package/src/commands/pdf/split.ts +148 -0
  56. package/src/commands/pdf/to-images.ts +84 -0
  57. package/src/commands/pdf/to-text.ts +51 -0
  58. package/src/commands/pdf/watermark.ts +179 -0
  59. package/src/commands/qr/bulk-generate.ts +136 -0
  60. package/src/commands/qr/generate.ts +128 -0
  61. package/src/commands/qr/index.ts +16 -0
  62. package/src/commands/qr/scan.ts +114 -0
  63. package/src/commands/setup.ts +156 -0
  64. package/src/index.ts +42 -0
  65. package/src/lib/audio/ffmpeg.ts +93 -0
  66. package/src/utils/colors.ts +41 -0
  67. package/src/utils/detect.ts +222 -0
  68. package/src/utils/errors.ts +89 -0
  69. package/src/utils/files.ts +148 -0
  70. package/src/utils/logger.ts +90 -0
  71. package/src/utils/pdf-tools.ts +220 -0
  72. package/src/utils/progress.ts +142 -0
  73. package/src/utils/style.ts +38 -0
  74. package/tsconfig.json +27 -0
@@ -0,0 +1,90 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
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 rotate = defineCommand({
10
+ meta: {
11
+ name: 'rotate',
12
+ description: 'Rotate or flip an image',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input image file',
18
+ required: true,
19
+ },
20
+ output: {
21
+ type: 'string',
22
+ alias: 'o',
23
+ description: 'Output file path',
24
+ },
25
+ angle: {
26
+ type: 'string',
27
+ alias: 'a',
28
+ description: 'Rotation angle (90, 180, 270, or any angle)',
29
+ },
30
+ flip: {
31
+ type: 'string',
32
+ alias: 'f',
33
+ description: 'Flip: horizontal, vertical',
34
+ },
35
+ },
36
+ async run({ args }) {
37
+ try {
38
+ const input = requireArg(args.input, 'input');
39
+
40
+ if (!args.angle && !args.flip) {
41
+ throw new Error('Specify --angle or --flip');
42
+ }
43
+
44
+ const inputPath = resolvePath(input as string);
45
+ if (!existsSync(inputPath)) {
46
+ throw new FileNotFoundError(input as string);
47
+ }
48
+
49
+ const outputPath = generateOutputPath(inputPath, args.output, '-rotated');
50
+ ensureDir(outputPath);
51
+
52
+ const inputSize = getFileSize(inputPath);
53
+
54
+ await withSpinner(
55
+ 'Rotating image...',
56
+ async () => {
57
+ let pipeline = sharp(inputPath);
58
+
59
+ if (args.angle) {
60
+ const angle = Number.parseInt(args.angle, 10);
61
+ pipeline = pipeline.rotate(angle);
62
+ }
63
+
64
+ if (args.flip === 'horizontal') {
65
+ pipeline = pipeline.flop();
66
+ } else if (args.flip === 'vertical') {
67
+ pipeline = pipeline.flip();
68
+ }
69
+
70
+ await pipeline.toFile(outputPath);
71
+ },
72
+ 'Image rotated successfully'
73
+ );
74
+
75
+ const ops: string[] = [];
76
+ if (args.angle) ops.push(`Rotated ${args.angle}°`);
77
+ if (args.flip) ops.push(`Flipped ${args.flip}`);
78
+ info(ops.join(', '));
79
+
80
+ fileResult(inputPath, outputPath, {
81
+ before: inputSize,
82
+ after: getFileSize(outputPath),
83
+ });
84
+
85
+ success('Image rotated');
86
+ } catch (err) {
87
+ handleError(err);
88
+ }
89
+ },
90
+ });
@@ -0,0 +1,60 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
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 stripMetadata = defineCommand({
10
+ meta: {
11
+ name: 'strip-metadata',
12
+ description: 'Remove EXIF and other metadata from an image',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input image 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, '-clean');
36
+ ensureDir(outputPath);
37
+
38
+ const inputSize = getFileSize(inputPath);
39
+
40
+ await withSpinner(
41
+ 'Removing metadata...',
42
+ async () => {
43
+ await sharp(inputPath)
44
+ .withMetadata({}) // Empty metadata object strips EXIF
45
+ .toFile(outputPath);
46
+ },
47
+ 'Metadata removed successfully'
48
+ );
49
+
50
+ fileResult(inputPath, outputPath, {
51
+ before: inputSize,
52
+ after: getFileSize(outputPath),
53
+ });
54
+
55
+ success('Metadata removed from image');
56
+ } catch (err) {
57
+ handleError(err);
58
+ }
59
+ },
60
+ });
@@ -0,0 +1,72 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import sharp from 'sharp';
4
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
5
+ import { resolvePath } from '../../utils/files';
6
+ import { info, success } from '../../utils/logger';
7
+ import { withSpinner } from '../../utils/progress';
8
+
9
+ export const toBase64 = defineCommand({
10
+ meta: {
11
+ name: 'to-base64',
12
+ description: 'Convert an image to a Base64 string',
13
+ },
14
+ args: {
15
+ input: {
16
+ type: 'positional',
17
+ description: 'Input image file',
18
+ required: true,
19
+ },
20
+ dataUri: {
21
+ type: 'boolean',
22
+ alias: 'd',
23
+ description: 'Output as data URI (with prefix)',
24
+ default: false,
25
+ },
26
+ },
27
+ async run({ args }) {
28
+ try {
29
+ const input = requireArg(args.input, 'input');
30
+
31
+ const inputPath = resolvePath(input as string);
32
+ if (!existsSync(inputPath)) {
33
+ throw new FileNotFoundError(input as string);
34
+ }
35
+
36
+ let base64String = '';
37
+ let mimeType = '';
38
+
39
+ await withSpinner(
40
+ 'Converting to Base64...',
41
+ async () => {
42
+ const metadata = await sharp(inputPath).metadata();
43
+ const buffer = await sharp(inputPath).toBuffer();
44
+ base64String = buffer.toString('base64');
45
+
46
+ // Determine MIME type
47
+ const formatMap: Record<string, string> = {
48
+ jpeg: 'image/jpeg',
49
+ png: 'image/png',
50
+ webp: 'image/webp',
51
+ gif: 'image/gif',
52
+ svg: 'image/svg+xml',
53
+ avif: 'image/avif',
54
+ };
55
+ mimeType = formatMap[metadata.format || 'jpeg'] || 'image/jpeg';
56
+ },
57
+ 'Conversion complete'
58
+ );
59
+
60
+ if (args.dataUri) {
61
+ console.log(`data:${mimeType};base64,${base64String}`);
62
+ } else {
63
+ console.log(base64String);
64
+ }
65
+
66
+ info(`Length: ${base64String.length} characters`);
67
+ success('Image converted to Base64');
68
+ } catch (err) {
69
+ handleError(err);
70
+ }
71
+ },
72
+ });
@@ -0,0 +1,141 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { defineCommand } from 'citty';
3
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
4
+ import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
5
+ import { fileResult, success } from '../../utils/logger';
6
+ import { withSpinner } from '../../utils/progress';
7
+
8
+ type Position = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
9
+
10
+ function getGravity(position: Position): string {
11
+ const map: Record<Position, string> = {
12
+ center: 'center',
13
+ 'top-left': 'northwest',
14
+ 'top-right': 'northeast',
15
+ 'bottom-left': 'southwest',
16
+ 'bottom-right': 'southeast',
17
+ };
18
+ return map[position] || 'southeast';
19
+ }
20
+
21
+ export const watermark = defineCommand({
22
+ meta: {
23
+ name: 'watermark',
24
+ description: 'Add a text watermark to an image',
25
+ },
26
+ args: {
27
+ input: {
28
+ type: 'positional',
29
+ description: 'Input image file',
30
+ required: true,
31
+ },
32
+ output: {
33
+ type: 'string',
34
+ alias: 'o',
35
+ description: 'Output file path',
36
+ },
37
+ text: {
38
+ type: 'string',
39
+ alias: 't',
40
+ description: 'Watermark text',
41
+ required: true,
42
+ },
43
+ position: {
44
+ type: 'string',
45
+ alias: 'p',
46
+ description: 'Position: center, top-left, top-right, bottom-left, bottom-right',
47
+ default: 'bottom-right',
48
+ },
49
+ opacity: {
50
+ type: 'string',
51
+ description: 'Opacity (0.0-1.0)',
52
+ default: '0.5',
53
+ },
54
+ size: {
55
+ type: 'string',
56
+ alias: 's',
57
+ description: 'Font size',
58
+ default: '24',
59
+ },
60
+ color: {
61
+ type: 'string',
62
+ alias: 'c',
63
+ description: 'Text color (hex)',
64
+ default: '#ffffff',
65
+ },
66
+ },
67
+ async run({ args }) {
68
+ try {
69
+ const input = requireArg(args.input, 'input');
70
+ const text = requireArg(args.text, 'text');
71
+
72
+ const inputPath = resolvePath(input as string);
73
+ if (!existsSync(inputPath)) {
74
+ throw new FileNotFoundError(input as string);
75
+ }
76
+
77
+ const outputPath = generateOutputPath(inputPath, args.output, '-watermarked');
78
+ ensureDir(outputPath);
79
+
80
+ const inputSize = getFileSize(inputPath);
81
+ const position = (args.position || 'bottom-right') as Position;
82
+ const opacity = Number.parseFloat(args.opacity || '0.5');
83
+ const fontSize = Number.parseInt(args.size || '24', 10);
84
+ const color = args.color || '#ffffff';
85
+
86
+ // Lazy load sharp
87
+ const { default: sharp } = await import('sharp');
88
+
89
+ await withSpinner(
90
+ 'Adding watermark...',
91
+ async () => {
92
+ // Get image dimensions
93
+ const metadata = await sharp(inputPath).metadata();
94
+ const width = metadata.width || 800;
95
+ const height = metadata.height || 600;
96
+
97
+ // Create SVG text overlay
98
+ const svg = `
99
+ <svg width="${width}" height="${height}">
100
+ <style>
101
+ .watermark {
102
+ fill: ${color};
103
+ fill-opacity: ${opacity};
104
+ font-size: ${fontSize}px;
105
+ font-family: sans-serif;
106
+ font-weight: bold;
107
+ }
108
+ </style>
109
+ <text
110
+ x="${position.includes('right') ? width - 20 : position.includes('left') ? 20 : width / 2}"
111
+ y="${position.includes('top') ? fontSize + 20 : position.includes('bottom') ? height - 20 : height / 2}"
112
+ text-anchor="${position.includes('right') ? 'end' : position.includes('left') ? 'start' : 'middle'}"
113
+ class="watermark"
114
+ >${text}</text>
115
+ </svg>
116
+ `;
117
+
118
+ await sharp(inputPath)
119
+ .composite([
120
+ {
121
+ input: Buffer.from(svg),
122
+ // biome-ignore lint/suspicious/noExplicitAny: sharp types mismatch with lazy load
123
+ gravity: getGravity(position) as any,
124
+ },
125
+ ])
126
+ .toFile(outputPath);
127
+ },
128
+ 'Watermark added successfully'
129
+ );
130
+
131
+ fileResult(inputPath, outputPath, {
132
+ before: inputSize,
133
+ after: getFileSize(outputPath),
134
+ });
135
+
136
+ success('Watermark added to image');
137
+ } catch (err) {
138
+ handleError(err);
139
+ }
140
+ },
141
+ });
@@ -0,0 +1,157 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
3
+ import { defineCommand } from 'citty';
4
+ import { PDFDocument } from 'pdf-lib';
5
+ import { detectGhostscript, getBestPdfCompressor, getInstallCommand } from '../../utils/detect';
6
+ import { FileNotFoundError, handleError, requireArg } from '../../utils/errors';
7
+ import { ensureDir, generateOutputPath, getFileSize, resolvePath } from '../../utils/files';
8
+ import { fileResult, info, success, warn } from '../../utils/logger';
9
+ import { withSpinner } from '../../utils/progress';
10
+ import { c } from '../../utils/style';
11
+
12
+ type CompressionQuality = 'low' | 'medium' | 'high';
13
+
14
+ const GS_SETTINGS: Record<CompressionQuality, string> = {
15
+ low: '/screen', // 72 dpi - smallest file
16
+ medium: '/ebook', // 150 dpi - good balance
17
+ high: '/printer', // 300 dpi - high quality
18
+ };
19
+
20
+ async function compressWithGhostscript(
21
+ input: string,
22
+ output: string,
23
+ quality: CompressionQuality
24
+ ): Promise<void> {
25
+ const gs = await detectGhostscript();
26
+ if (!gs.available || !gs.path) {
27
+ throw new Error('Ghostscript not found');
28
+ }
29
+
30
+ const gsPath = gs.path;
31
+
32
+ return new Promise((resolve, reject) => {
33
+ const gsProcess = spawn(gsPath, [
34
+ '-sDEVICE=pdfwrite',
35
+ '-dCompatibilityLevel=1.4',
36
+ `-dPDFSETTINGS=${GS_SETTINGS[quality]}`,
37
+ '-dNOPAUSE',
38
+ '-dQUIET',
39
+ '-dBATCH',
40
+ '-dDetectDuplicateImages=true',
41
+ '-dCompressFonts=true',
42
+ '-dSubsetFonts=true',
43
+ `-sOutputFile=${output}`,
44
+ input,
45
+ ]);
46
+
47
+ gsProcess.on('close', (code) => {
48
+ if (code === 0) {
49
+ resolve();
50
+ } else {
51
+ reject(new Error(`Ghostscript exited with code ${code}`));
52
+ }
53
+ });
54
+
55
+ gsProcess.on('error', reject);
56
+ });
57
+ }
58
+
59
+ async function compressWithPdfLib(input: string, output: string): Promise<void> {
60
+ const pdfBytes = await Bun.file(input).arrayBuffer();
61
+ const pdf = await PDFDocument.load(pdfBytes);
62
+
63
+ // Remove metadata to reduce size
64
+ pdf.setTitle('');
65
+ pdf.setAuthor('');
66
+ pdf.setSubject('');
67
+ pdf.setKeywords([]);
68
+ pdf.setProducer('');
69
+ pdf.setCreator('');
70
+
71
+ // Save with optimization
72
+ const compressedBytes = await pdf.save({
73
+ useObjectStreams: true,
74
+ addDefaultPage: false,
75
+ });
76
+
77
+ await Bun.write(output, compressedBytes);
78
+ }
79
+
80
+ export const compress = defineCommand({
81
+ meta: {
82
+ name: 'compress',
83
+ description: 'Compress a PDF file to reduce size',
84
+ },
85
+ args: {
86
+ input: {
87
+ type: 'positional',
88
+ description: 'Input PDF file',
89
+ required: true,
90
+ },
91
+ output: {
92
+ type: 'string',
93
+ alias: 'o',
94
+ description: 'Output file path',
95
+ },
96
+ quality: {
97
+ type: 'string',
98
+ alias: 'q',
99
+ description: 'Compression quality: low, medium, high',
100
+ default: 'medium',
101
+ },
102
+ },
103
+ async run({ args }) {
104
+ try {
105
+ const input = requireArg(args.input, 'input');
106
+ const inputPath = resolvePath(input as string);
107
+
108
+ if (!existsSync(inputPath)) {
109
+ throw new FileNotFoundError(input as string);
110
+ }
111
+
112
+ const outputPath = generateOutputPath(inputPath, args.output, '-compressed');
113
+ ensureDir(outputPath);
114
+
115
+ const inputSize = getFileSize(inputPath);
116
+ const quality = (args.quality || 'medium') as CompressionQuality;
117
+
118
+ // Determine best compression method
119
+ const method = await getBestPdfCompressor();
120
+
121
+ await withSpinner(
122
+ `Compressing PDF (${quality} quality)...`,
123
+ async () => {
124
+ if (method === 'ghostscript') {
125
+ info('Using Ghostscript for optimal compression');
126
+ await compressWithGhostscript(inputPath, outputPath, quality);
127
+ } else {
128
+ warn('Using built-in compression (less effective)');
129
+ const installCmd = getInstallCommand('gs');
130
+ if (installCmd) {
131
+ console.log(
132
+ ` ${c.dim('Hint:')} Install Ghostscript for better compression: ${c.active(installCmd)}`
133
+ );
134
+ }
135
+ await compressWithPdfLib(inputPath, outputPath);
136
+ }
137
+ },
138
+ 'PDF compressed successfully'
139
+ );
140
+
141
+ const outputSize = getFileSize(outputPath);
142
+ fileResult(inputPath, outputPath, {
143
+ before: inputSize,
144
+ after: outputSize,
145
+ });
146
+
147
+ if (outputSize >= inputSize) {
148
+ warn('Compressed file is not smaller than original. The PDF may already be optimized.');
149
+ } else {
150
+ const savings = ((1 - outputSize / inputSize) * 100).toFixed(1);
151
+ success(`Reduced file size by ${savings}%`);
152
+ }
153
+ } catch (err) {
154
+ handleError(err);
155
+ }
156
+ },
157
+ });
@@ -0,0 +1,102 @@
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 decrypt = defineCommand({
11
+ meta: {
12
+ name: 'decrypt',
13
+ description: 'Remove password protection from 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: 'Password to unlock the PDF',
30
+ required: true,
31
+ },
32
+ },
33
+ async run({ args }) {
34
+ try {
35
+ const input = requireArg(args.input, 'input');
36
+ const password = requireArg(args.password, 'password');
37
+
38
+ const inputPath = resolvePath(input as string);
39
+ if (!existsSync(inputPath)) {
40
+ throw new FileNotFoundError(input as string);
41
+ }
42
+
43
+ // Check for qpdf
44
+ const qpdf = await detectQpdf();
45
+ if (!qpdf.available || !qpdf.path) {
46
+ warn('qpdf is required for PDF decryption');
47
+ const installCmd = getInstallCommand('qpdf');
48
+ if (installCmd) {
49
+ info(`Install: ${installCmd}`);
50
+ }
51
+ return;
52
+ }
53
+
54
+ const outputPath = generateOutputPath(inputPath, args.output, '-decrypted');
55
+ ensureDir(outputPath);
56
+
57
+ const inputSize = getFileSize(inputPath);
58
+ const qpdfPath = qpdf.path;
59
+
60
+ // Build qpdf arguments for decryption
61
+ const qpdfArgs = ['--decrypt', `--password=${password}`, inputPath, outputPath];
62
+
63
+ await withSpinner(
64
+ 'Decrypting PDF with qpdf...',
65
+ async () => {
66
+ return new Promise<void>((resolve, reject) => {
67
+ const proc = spawn(qpdfPath, qpdfArgs, {
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ });
70
+
71
+ let stderr = '';
72
+ proc.stderr?.on('data', (data) => {
73
+ stderr += data.toString();
74
+ });
75
+
76
+ proc.on('close', (code) => {
77
+ if (code === 0) {
78
+ resolve();
79
+ } else if (code === 2 && stderr.includes('password')) {
80
+ reject(new Error('Incorrect password'));
81
+ } else {
82
+ reject(new Error(`qpdf failed: ${stderr}`));
83
+ }
84
+ });
85
+
86
+ proc.on('error', reject);
87
+ });
88
+ },
89
+ 'PDF decrypted successfully'
90
+ );
91
+
92
+ fileResult(inputPath, outputPath, {
93
+ before: inputSize,
94
+ after: getFileSize(outputPath),
95
+ });
96
+
97
+ success('PDF password protection removed');
98
+ } catch (err) {
99
+ handleError(err);
100
+ }
101
+ },
102
+ });