diff --git a/README.md b/README.md index 7c476e8..b08e1f0 100644 --- a/README.md +++ b/README.md @@ -60,11 +60,40 @@ The documentation for Nutrient DWS TypeScript Client is also available on [Conte ## Quick Start ```typescript +import { NutrientClient } from '@nutrient-sdk/dws-client-typescript'; + const client = new NutrientClient({ apiKey: 'nutr_sk_your_secret_key' }); ``` +### Working with URLs + +Most methods accept URLs directly. The URL is passed to the server, which fetches the content—this avoids SSRF vulnerabilities since the client never fetches URLs itself. + +```typescript +// Pass URL as a string +const result = await client.convert('https://example.com/document.pdf', 'docx'); + +// Or as an object (useful for TypeScript type narrowing) +const result = await client.convert({ type: 'url', url: 'https://example.com/document.pdf' }, 'docx'); + +// URLs also work with the workflow builder +const result = await client.workflow() + .addFilePart('https://example.com/document.pdf') + .outputPdf() + .execute(); +``` + +**Exception:** The `sign()` method only accepts local files (file paths, Buffers, streams) because the underlying API endpoint doesn't support URL inputs. For signing remote files, fetch the content first: + +```typescript +// Fetch and pass the bytes for signing +const response = await fetch('https://example.com/document.pdf'); +const buffer = Buffer.from(await response.arrayBuffer()); +const result = await client.sign(buffer, { /* signature options */ }); +``` + ## Direct Methods The client provides numerous methods for document processing: diff --git a/package-lock.json b/package-lock.json index 6a562f0..2e27e6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -76,7 +76,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -2405,7 +2404,6 @@ "integrity": "sha512-+054pVMzVTmRQV8BhpGv3UyfZ2Llgl8rdpDTon+cUH9+na0ncBVXj3wTUKh14+Kiz18ziM3b4ikpP5/Pc0rQEQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2479,7 +2477,6 @@ "integrity": "sha512-npiaib8XzbjtzS2N4HlqPvlpxpmZ14FjSJrteZpPxGUaYPlvhzlzUZ4mZyABo0EFrOWnvyd0Xxroq//hKhtAWg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.0", "@typescript-eslint/types": "8.53.0", @@ -2986,7 +2983,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3311,7 +3307,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3909,7 +3904,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -3974,7 +3968,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4974,7 +4967,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7055,7 +7047,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7185,7 +7176,6 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7311,7 +7301,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -7368,7 +7357,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index abc7e83..e8fa9a5 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -18,7 +18,6 @@ import { samplePNG, TestDocumentGenerator, } from './helpers'; -import { getPdfPageCount, processFileInput } from '../inputs'; // Skip integration tests in CI/automated environments unless explicitly enabled with valid API key const shouldRunIntegrationTests = Boolean(process.env['NUTRIENT_API_KEY']); @@ -236,13 +235,11 @@ describeIntegration('Integration Tests with Live API - Direct Methods', () => { describe('merge()', () => { it('should merge multiple PDF files', async () => { const result = await client.merge([samplePDF, samplePDF, samplePDF]); - const normalizedPdf = await processFileInput(samplePDF); - const pageCount = await getPdfPageCount(normalizedPdf); expect(result).toBeDefined(); expect(result.buffer).toBeInstanceOf(Uint8Array); expect(result.mimeType).toBe('application/pdf'); - const normalizedResult = await processFileInput(result.buffer); - await expect(getPdfPageCount(normalizedResult)).resolves.toBe(pageCount * 3); + // Merged PDF should be larger than original + expect(result.buffer.length).toBeGreaterThan(samplePDF.length); }, 60000); }); diff --git a/src/__tests__/unit/client.test.ts b/src/__tests__/unit/client.test.ts index b5601f1..7773062 100644 --- a/src/__tests__/unit/client.test.ts +++ b/src/__tests__/unit/client.test.ts @@ -114,12 +114,6 @@ interface MockWorkflowWithOutputStage; -const mockIsValidPdf = inputsModule.isValidPdf as jest.MockedFunction< - typeof inputsModule.isValidPdf ->; -const mockGetPdfPageCount = inputsModule.getPdfPageCount as jest.MockedFunction< - typeof inputsModule.getPdfPageCount ->; const mockSendRequest = httpModule.sendRequest as jest.MockedFunction< typeof httpModule.sendRequest >; @@ -173,8 +167,6 @@ describe('NutrientClient', () => { beforeEach(() => { jest.clearAllMocks(); mockValidateFileInput.mockReturnValue(true); - mockIsValidPdf.mockResolvedValue(true); - mockGetPdfPageCount.mockResolvedValue(10); mockSendRequest.mockResolvedValue({ data: TestDocumentGenerator.generateSimplePdf() as never, status: 200, @@ -239,6 +231,7 @@ describe('NutrientClient', () => { }), ).toThrow('Base URL must be a string'); }); + }); describe('workflow()', () => { @@ -1056,8 +1049,7 @@ describe('NutrientClient', () => { await client.addPage(file, count, index); - // Mock getPdfPageCount to test index logic - // This is a simplified test since we can't easily mock the internal implementation + // Verify the workflow was called with the correct page ranges expect(mockWorkflowInstance.addFilePart).toHaveBeenCalledWith(file, { pages: { end: 1, start: 0 }, }); @@ -1169,18 +1161,19 @@ describe('NutrientClient', () => { it('should delete specific pages from a document', async () => { const file = 'test-file.pdf'; - const pageIndices = [3, 1, 1]; + const pageIndices = [3, 1, 1]; // Delete pages 1 and 3 (duplicates removed) await client.deletePages(file, pageIndices); + // Should keep: [0-0], [2-2], [4 to end] expect(mockWorkflowInstance.addFilePart).toHaveBeenCalledWith(file, { - pages: { end: 0, start: 0 }, + pages: { start: 0, end: 0 }, }); expect(mockWorkflowInstance.addFilePart).toHaveBeenCalledWith(file, { - pages: { end: 2, start: 2 }, + pages: { start: 2, end: 2 }, }); expect(mockWorkflowInstance.addFilePart).toHaveBeenCalledWith(file, { - pages: { end: 9, start: 4 }, + pages: { start: 4, end: -1 }, // -1 means "to end of document" }); expect(mockWorkflowInstance.addFilePart).toHaveBeenCalledTimes(3); expect(mockWorkflowInstance.outputPdf).toHaveBeenCalled(); diff --git a/src/__tests__/unit/inputs.test.ts b/src/__tests__/unit/inputs.test.ts index 32fc934..4edd5dc 100644 --- a/src/__tests__/unit/inputs.test.ts +++ b/src/__tests__/unit/inputs.test.ts @@ -1,16 +1,8 @@ -import { - getPdfPageCount, - isRemoteFileInput, - isValidPdf, - processFileInput, - processRemoteFileInput, - validateFileInput, -} from '../../inputs'; +import { getRemoteUrl, isRemoteFileInput, processFileInput, validateFileInput } from '../../inputs'; import { ValidationError } from '../../errors'; import { Readable } from 'stream'; import fs from 'fs'; import type { FileInput } from '../../types'; -import { samplePDF, TestDocumentGenerator } from '../helpers'; // Mock fetch for URL tests global.fetch = jest.fn(); @@ -197,6 +189,42 @@ describe('Input Processing (Node.js only)', () => { }); }); + describe('getRemoteUrl', () => { + it('should extract URL from URL string', () => { + const url = 'https://example.com/test.pdf'; + expect(getRemoteUrl(url)).toBe(url); + }); + + it('should return null for file path string', () => { + expect(getRemoteUrl('test.pdf')).toBeNull(); + }); + + it('should extract URL from UrlInput object', () => { + const urlInput = { type: 'url' as const, url: 'https://example.com/test.pdf' }; + expect(getRemoteUrl(urlInput)).toBe('https://example.com/test.pdf'); + }); + + it('should return null for Buffer', () => { + expect(getRemoteUrl(Buffer.from('test'))).toBeNull(); + }); + + it('should return null for Uint8Array', () => { + expect(getRemoteUrl(new Uint8Array([1, 2, 3]))).toBeNull(); + }); + + it('should return null for FilePathInput', () => { + expect(getRemoteUrl({ type: 'file-path', path: 'test.pdf' })).toBeNull(); + }); + + it('should return null for BufferInput', () => { + expect(getRemoteUrl({ type: 'buffer', buffer: Buffer.from('test'), filename: 'test.pdf' })).toBeNull(); + }); + + it('should return null for Uint8ArrayInput', () => { + expect(getRemoteUrl({ type: 'uint8array', data: new Uint8Array([1, 2, 3]), filename: 'test.bin' })).toBeNull(); + }); + }); + describe('processFileInput - Invalid inputs', () => { it('should throw for URL', async () => { await expect(processFileInput('https://example.com/test.pdf')).rejects.toThrow( @@ -229,336 +257,4 @@ describe('Input Processing (Node.js only)', () => { ); }); }); - - describe('processRemoteFileInput', () => { - beforeEach(() => { - (fetch as jest.Mock).mockClear(); - }); - - it('should process URL string input', async () => { - const mockResponse = { - ok: true, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - }; - (fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await processRemoteFileInput('https://example.com/test.pdf'); - - expect(fetch).toHaveBeenCalledWith('https://example.com/test.pdf'); - expect(mockResponse.arrayBuffer).toHaveBeenCalled(); - expect(result).toEqual({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data: expect.any(Buffer), - filename: 'buffer', - }); - }); - - it('should process URL object input', async () => { - const mockResponse = { - ok: true, - arrayBuffer: jest.fn().mockResolvedValue(new ArrayBuffer(10)), - }; - (fetch as jest.Mock).mockResolvedValue(mockResponse); - - const result = await processRemoteFileInput({ - type: 'url', - url: 'https://example.com/test.pdf', - }); - - expect(fetch).toHaveBeenCalledWith('https://example.com/test.pdf'); - expect(mockResponse.arrayBuffer).toHaveBeenCalled(); - expect(result).toEqual({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data: expect.any(Buffer), - filename: 'buffer', - }); - }); - - it('should throw ValidationError for non-OK response', async () => { - const mockResponse = { - ok: false, - status: 404, - statusText: 'Not Found', - }; - (fetch as jest.Mock).mockResolvedValue(mockResponse); - - await expect(processRemoteFileInput('https://example.com/test.pdf')).rejects.toThrow( - ValidationError, - ); - }); - - it('should throw ValidationError when fetch fails', async () => { - (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); - - await expect(processRemoteFileInput('https://example.com/test.pdf')).rejects.toThrow( - ValidationError, - ); - }); - }); - - describe('getPdfPageCount', () => { - const cases = [ - { - name: 'PDF with 1 page', - input: TestDocumentGenerator.generateSimplePdf('Text'), - expected: 1, - }, - { name: 'PDF with 6 pages', input: samplePDF, expected: 6 }, - ]; - - it.each(cases)('should return $expected for $name', async (testCase) => { - // First convert FileInput to NormalizedFileData - const normalizedData = await processFileInput(testCase.input); - await expect(getPdfPageCount(normalizedData)).resolves.toEqual(testCase.expected); - }); - - it('should handle Buffer data', async () => { - const pdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const normalizedData = { - data: pdfBuffer, - filename: 'test.pdf', - }; - await expect(getPdfPageCount(normalizedData)).resolves.toEqual(1); - }); - - it('should handle Uint8Array data', async () => { - const pdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const uint8Array = new Uint8Array(pdfBuffer); - const normalizedData = { - data: uint8Array, - filename: 'test.pdf', - }; - await expect(getPdfPageCount(normalizedData)).resolves.toEqual(1); - }); - - it('should handle ReadableStream data', async () => { - const pdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const mockStream = new Readable(); - mockStream.push(pdfBuffer); - mockStream.push(null); // End the stream - - const normalizedData = { - data: mockStream, - filename: 'test.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).resolves.toEqual(1); - }); - - it('should throw for invalid PDF data type', async () => { - const normalizedData = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment - data: 'not a valid data type' as any, - filename: 'test.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when ReadableStream errors', async () => { - const mockStream = new Readable({ - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - read() { - this.emit('error', new Error('Stream error')); - }, - }); - - const normalizedData = { - data: mockStream, - filename: 'test.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when PDF has no objects', async () => { - // Create a PDF-like buffer without any objects - const invalidPdf = Buffer.from('%PDF-1.4\n%%EOF'); - const normalizedData = { - data: invalidPdf, - filename: 'invalid.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when PDF has no catalog object', async () => { - // Create a PDF-like buffer with objects but no catalog - const invalidPdf = Buffer.from('%PDF-1.4\n1 0 obj\n<< /Type /NotCatalog >>\nendobj\n%%EOF'); - const normalizedData = { - data: invalidPdf, - filename: 'invalid.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when PDF catalog has no Pages reference', async () => { - // Create a PDF-like buffer with catalog but no Pages reference - const invalidPdf = Buffer.from('%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF'); - const normalizedData = { - data: invalidPdf, - filename: 'invalid.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when Pages object is not found', async () => { - // Create a PDF-like buffer with catalog and Pages reference but no Pages object - const invalidPdf = Buffer.from( - '%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n%%EOF', - ); - const normalizedData = { - data: invalidPdf, - filename: 'invalid.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - - it('should throw when Pages object has no Count', async () => { - // Create a PDF-like buffer with Pages object but no Count - const invalidPdf = Buffer.from( - '%PDF-1.4\n1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n2 0 obj\n<< /Type /Pages >>\nendobj\n%%EOF', - ); - const normalizedData = { - data: invalidPdf, - filename: 'invalid.pdf', - }; - - await expect(getPdfPageCount(normalizedData)).rejects.toThrow(ValidationError); - }); - }); - - describe('isValidPdf', () => { - it('should return true for valid PDF files', async () => { - // Test with generated PDF - const validPdf = TestDocumentGenerator.generateSimplePdf('Test content'); - const normalizedValidPdf = await processFileInput(validPdf); - await expect(isValidPdf(normalizedValidPdf)).resolves.toBe(true); - - // Test with sample PDF - const normalizedSamplePdf = await processFileInput(samplePDF); - await expect(isValidPdf(normalizedSamplePdf)).resolves.toBe(true); - }); - - it('should return false for non-PDF files', async () => { - // Test with non-PDF buffer - const nonPdfBuffer = Buffer.from('This is not a PDF file'); - const normalizedNonPdfBuffer = await processFileInput(nonPdfBuffer); - await expect(isValidPdf(normalizedNonPdfBuffer)).resolves.toBe(false); - - // Test with non-PDF Uint8Array - const nonPdfUint8Array = new TextEncoder().encode('This is not a PDF file'); - const normalizedNonPdfUint8Array = await processFileInput(nonPdfUint8Array); - await expect(isValidPdf(normalizedNonPdfUint8Array)).resolves.toBe(false); - }); - - it('should handle invalid inputs gracefully', async () => { - // For this test, we'll create normalized data directly since we're testing error cases - // that would fail during processFileInput - - // Create a mock ReadableStream that throws an error when read - const errorStream = new Readable({ - // eslint-disable-next-line @typescript-eslint/explicit-function-return-type - read() { - this.emit('error', new Error('Read error')); - }, - }); - - const normalizedErrorData = { - data: errorStream, - filename: 'error.pdf', - }; - - await expect(isValidPdf(normalizedErrorData)).resolves.toBe(false); - }); - - it('should handle Buffer data', async () => { - // Valid PDF Buffer - const validPdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const normalizedValidData = { - data: validPdfBuffer, - filename: 'valid.pdf', - }; - await expect(isValidPdf(normalizedValidData)).resolves.toBe(true); - - // Invalid PDF Buffer - const invalidPdfBuffer = Buffer.from('Not a PDF'); - const normalizedInvalidData = { - data: invalidPdfBuffer, - filename: 'invalid.pdf', - }; - await expect(isValidPdf(normalizedInvalidData)).resolves.toBe(false); - }); - - it('should handle Uint8Array data', async () => { - // Valid PDF Uint8Array - const validPdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const validUint8Array = new Uint8Array(validPdfBuffer); - const normalizedValidData = { - data: validUint8Array, - filename: 'valid.pdf', - }; - await expect(isValidPdf(normalizedValidData)).resolves.toBe(true); - - // Invalid PDF Uint8Array - const invalidUint8Array = new TextEncoder().encode('Not a PDF'); - const normalizedInvalidData = { - data: invalidUint8Array, - filename: 'invalid.pdf', - }; - await expect(isValidPdf(normalizedInvalidData)).resolves.toBe(false); - }); - - it('should handle ReadableStream data', async () => { - // Valid PDF ReadableStream - const validPdfBuffer = Buffer.from(TestDocumentGenerator.generateSimplePdf('Text')); - const validStream = new Readable(); - validStream.push(validPdfBuffer); - validStream.push(null); // End the stream - - const normalizedValidData = { - data: validStream, - filename: 'valid.pdf', - }; - await expect(isValidPdf(normalizedValidData)).resolves.toBe(true); - - // Invalid PDF ReadableStream - const invalidStream = new Readable(); - invalidStream.push(Buffer.from('Not a PDF')); - invalidStream.push(null); // End the stream - - const normalizedInvalidData = { - data: invalidStream, - filename: 'invalid.pdf', - }; - await expect(isValidPdf(normalizedInvalidData)).resolves.toBe(false); - }); - - it('should return false for invalid data types', async () => { - const normalizedInvalidData = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-assignment - data: 'not a valid data type' as any, - filename: 'invalid.pdf', - }; - - await expect(isValidPdf(normalizedInvalidData)).resolves.toBe(false); - }); - - it('should handle errors during processing', async () => { - // Mock a general error during processing - jest.spyOn(Buffer.prototype, 'slice').mockImplementationOnce(() => { - throw new Error('Unexpected error'); - }); - - const normalizedData = { - data: Buffer.from('test'), - filename: 'test.pdf', - }; - - await expect(isValidPdf(normalizedData)).resolves.toBe(false); - }); - }); }); diff --git a/src/__tests__/unit/workflow.test.ts b/src/__tests__/unit/workflow.test.ts index 4f30985..a2790fb 100644 --- a/src/__tests__/unit/workflow.test.ts +++ b/src/__tests__/unit/workflow.test.ts @@ -14,6 +14,12 @@ jest.mock('../../http'); const mockValidateFileInput = inputsModule.validateFileInput as jest.MockedFunction< typeof inputsModule.validateFileInput >; +const mockIsRemoteFileInput = inputsModule.isRemoteFileInput as jest.MockedFunction< + typeof inputsModule.isRemoteFileInput +>; +const mockGetRemoteUrl = inputsModule.getRemoteUrl as jest.MockedFunction< + typeof inputsModule.getRemoteUrl +>; const mockSendRequest = httpModule.sendRequest as jest.MockedFunction< typeof httpModule.sendRequest >; @@ -31,6 +37,8 @@ describe('WorkflowBuilder', () => { workflow = new WorkflowBuilder(mockClientOptions); // Default mocks mockValidateFileInput.mockReturnValue(true); + mockIsRemoteFileInput.mockReturnValue(false); + mockGetRemoteUrl.mockReturnValue(null); // Default: not a URL mockSendRequest.mockResolvedValue({ data: new Blob(['mock response'], { type: 'application/pdf' }) as never, status: 200, @@ -102,20 +110,16 @@ describe('WorkflowBuilder', () => { expect(result).toBe(workflow); }); - it('should not call registerAssets when adding a file as a URL string', () => { - // Mock isRemoteFileInput to return true for URL string - jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true); - - // Create a spy on the registerAssets method - const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets'); - + it('should handle URL string input without adding to assets map', () => { const urlString = 'https://example.com/document.pdf'; + // Mock getRemoteUrl to return the URL (indicating it's a remote file) + mockGetRemoteUrl.mockReturnValueOnce(urlString); + const result = workflow.addFilePart(urlString); expect(result).toBe(workflow); - expect(registerAssetsSpy).not.toHaveBeenCalled(); - // Verify the file part was added with the URL + // Verify the file part was added with the URL (not stored in assets) expect( workflow['buildInstructions'].parts[workflow['buildInstructions'].parts.length - 1], ).toEqual( @@ -124,21 +128,18 @@ describe('WorkflowBuilder', () => { }), ); - registerAssetsSpy.mockRestore(); + // Verify no assets were registered (URLs are passed directly) + expect(workflow['assets'].size).toBe(0); }); - it('should not call registerAssets when adding a file as a URL object', () => { - // Mock isRemoteFileInput to return true for URL object - jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true); - - // Create a spy on the registerAssets method - const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets'); - + it('should handle URL object input without adding to assets map', () => { const urlObject: UrlInput = { type: 'url', url: 'https://example.com/document.pdf' }; + // Mock getRemoteUrl to return the URL (indicating it's a remote file) + mockGetRemoteUrl.mockReturnValueOnce(urlObject.url); + const result = workflow.addFilePart(urlObject); expect(result).toBe(workflow); - expect(registerAssetsSpy).not.toHaveBeenCalled(); // Verify the file part was added with the URL expect( @@ -149,7 +150,8 @@ describe('WorkflowBuilder', () => { }), ); - registerAssetsSpy.mockRestore(); + // Verify no assets were registered (URLs are passed directly) + expect(workflow['assets'].size).toBe(0); }); }); @@ -171,18 +173,14 @@ describe('WorkflowBuilder', () => { expect(result).toBe(workflow); }); - it('should not call registerAssets when adding HTML as a URL string', () => { - // Mock isRemoteFileInput to return true for URL string - jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true); - - // Create a spy on the registerAssets method - const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets'); - + it('should handle HTML URL string input without adding to assets map', () => { const urlString = 'https://example.com/page.html'; + // Mock getRemoteUrl to return the URL (indicating it's a remote file) + mockGetRemoteUrl.mockReturnValueOnce(urlString); + const result = workflow.addHtmlPart(urlString); expect(result).toBe(workflow); - expect(registerAssetsSpy).not.toHaveBeenCalled(); // Verify the HTML part was added with the URL expect( @@ -193,21 +191,18 @@ describe('WorkflowBuilder', () => { }), ); - registerAssetsSpy.mockRestore(); + // Verify no assets were registered (URLs are passed directly) + expect(workflow['assets'].size).toBe(0); }); - it('should not call registerAssets when adding HTML as a URL object', () => { - // Mock isRemoteFileInput to return true for URL object - jest.spyOn(inputsModule, 'isRemoteFileInput').mockReturnValueOnce(true); - - // Create a spy on the registerAssets method - const registerAssetsSpy = jest.spyOn(workflow as never, 'registerAssets'); - + it('should handle HTML URL object input without adding to assets map', () => { const urlObject: UrlInput = { type: 'url', url: 'https://example.com/page.html' }; + // Mock getRemoteUrl to return the URL (indicating it's a remote file) + mockGetRemoteUrl.mockReturnValueOnce(urlObject.url); + const result = workflow.addHtmlPart(urlObject); expect(result).toBe(workflow); - expect(registerAssetsSpy).not.toHaveBeenCalled(); // Verify the HTML part was added with the URL expect( @@ -218,7 +213,8 @@ describe('WorkflowBuilder', () => { }), ); - registerAssetsSpy.mockRestore(); + // Verify no assets were registered (URLs are passed directly) + expect(workflow['assets'].size).toBe(0); }); }); @@ -274,6 +270,54 @@ describe('WorkflowBuilder', () => { expect(result).toBe(workflow); expect(mockValidateFileInput).toHaveBeenCalledWith(xfdfFile); }); + + it('should handle URL input for watermarkImage action', () => { + const urlInput: UrlInput = { type: 'url', url: 'https://example.com/watermark.png' }; + const action = BuildActions.watermarkImage(urlInput); + + // Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL) + mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url); + + workflow.addFilePart('test.pdf').applyAction(action); + + // Verify the action's fileInput contains the URL + expect(action.fileInput).toEqual(urlInput); + + // Verify only the test.pdf was registered as local asset (not the watermark URL) + expect(workflow['assets'].size).toBe(1); + }); + + it('should handle URL input for applyInstantJson action', () => { + const urlInput: UrlInput = { type: 'url', url: 'https://example.com/annotations.json' }; + const action = BuildActions.applyInstantJson(urlInput); + + // Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL) + mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url); + + workflow.addFilePart('test.pdf').applyAction(action); + + // Verify the action's fileInput contains the URL + expect(action.fileInput).toEqual(urlInput); + + // Verify only the test.pdf was registered as local asset (not the JSON URL) + expect(workflow['assets'].size).toBe(1); + }); + + it('should handle URL input for applyXfdf action', () => { + const urlInput: UrlInput = { type: 'url', url: 'https://example.com/annotations.xfdf' }; + const action = BuildActions.applyXfdf(urlInput); + + // Mock getRemoteUrl: first call for test.pdf (returns null), second for URL action (returns URL) + mockGetRemoteUrl.mockReturnValueOnce(null).mockReturnValueOnce(urlInput.url); + + workflow.addFilePart('test.pdf').applyAction(action); + + // Verify the action's fileInput contains the URL + expect(action.fileInput).toEqual(urlInput); + + // Verify only the test.pdf was registered as local asset (not the XFDF URL) + expect(workflow['assets'].size).toBe(1); + }); }); describe('output methods', () => { diff --git a/src/build.ts b/src/build.ts index 48e6106..af7f7cf 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,16 +1,17 @@ import type { components } from './generated/api-types'; -import type { FileInput } from './types'; +import type { FileInputWithUrl } from './types'; const DEFAULT_DIMENSION = { value: 100, unit: '%' as const }; /** - * Internal action type that holds FileInput for deferred registration + * Internal action type that holds FileInput for deferred registration. + * Supports both local files and URLs. */ export interface ActionWithFileInput< Action extends components['schemas']['BuildAction'] = components['schemas']['BuildAction'], > { __needsFileRegistration: true; - fileInput: FileInput; + fileInput: FileInputWithUrl; createAction: (fileHandle: components['schemas']['FileHandle']) => Action; } @@ -79,7 +80,7 @@ export const BuildActions = { /** * Create an image watermark action - * @param image - Watermark image + * @param image - Watermark image (local file or URL) * @param options - Watermark options * @param options.width - Width dimension of the watermark (value and unit, e.g. {value: 100, unit: '%'}) * @param options.height - Height dimension of the watermark (value and unit, e.g. {value: 100, unit: '%'}) @@ -91,7 +92,7 @@ export const BuildActions = { * @param options.opacity - Watermark opacity (0 is fully transparent, 1 is fully opaque) */ watermarkImage( - image: FileInput, + image: FileInputWithUrl, options: Partial> = { width: DEFAULT_DIMENSION, height: DEFAULT_DIMENSION, @@ -127,10 +128,10 @@ export const BuildActions = { /** * Create an apply Instant JSON action - * @param file - Instant JSON file input + * @param file - Instant JSON file input (local file or URL) */ applyInstantJson( - file: FileInput, + file: FileInputWithUrl, ): ActionWithFileInput { return { __needsFileRegistration: true, @@ -146,13 +147,13 @@ export const BuildActions = { /** * Create an apply XFDF action - * @param file - XFDF file input + * @param file - XFDF file input (local file or URL) * @param options - Apply Xfdf options * @param options.ignorePageRotation - If true, ignores page rotation when applying XFDF data (default: false) * @param options.richTextEnabled - If true, plain text annotations will be converted to rich text annotations. If false, all text annotations will be plain text annotations (default: true) */ applyXfdf( - file: FileInput, + file: FileInputWithUrl, options?: Partial>, ): ActionWithFileInput { return { diff --git a/src/builders/workflow.ts b/src/builders/workflow.ts index 4f87a7b..86b9324 100644 --- a/src/builders/workflow.ts +++ b/src/builders/workflow.ts @@ -1,8 +1,8 @@ import type { FileInput, + FileInputWithUrl, OutputTypeMap, TypedWorkflowResult, - UrlInput, WorkflowDryRunResult, WorkflowExecuteOptions, WorkflowOutput, @@ -12,7 +12,7 @@ import { BuildOutputs } from '../build'; import { BaseBuilder } from './base'; import { NutrientError, ValidationError } from '../errors'; import type { NormalizedFileData } from '../inputs'; -import { isRemoteFileInput, processFileInput, validateFileInput } from '../inputs'; +import { getRemoteUrl, isRemoteFileInput, processFileInput, validateFileInput } from '../inputs'; import type { components } from '../generated/api-types'; import type { ResponseType } from 'axios'; @@ -31,27 +31,32 @@ export class WorkflowBuilder< parts: [], }; - private assets: Map> = new Map(); + private assets: Map = new Map(); private assetIndex = 0; private currentStep = 0; private isExecuted = false; /** - * Registers an asset in the workflow and returns its key for use in actions - * @param asset - The asset to register - * @returns The asset key that can be used in BuildActions + * Registers an asset in the workflow and returns its file handle for use in actions. + * For URL inputs, returns a URL file handle directly. + * For local files, registers the asset and returns the asset key. + * @param asset - The asset to register (local file or URL) + * @returns The file handle that can be used in BuildActions */ - private registerAssets(asset: Exclude): string { + private registerAssets(asset: FileInputWithUrl): components['schemas']['FileHandle'] { if (!validateFileInput(asset)) { throw new ValidationError('Invalid file input provided to workflow', { asset }); } - if (isRemoteFileInput(asset)) { - throw new ValidationError("Remote file input doesn't need to be registered", { asset }); + // Handle URL inputs - return URL file handle directly + const remoteUrl = getRemoteUrl(asset); + if (remoteUrl !== null) { + return { url: remoteUrl }; } + // Handle local files - register in assets map const assetKey = `asset_${this.assetIndex++}`; - this.assets.set(assetKey, asset); + this.assets.set(assetKey, asset as FileInput); return assetKey; } @@ -60,18 +65,13 @@ export class WorkflowBuilder< * Adds a file part to the workflow */ addFilePart( - file: FileInput, + file: FileInputWithUrl, options?: Omit, actions?: ApplicableAction[], ): this { this.ensureNotExecuted(); - let fileField: components['schemas']['FileHandle']; - if (isRemoteFileInput(file)) { - fileField = { url: typeof file === 'string' ? file : file.url }; - } else { - fileField = this.registerAssets(file); - } + const fileField = this.registerAssets(file); const processedActions = actions ? actions.map((action) => this.processAction(action)) @@ -91,29 +91,25 @@ export class WorkflowBuilder< * Adds an HTML part to the workflow */ addHtmlPart( - html: FileInput, - assets?: Exclude[], + html: FileInputWithUrl, + assets?: FileInput[], options?: Omit, actions?: ApplicableAction[], ): this { this.ensureNotExecuted(); - let htmlField: components['schemas']['FileHandle']; - if (isRemoteFileInput(html)) { - htmlField = { url: typeof html === 'string' ? html : html.url }; - } else { - htmlField = this.registerAssets(html); - } + const htmlField = this.registerAssets(html); let assetsField: string[] | undefined; if (assets) { assetsField = []; for (const asset of assets) { + // Validate upfront: HTML assets must be local files, not URLs if (isRemoteFileInput(asset)) { - throw new ValidationError('Assets file input cannot be an URL', { input: asset }); + throw new ValidationError('HTML assets must be local files, not URLs', { asset }); } - const asset_key = this.registerAssets(asset); - assetsField.push(asset_key); + // Since we validated it's not a URL, registerAssets will return a string key + assetsField.push(this.registerAssets(asset) as string); } } @@ -189,14 +185,7 @@ export class WorkflowBuilder< private processAction(action: ApplicableAction): components['schemas']['BuildAction'] { if (this.isActionWithFileInput(action)) { // Register the file and create the actual action - let fileHandle: components['schemas']['FileHandle']; - if (isRemoteFileInput(action.fileInput)) { - fileHandle = { - url: typeof action.fileInput === 'string' ? action.fileInput : action.fileInput.url, - }; - } else { - fileHandle = this.registerAssets(action.fileInput); - } + const fileHandle = this.registerAssets(action.fileInput); return action.createAction(fileHandle); } return action; diff --git a/src/client.ts b/src/client.ts index 3a9c3d5..815fcc9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,23 +1,19 @@ import type { FileInput, + FileInputWithUrl, NutrientClientOptions, WorkflowInitialStage, TypedWorkflowResult, WorkflowWithPartsStage, OutputTypeMap, WorkflowResult, + UrlInput, } from './types'; import { ValidationError, NutrientError } from './errors'; import { workflow } from './workflow'; import type { components, operations } from './generated/api-types'; import { BuildActions } from './build'; -import { - getPdfPageCount, - isRemoteFileInput, - processFileInput, - processRemoteFileInput, - isValidPdf, -} from './inputs'; +import { processFileInput, isRemoteFileInput } from './inputs'; import { sendRequest } from './http'; import type { NormalizedFileData } from './inputs'; import type { ApplicableAction } from './builders/workflow'; @@ -257,7 +253,11 @@ export class NutrientClient { /** * Signs a PDF document * - * @param pdf - The PDF file to sign + * Note: Unlike most other methods, `sign()` does not accept URLs. This is because + * the underlying `/sign` API endpoint only accepts binary file uploads. To sign a + * remote file, fetch its contents first and pass the buffer. + * + * @param pdf - The PDF file to sign (file path, Buffer, or Uint8Array - not URLs) * @param data - Signature data * @param options - Additional options * @returns Promise resolving to the signed PDF file output @@ -291,29 +291,18 @@ export class NutrientClient { graphicImage?: FileInput; }, ): Promise { - // Normalize the file input - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } + const normalizedFile = await processFileInput(pdf); // Prepare optional files let normalizedImage: NormalizedFileData | undefined; let normalizedGraphicImage: NormalizedFileData | undefined; if (options?.image) { - normalizedImage = isRemoteFileInput(options.image) - ? await processRemoteFileInput(options.image) - : await processFileInput(options.image); + normalizedImage = await processFileInput(options.image); } if (options?.graphicImage) { - normalizedGraphicImage = isRemoteFileInput(options.graphicImage) - ? await processRemoteFileInput(options.graphicImage) - : await processFileInput(options.graphicImage); + normalizedGraphicImage = await processFileInput(options.graphicImage); } const response = await sendRequest( @@ -364,7 +353,7 @@ export class NutrientClient { * ``` */ async watermarkText( - file: FileInput, + file: FileInputWithUrl, text: string, options: Partial> = {}, ): Promise { @@ -404,7 +393,7 @@ export class NutrientClient { * ``` */ async watermarkImage( - file: FileInput, + file: FileInputWithUrl, image: FileInput, options: Partial> = {}, ): Promise { @@ -472,7 +461,7 @@ export class NutrientClient { | 'webp' | 'html' | 'markdown', - >(file: FileInput, targetFormat: T): Promise { + >(file: FileInputWithUrl, targetFormat: T): Promise { const builder = this.workflow().addFilePart(file); let result: TypedWorkflowResult; @@ -552,7 +541,7 @@ export class NutrientClient { * ``` */ async ocr( - file: FileInput, + file: FileInputWithUrl, language: components['schemas']['OcrLanguage'] | components['schemas']['OcrLanguage'][], ): Promise { const ocrAction = BuildActions.ocr(language); @@ -601,7 +590,7 @@ export class NutrientClient { * ``` */ async extractText( - file: FileInput, + file: FileInputWithUrl, pages?: { start?: number; end?: number }, ): Promise { const normalizedPages = pages ? normalizePageParams(pages) : undefined; @@ -669,7 +658,7 @@ export class NutrientClient { * ``` */ async extractTable( - file: FileInput, + file: FileInputWithUrl, pages?: { start?: number; end?: number }, ): Promise { const normalizedPages = pages ? normalizePageParams(pages) : undefined; @@ -730,7 +719,7 @@ export class NutrientClient { * ``` */ async extractKeyValuePairs( - file: FileInput, + file: FileInputWithUrl, pages?: { start?: number; end?: number }, ): Promise { const normalizedPages = pages ? normalizePageParams(pages) : undefined; @@ -770,7 +759,7 @@ export class NutrientClient { * ``` */ async passwordProtect( - file: FileInput, + file: FileInputWithUrl, userPassword: string, ownerPassword: string, permissions?: components['schemas']['PDFUserPermission'][], @@ -813,17 +802,9 @@ export class NutrientClient { * ``` */ async setMetadata( - pdf: FileInput, + pdf: FileInputWithUrl, metadata: components['schemas']['Metadata'], ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const result = await this.workflow().addFilePart(pdf).outputPdf({ metadata }).execute(); return this.processTypedWorkflowResult(result); } @@ -855,17 +836,9 @@ export class NutrientClient { * ``` */ async setPageLabels( - pdf: FileInput, + pdf: FileInputWithUrl, labels: components['schemas']['Label'][], ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const result = await this.workflow().addFilePart(pdf).outputPdf({ labels }).execute(); return this.processTypedWorkflowResult(result); } @@ -894,17 +867,9 @@ export class NutrientClient { * ``` */ async applyInstantJson( - pdf: FileInput, + pdf: FileInputWithUrl, instantJsonFile: FileInput, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const applyJsonAction = BuildActions.applyInstantJson(instantJsonFile); const result = await this.workflow() @@ -944,21 +909,13 @@ export class NutrientClient { * ``` */ async applyXfdf( - pdf: FileInput, + pdf: FileInputWithUrl, xfdfFile: FileInput, options?: { ignorePageRotation?: boolean; richTextEnabled?: boolean; }, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const applyXfdfAction = BuildActions.applyXfdf(xfdfFile, options); const result = await this.workflow() @@ -1023,21 +980,48 @@ export class NutrientClient { * ``` */ async createRedactionsAI( - pdf: FileInput, + pdf: FileInputWithUrl, criteria: string, redaction_state: 'stage' | 'apply' = 'stage', pages?: { start?: number; end?: number }, options?: components['schemas']['RedactData']['options'], ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); + // Build page range for API (supports negative indices natively) + const pageRange = pages ? { start: pages.start ?? 0, end: pages.end ?? -1 } : undefined; + + // Check if input is a URL + if (isRemoteFileInput(pdf)) { + const url = typeof pdf === 'string' ? pdf : (pdf as UrlInput).url; - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); + const response = await sendRequest( + { + method: 'POST', + endpoint: '/ai/redact', + data: { + data: { + documents: [ + { + file: { url }, + pages: pageRange, + }, + ], + criteria, + redaction_state, + options, + }, + }, + }, + this.options, + 'arraybuffer', + ); + + const buffer = new Uint8Array(response.data as unknown as ArrayBuffer); + return { mimeType: 'application/pdf', filename: 'output.pdf', buffer }; } - const pageCount = await getPdfPageCount(normalizedFile); + // Local file input - the cast to FileInput is safe because we've already + // handled the URL case above via isRemoteFileInput() check + const normalizedFile = await processFileInput(pdf as FileInput); const response = await sendRequest( { @@ -1048,7 +1032,7 @@ export class NutrientClient { documents: [ { file: 'file', - pages: pages ? normalizePageParams(pages, pageCount) : undefined, + pages: pageRange, }, ], criteria, @@ -1115,7 +1099,7 @@ export class NutrientClient { * ``` */ async createRedactionsPreset( - pdf: FileInput, + pdf: FileInputWithUrl, preset: components['schemas']['SearchPreset'], redaction_state: 'stage' | 'apply' = 'stage', pages?: { start?: number; end?: number }, @@ -1128,20 +1112,23 @@ export class NutrientClient { 'type' | 'strategyOptions' | 'strategy' >, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); + const start = pages?.start ?? 0; + const end = pages?.end ?? -1; - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); + // Validate: negative end indices other than -1 require page count which we don't have + if (end < -1) { + throw new ValidationError( + 'Negative end indices other than -1 are not supported for redaction page ranges', + { pages }, + ); } - // Get page count for handling negative indices - const pageCount = await getPdfPageCount(normalizedFile); - const normalizedPages = normalizePageParams(pages, pageCount); + + // Calculate limit: if end is -1, omit limit (search to end); otherwise calculate count + const limit = end === -1 ? undefined : end - start + 1; const createRedactionsAction = BuildActions.createRedactionsPreset(preset, options, { - start: normalizedPages.start, - limit: normalizedPages.end >= 0 ? normalizedPages.end - normalizedPages.start + 1 : undefined, + start, + ...(limit !== undefined ? { limit } : {}), ...presetOptions, }); const actions: ApplicableAction[] = [createRedactionsAction]; @@ -1201,7 +1188,7 @@ export class NutrientClient { * ``` */ async createRedactionsRegex( - pdf: FileInput, + pdf: FileInputWithUrl, regex: string, redaction_state: 'stage' | 'apply' = 'stage', pages?: { start?: number; end?: number }, @@ -1214,21 +1201,23 @@ export class NutrientClient { 'type' | 'strategyOptions' | 'strategy' >, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); + const start = pages?.start ?? 0; + const end = pages?.end ?? -1; - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); + // Validate: negative end indices other than -1 require page count which we don't have + if (end < -1) { + throw new ValidationError( + 'Negative end indices other than -1 are not supported for redaction page ranges', + { pages }, + ); } - // Get page count for handling negative indices - const pageCount = await getPdfPageCount(normalizedFile); - const normalizedPages = normalizePageParams(pages, pageCount); + // Calculate limit: if end is -1, omit limit (search to end); otherwise calculate count + const limit = end === -1 ? undefined : end - start + 1; const createRedactionsAction = BuildActions.createRedactionsRegex(regex, options, { - start: normalizedPages.start, - limit: normalizedPages.end >= 0 ? normalizedPages.end - normalizedPages.start + 1 : undefined, + start, + ...(limit !== undefined ? { limit } : {}), ...regrexOptions, }); const actions: ApplicableAction[] = [createRedactionsAction]; @@ -1288,7 +1277,7 @@ export class NutrientClient { * ``` */ async createRedactionsText( - pdf: FileInput, + pdf: FileInputWithUrl, text: string, redaction_state: 'stage' | 'apply' = 'stage', pages?: { start?: number; end?: number }, @@ -1301,20 +1290,23 @@ export class NutrientClient { 'type' | 'strategyOptions' | 'strategy' >, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); + const start = pages?.start ?? 0; + const end = pages?.end ?? -1; - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); + // Validate: negative end indices other than -1 require page count which we don't have + if (end < -1) { + throw new ValidationError( + 'Negative end indices other than -1 are not supported for redaction page ranges', + { pages }, + ); } - // Get page count for handling negative indices - const pageCount = await getPdfPageCount(normalizedFile); - const normalizedPages = normalizePageParams(pages, pageCount); + + // Calculate limit: if end is -1, omit limit (search to end); otherwise calculate count + const limit = end === -1 ? undefined : end - start + 1; const createRedactionsAction = BuildActions.createRedactionsText(text, options, { - start: normalizedPages.start, - limit: normalizedPages.end >= 0 ? normalizedPages.end - normalizedPages.start + 1 : undefined, + start, + ...(limit !== undefined ? { limit } : {}), ...textOptions, }); const actions: ApplicableAction[] = [createRedactionsAction]; @@ -1356,17 +1348,9 @@ export class NutrientClient { * fs.writeFileSync('document-with-applied-redactions.pdf', Buffer.from(result.buffer)); * ``` */ - async applyRedactions(pdf: FileInput): Promise { + async applyRedactions(pdf: FileInputWithUrl): Promise { const applyRedactionsAction = BuildActions.applyRedactions(); - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const result = await this.workflow() .addFilePart(pdf, undefined, [applyRedactionsAction]) .outputPdf() @@ -1402,17 +1386,9 @@ export class NutrientClient { * ``` */ async flatten( - pdf: FileInput, + pdf: FileInputWithUrl, annotationIds?: (string | number)[], ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const flattenAction = BuildActions.flatten(annotationIds); const result = await this.workflow() @@ -1458,38 +1434,28 @@ export class NutrientClient { * ``` */ async rotate( - pdf: FileInput, + pdf: FileInputWithUrl, angle: 90 | 180 | 270, pages?: { start?: number; end?: number }, ): Promise { const rotateAction = BuildActions.rotate(angle); const workflow = this.workflow(); - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - if (pages) { - const pageCount = await getPdfPageCount(normalizedFile); - const normalizedPages = normalizePageParams(pages, pageCount); + const start = pages.start ?? 0; + const end = pages.end ?? -1; // Add pages before the range to rotate - if (normalizedPages.start > 0) { - workflow.addFilePart(pdf, { pages: { start: 0, end: normalizedPages.start - 1 } }); + if (start > 0) { + workflow.addFilePart(pdf, { pages: { start: 0, end: start - 1 } }); } // Add the specific pages with rotation action - workflow.addFilePart(pdf, { pages: normalizedPages }, [rotateAction]); + workflow.addFilePart(pdf, { pages: { start, end } }, [rotateAction]); - // Add pages after the range to rotate - if (normalizedPages.end < pageCount - 1) { - workflow.addFilePart(pdf, { - pages: { start: normalizedPages.end + 1, end: pageCount - 1 }, - }); + // Add pages after the range to rotate (unless rotating to the last page) + if (end !== -1) { + workflow.addFilePart(pdf, { pages: { start: end + 1, end: -1 } }); } } else { // If no pages specified, rotate the entire document @@ -1507,6 +1473,7 @@ export class NutrientClient { * @param pdf - The PDF file to add pages to * @param count - The number of blank pages to add * @param index - Optional index where to add the blank pages (0-based). If not provided, pages are added at the end. + * Must be non-negative. If the index exceeds the document's page count, the server will return an error. * @returns Promise resolving to the document with added pages * * @example @@ -1528,53 +1495,35 @@ export class NutrientClient { * fs.writeFileSync('document-with-added-pages.pdf', Buffer.from(result.buffer)); * ``` */ - async addPage(pdf: FileInput, count: number = 1, index?: number): Promise { + async addPage( + pdf: FileInputWithUrl, + count: number = 1, + index?: number, + ): Promise { let result: WorkflowResult; - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - - // If no index is provided or it's the end of the document, simply add pages at the end + // If no index is provided, simply add pages at the end if (index === undefined) { const builder = this.workflow().addFilePart(pdf); - - // Add the specified number of blank pages builder.addNewPage({ pageCount: count }); - result = await builder.outputPdf().execute(); } else { - // Get the actual page count of the PDF - - const pageCount = await getPdfPageCount(normalizedFile); - - // Validate that the index is within range - if (index < 0 || index > pageCount) { - throw new ValidationError( - `Index ${index} is out of range (document has ${pageCount} pages)`, - ); + if (index < 0) { + throw new ValidationError('Index must be a non-negative number', { index }); } const builder = this.workflow(); // Add pages before the specified index if (index > 0) { - const beforePages = normalizePageParams({ start: 0, end: index - 1 }, pageCount); - builder.addFilePart(pdf, { pages: beforePages }); + builder.addFilePart(pdf, { pages: { start: 0, end: index - 1 } }); } // Add the blank pages builder.addNewPage({ pageCount: count }); - // Add pages after the specified index - if (index < pageCount) { - const afterPages = normalizePageParams({ start: index, end: pageCount - 1 }, pageCount); - builder.addFilePart(pdf, { pages: afterPages }); - } + // Add pages after the specified index (from index to end) + builder.addFilePart(pdf, { pages: { start: index, end: -1 } }); result = await (builder as WorkflowWithPartsStage).outputPdf().execute(); } @@ -1604,7 +1553,7 @@ export class NutrientClient { * fs.writeFileSync('merged-document.pdf', Buffer.from(result.buffer)); * ``` */ - async merge(files: FileInput[]): Promise { + async merge(files: FileInputWithUrl[]): Promise { if (!files || files.length < 2) { throw new ValidationError('At least 2 files are required for merge operation'); } @@ -1651,40 +1600,22 @@ export class NutrientClient { * ``` */ async split( - pdf: FileInput, + pdf: FileInputWithUrl, pageRanges: { start?: number; end?: number }[], ): Promise { if (!pageRanges || pageRanges.length === 0) { throw new ValidationError('At least one page range is required for splitting'); } - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - - // Get the actual page count of the PDF - const pageCount = await getPdfPageCount(normalizedFile); - - // Normalize and validate all page ranges - const normalizedRanges = pageRanges.map((range) => normalizePageParams(range, pageCount)); - - // Validate that all page ranges are within bounds - for (const range of normalizedRanges) { - if (range.start > range.end) { - throw new ValidationError(`Page range ${JSON.stringify(range)} is invalid (start > end)`); - } - } - // Create a separate workflow for each page range + // The API natively supports negative indices (e.g., -1 = last page) const workflows: Promise[] = []; - for (const range of normalizedRanges) { + for (const range of pageRanges) { const builder = this.workflow(); - builder.addFilePart(pdf, { pages: range }); + builder.addFilePart(pdf, { + pages: { start: range.start ?? 0, end: range.end ?? -1 }, + }); workflows.push((builder as WorkflowWithPartsStage).outputPdf().execute()); } @@ -1722,45 +1653,20 @@ export class NutrientClient { * const result = await client.duplicatePages('document.pdf', [-1, -2, -3]); * ``` */ - async duplicatePages(pdf: FileInput, pageIndices: number[]): Promise { + async duplicatePages( + pdf: FileInputWithUrl, + pageIndices: number[], + ): Promise { if (!pageIndices || pageIndices.length === 0) { throw new ValidationError('At least one page index is required for duplication'); } - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - - // Get the actual page count of the PDF - const pageCount = await getPdfPageCount(normalizedFile); - - // Normalize negative indices - const normalizedIndices = pageIndices.map((index) => { - if (index < 0) { - // Handle negative indices (e.g., -1 is the last page) - return pageCount + index; - } - return index; - }); - - // Validate that all page indices are within range - if (normalizedIndices.some((index) => index < 0 || index >= pageCount)) { - throw new ValidationError( - `Page indices ${pageIndices.toString()} are out of range (document has ${pageCount} pages)`, - ); - } - const builder = this.workflow(); // Add each page in the order specified - for (const pageIndex of normalizedIndices) { - // Use normalizePageParams to ensure consistent handling - const pageRange = normalizePageParams({ start: pageIndex, end: pageIndex }); - builder.addFilePart(pdf, { pages: pageRange }); + // The API natively supports negative indices (e.g., -1 = last page) + for (const pageIndex of pageIndices) { + builder.addFilePart(pdf, { pages: { start: pageIndex, end: pageIndex } }); } const result = await (builder as WorkflowWithPartsStage).outputPdf().execute(); @@ -1798,68 +1704,78 @@ export class NutrientClient { * fs.writeFileSync('modified-document.pdf', Buffer.from(result.buffer)); * ``` */ - async deletePages(pdf: FileInput, pageIndices: number[]): Promise { + async deletePages( + pdf: FileInputWithUrl, + pageIndices: number[], + ): Promise { if (!pageIndices || pageIndices.length === 0) { throw new ValidationError('At least one page index is required for deletion'); } - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); + // Algorithm overview: + // We build "keep ranges" (pages to retain) by finding gaps between deleted indices. + // The API supports negative indices (-1 = last page, -2 = second-to-last, etc.) + // + // Examples: + // - deletePages(pdf, [1, 3]) on 5-page doc → keep [0-0], [2-2], [4 to -1] + // - deletePages(pdf, [0, -1]) → delete first and last → keep [1 to -2] + // - deletePages(pdf, [-3, -1]) → delete pages -3 and -1 → keep [0 to -4], [-2 to -2] - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - - // Get the actual page count of the PDF - const pageCount = await getPdfPageCount(normalizedFile); - - // Normalize negative indices - const normalizedIndices = pageIndices.map((index) => { - if (index < 0) { - // Handle negative indices (e.g., -1 is the last page) - return pageCount + index; - } - return index; - }); - - // Remove duplicates and sort the deleteIndices - const deleteIndices = [...new Set(normalizedIndices)].sort((a, b) => a - b); - - // Validate that all page indices are within range - if (deleteIndices.some((index) => index < 0 || index >= pageCount)) { - throw new ValidationError( - `Page indices ${pageIndices.toString()} are out of range (document has ${pageCount} pages)`, - ); - } + // Separate positive and negative indices (they're handled differently) + const positiveDeletes = [...new Set(pageIndices.filter((i) => i >= 0))].sort((a, b) => a - b); + const negativeDeletes = [...new Set(pageIndices.filter((i) => i < 0))].sort((a, b) => a - b); const builder = this.workflow(); - - // Group consecutive pages that should be kept into ranges - let currentPage: number = 0; - const pageRanges: { start: number; end: number }[] = []; - - for (const deleteIndex of deleteIndices) { + const keepRanges: { start: number; end: number }[] = []; + + // Determine the end boundary for positive ranges: + // - If we have negative deletes (e.g., -3), positive ranges must end before them (at -4) + // - If no negative deletes, positive ranges extend to end of document (-1) + const firstNegativeDelete = negativeDeletes[0]; + const endBoundary = firstNegativeDelete !== undefined ? firstNegativeDelete - 1 : -1; + + // Build keep ranges from positive deletions + // Walk through sorted positive indices and create ranges for gaps + let currentPage = 0; + for (const deleteIndex of positiveDeletes) { if (currentPage < deleteIndex) { - pageRanges.push(normalizePageParams({ start: currentPage, end: deleteIndex - 1 })); + keepRanges.push({ start: currentPage, end: deleteIndex - 1 }); } currentPage = deleteIndex + 1; } - if ( - (currentPage > 0 || (currentPage == 0 && deleteIndices.length == 0)) && - currentPage < pageCount - ) { - pageRanges.push(normalizePageParams({ start: currentPage, end: pageCount - 1 })); + // Add the final range from last deleted positive to the end boundary + // This captures everything between positive deletes and negative deletes + keepRanges.push({ start: currentPage, end: endBoundary }); + + // Build keep ranges for gaps between negative deletions + // e.g., if deleting [-3, -1], there's a gap at -2 we need to keep + // Negative indices are sorted ascending: [-3, -2, -1] so we look for gaps + for (let i = 0; i < negativeDeletes.length - 1; i++) { + const current = negativeDeletes[i]; + const next = negativeDeletes[i + 1]; + if (current !== undefined && next !== undefined && next - current > 1) { + // There's a gap between these negative indices (e.g., -3 to -1 has gap at -2) + keepRanges.push({ start: current + 1, end: next - 1 }); + } } - if (pageRanges.length === 0) { + // Filter out invalid ranges where start > end (only for positive indices) + // Ranges with negative end are valid - the API will resolve them + const validRanges = keepRanges.filter((range) => { + if (range.start >= 0 && range.end >= 0) { + return range.start <= range.end; + } + return true; + }); + + if (validRanges.length === 0) { throw new ValidationError('You cannot delete all pages from a document'); } - pageRanges.forEach((range) => { + for (const range of validRanges) { builder.addFilePart(pdf, { pages: range }); - }); + } const result = await (builder as WorkflowWithPartsStage).outputPdf().execute(); return this.processTypedWorkflowResult(result); @@ -1883,17 +1799,9 @@ export class NutrientClient { * ``` */ async optimize( - pdf: FileInput, + pdf: FileInputWithUrl, options: components['schemas']['OptimizePdf'] = { imageOptimizationQuality: 2 }, ): Promise { - const normalizedFile = isRemoteFileInput(pdf) - ? await processRemoteFileInput(pdf) - : await processFileInput(pdf); - - if (!(await isValidPdf(normalizedFile))) { - throw new ValidationError('Invalid pdf file', { input: pdf }); - } - const result = await this.workflow() .addFilePart(pdf) .outputPdf({ optimize: options }) diff --git a/src/index.ts b/src/index.ts index f07eae5..65f091d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -43,7 +43,6 @@ export { validateFileInput, processFileInput, isRemoteFileInput, - processRemoteFileInput, type NormalizedFileData, } from './inputs'; export { type ActionWithFileInput } from './build'; diff --git a/src/inputs.ts b/src/inputs.ts index 8e94b98..866edfd 100644 --- a/src/inputs.ts +++ b/src/inputs.ts @@ -1,9 +1,8 @@ -import type { FileInput, UrlInput } from './types'; +import type { FileInput, FileInputWithUrl } from './types'; import { isBuffer, isUint8Array, isUrl } from './types'; import { ValidationError } from './errors'; import fs from 'fs'; import path from 'path'; -import { Readable } from 'stream'; /** * Normalized file data for internal processing (Node.js only) @@ -15,11 +14,9 @@ export interface NormalizedFileData { } /** - * Processes various file input types into a normalized format (Node.js only) + * Processes various file input types into a normalized format (Node.js only). */ -export async function processFileInput( - input: Exclude, -): Promise { +export async function processFileInput(input: FileInput): Promise { if (typeof input === 'string') { return await processFilePathInput(input); } @@ -134,9 +131,9 @@ export function validateFileInput(input: unknown): input is FileInput { } /** - * Validation that the input is a remote file type + * Checks if the input is a URL (for workflow builder use). */ -export function isRemoteFileInput(input: FileInput): input is UrlInput | string { +export function isRemoteFileInput(input: FileInputWithUrl): boolean { if (typeof input === 'string') { return isUrl(input); } @@ -145,195 +142,18 @@ export function isRemoteFileInput(input: FileInput): input is UrlInput | string } /** - * Process Remote File Input + * Extracts the URL from a remote file input, if it is one. + * Returns null if the input is not a remote file (URL). + * This avoids unsafe type assertions by handling both string URLs and UrlInput objects. */ -export async function processRemoteFileInput( - input: UrlInput | string, -): Promise { - let url: string; +export function getRemoteUrl(input: FileInputWithUrl): string | null { if (typeof input === 'string') { - url = input; - } else { - url = input.url; + return isUrl(input) ? input : null; } - const buffer = await fetchFromUrl(url); - return { - data: buffer, - filename: 'buffer', - }; -} - -/** - * Fetches data from a URL and returns it as a Buffer - * - * @param url - The URL to fetch data from - * @returns A Buffer containing the fetched data - * @throws {ValidationError} If the fetch fails or returns a non-OK response - */ -async function fetchFromUrl(url: string): Promise { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new ValidationError(`Failed to fetch URL: ${response.status} ${response.statusText}`, { - url, - status: response.status, - statusText: response.statusText, - }); - } - - return Buffer.from(await response.arrayBuffer()); - } catch (error) { - if (error instanceof ValidationError) { - throw error; - } - throw new ValidationError(`Failed to fetch URL: ${url}`, { - url, - error: error instanceof Error ? error.message : String(error), - }); - } -} - -/** - * Zero dependency way to get the number of pages in a PDF. - * - * @param pdfData - Normalized file data of a PDF file - * @returns Number of pages in a PDF - * @throws {ValidationError} If the input is not a valid PDF or if the page count cannot be determined - */ -export async function getPdfPageCount(pdfData: NormalizedFileData): Promise { - let pdfBytes: Buffer; - - // Handle different data types in NormalizedFileData - if (isBuffer(pdfData.data)) { - pdfBytes = pdfData.data; - } else if (isUint8Array(pdfData.data)) { - pdfBytes = Buffer.from(pdfData.data); - } else if (pdfData.data instanceof fs.ReadStream || pdfData.data instanceof Readable) { - // Handle ReadableStream by reading it into a buffer - try { - const chunks = []; - for await (const chunk of pdfData.data) { - chunks.push(chunk); - } - pdfBytes = Buffer.concat(chunks); - } catch (error) { - throw new ValidationError(`Failed to read PDF stream: ${pdfData.filename}`, { - filename: pdfData.filename, - error: error instanceof Error ? error.message : String(error), - }); - } - } else { - throw new ValidationError('Invalid PDF data provided', { input: pdfData }); - } - - // Convert to string for regex operations - const pdfContent = pdfBytes.toString('binary'); - - // Find all PDF objects - using a safer regex pattern to avoid catastrophic backtracking - // Limit the content between obj and endobj to avoid ReDoS vulnerability - const objects: Array<[string, string, string]> = []; - - // Split by 'endobj' and process each chunk - const chunks = pdfContent.split('endobj'); - for (let i = 0; i < chunks.length - 1; i++) { - // For each chunk, find the start of the object - const objMatch = /(\d+)\s+(\d+)\s+obj/.exec(chunks[i] as string); - if (objMatch?.[1] && objMatch[2]) { - const objNum = objMatch[1]; - const genNum = objMatch[2]; - // Extract content after 'obj' - const content = (chunks[i] as string).substring(objMatch.index + objMatch[0].length); - objects.push([objNum, genNum, content]); - } - } - - if (objects.length === 0) { - throw new ValidationError('Could not find any objects in PDF', { input: pdfData }); - } - - // Get the Catalog Object - let catalogObj: string | null = null; - for (const [, , objData] of objects) { - if (objData.includes('/Type') && objData.includes('/Catalog')) { - catalogObj = objData; - break; - } - } - - if (!catalogObj) { - throw new ValidationError('Could not find /Catalog object in PDF', { input: pdfData }); - } - - // Extract /Pages reference (e.g. 3 0 R) - const pagesRefMatch = /\/Pages\s+(\d+)\s+(\d+)\s+R/.exec(catalogObj); - if (!pagesRefMatch) { - throw new ValidationError('Could not find /Pages reference in /Catalog', { input: pdfData }); - } - - const pagesObjNum = pagesRefMatch[1]; - const pagesObjGen = pagesRefMatch[2]; - - // Find the referenced /Pages object from our already parsed objects array - // This avoids using another potentially vulnerable regex - let pagesObjData: string | null = null; - for (const [objNum, genNum, objData] of objects) { - if (objNum === pagesObjNum && genNum === pagesObjGen) { - pagesObjData = objData; - break; - } - } - - if (!pagesObjData) { - throw new ValidationError('Could not find root /Pages object', { input: pdfData }); - } - - // Extract /Count - const countMatch = /\/Count\s+(\d+)/.exec(pagesObjData); - if (!countMatch) { - throw new ValidationError('Could not find /Count in root /Pages object', { input: pdfData }); + if (typeof input === 'object' && input !== null && 'type' in input && input.type === 'url') { + return input.url; } - return parseInt(countMatch[1] as string, 10); -} - -/** - * Zero dependency way to check if a file is a valid PDF. - * - * @param fileData - Normalized file data to check - * @returns Boolean indicating if the input is a valid PDF - */ -export async function isValidPdf(fileData: NormalizedFileData): Promise { - let fileBytes: Buffer; - - try { - // Handle different data types in NormalizedFileData - if (isBuffer(fileData.data)) { - fileBytes = fileData.data; - } else if (isUint8Array(fileData.data)) { - fileBytes = Buffer.from(fileData.data); - } else if (fileData.data instanceof fs.ReadStream || fileData.data instanceof Readable) { - // Handle ReadableStream by reading it into a buffer - try { - const chunks = []; - for await (const chunk of fileData.data) { - chunks.push(chunk); - } - fileBytes = Buffer.concat(chunks); - } catch { - return false; - } - } else { - return false; - } - - // Check for PDF header - // PDF files start with %PDF- followed by version number (e.g., %PDF-1.4) - const pdfHeader = fileBytes.slice(0, 5).toString('ascii'); - return pdfHeader === '%PDF-'; - } catch { - // If any error occurs during reading or processing, it's not a valid PDF - return false; - } + return null; } diff --git a/src/types/inputs.ts b/src/types/inputs.ts index b2bc3f7..494a523 100644 --- a/src/types/inputs.ts +++ b/src/types/inputs.ts @@ -33,16 +33,22 @@ export interface UrlInput { } /** - * Union type for all possible file inputs (Node.js only) + * Union type for local file inputs (Node.js only). + * Does not include URLs - for URL support, use the workflow builder. */ export type FileInput = | FilePathInput | BufferInput | Uint8ArrayInput - | UrlInput | Buffer // Node.js Buffer | Uint8Array // Raw binary data - | string; // File path or URL + | string; // File path + +/** + * File input that also accepts URLs (for workflow builder). + * URLs are passed directly to the server for fetching. + */ +export type FileInputWithUrl = FileInput | UrlInput; /** * Type guard to check if input is a Buffer diff --git a/src/types/workflow.ts b/src/types/workflow.ts index 180d48c..cf3d459 100644 --- a/src/types/workflow.ts +++ b/src/types/workflow.ts @@ -1,4 +1,4 @@ -import type { FileInput, UrlInput } from './inputs'; +import type { FileInput, FileInputWithUrl } from './inputs'; import type { components } from '../generated/api-types'; import type { ApplicableAction } from '../builders/workflow'; @@ -40,13 +40,13 @@ export type OutputTypeMap = { // Stage 1: Initial workflow - only part methods available export interface WorkflowInitialStage { addFilePart( - file: FileInput, + file: FileInputWithUrl, options?: Omit, actions?: ApplicableAction[], ): WorkflowWithPartsStage; addHtmlPart( - html: FileInput, - assets?: Exclude[], + html: FileInputWithUrl, + assets?: FileInput[], options?: Omit, actions?: ApplicableAction[], ): WorkflowWithPartsStage;