@tstdl/base 0.93.128 → 0.93.130

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.
@@ -156,9 +156,9 @@ describe('ApiClient', () => {
156
156
  expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].credentials).toBe('include');
157
157
  await client.withBustCache();
158
158
  expect(mockHttpClient.rawRequest.mock.calls.at(-1)[0].context[bustCache]).toBe(true);
159
- expect(Client.getEndpointResource('withCredentials')).toContain('with-credentials');
160
- expect(Client.getEndpointUrl('withCredentials').href).toBe('http://baseurl/api/v1/features/with-credentials');
161
- expect(client.getEndpointUrl('withCredentials').href).toBe('http://localhost/api/v1/features/with-credentials');
159
+ expect(Client.getEndpointResource('withCredentials')).not.toContain('withCredentials');
160
+ expect(Client.getEndpointUrl('withCredentials').href).toBe('http://baseurl/api/v1/features');
161
+ expect(client.getEndpointUrl('withCredentials').href).toBe('http://localhost/api/v1/features');
162
162
  });
163
163
  it('should handle Server Sent Events and DataStream', async () => {
164
164
  const apiDefinition = defineApi({
@@ -185,10 +185,10 @@ describe('ApiClient', () => {
185
185
  const client = new Client(mockHttpClient);
186
186
  const sse = await client.events();
187
187
  expect(sse).toBeInstanceOf(ServerSentEvents);
188
- expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse/events'), expect.any(Object));
188
+ expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse'), expect.any(Object));
189
189
  const stream = await client.stream();
190
190
  expect(stream).toBeInstanceOf(Object); // It's an Observable
191
- expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse/stream'), expect.any(Object));
191
+ expect(EventSourceMock).toHaveBeenCalledWith(expect.stringContaining('sse'), expect.any(Object));
192
192
  vi.unstubAllGlobals();
193
193
  });
194
194
  });
package/api/types.d.ts CHANGED
@@ -54,7 +54,7 @@ export type ApiEndpointDefinition = {
54
54
  *
55
55
  * results in
56
56
  * ```ts
57
- * ${endpoint.rootResource ?? api.ressource}/${endpoint.resource}
57
+ * ${endpoint.rootResource ?? api.resource}/${endpoint.resource}
58
58
  * ```
59
59
  * @default name of endpoint property
60
60
  */
package/api/types.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { objectEntries } from '../utils/object/object.js';
2
- import { hyphenate } from '../utils/string/index.js';
3
- import { isFunction, isUndefined } from '../utils/type-guards.js';
2
+ import { isFunction } from '../utils/type-guards.js';
4
3
  import { resolveValueOrProvider } from '../utils/value-or-provider.js';
5
4
  export function defineApi(definition) {
6
5
  return definition;
@@ -16,11 +15,5 @@ export function normalizedApiDefinitionEndpoints(apiDefinitionEndpoints) {
16
15
  return Object.fromEntries(entries);
17
16
  }
18
17
  export function normalizedApiDefinitionEndpointsEntries(apiDefinition) {
19
- return objectEntries(apiDefinition).map(([key, def]) => {
20
- const endpoint = resolveValueOrProvider(def);
21
- if (isUndefined(endpoint.resource)) {
22
- endpoint.resource = hyphenate(key);
23
- }
24
- return [key, endpoint];
25
- });
18
+ return objectEntries(apiDefinition).map(([key, def]) => [key, resolveValueOrProvider(def)]);
26
19
  }
@@ -6,7 +6,6 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
6
6
  };
7
7
  import sharp, {} from 'sharp';
8
8
  import { match } from 'ts-pattern';
9
- import { injectGenkit } from '../../../ai/genkit/index.js';
10
9
  import { ForbiddenError } from '../../../errors/forbidden.error.js';
11
10
  import { NotImplementedError } from '../../../errors/not-implemented.error.js';
12
11
  import { getMimeType, getMimeTypeExtensions, mimeTypes } from '../../../file/index.js';
@@ -20,14 +19,15 @@ import { digest } from '../../../utils/cryptography.js';
20
19
  import { currentTimestamp } from '../../../utils/date-time.js';
21
20
  import { getRandomString } from '../../../utils/random.js';
22
21
  import { readableStreamFromPromise, readBinaryStream } from '../../../utils/stream/index.js';
23
- import { isNotReadableStream, isNotUint8Array, isUint8Array } from '../../../utils/type-guards.js';
22
+ import { isNotReadableStream, isNotUint8Array, isString, isUint8Array } from '../../../utils/type-guards.js';
24
23
  import { millisecondsPerMinute } from '../../../utils/units.js';
25
24
  import { Document } from '../../models/index.js';
26
25
  import { DocumentManagementConfiguration } from '../module.js';
26
+ import { DocumentManagementAncillaryService } from './document-management-ancillary.service.js';
27
27
  import { DocumentManagementSingleton } from './singleton.js';
28
28
  let DocumentFileService = class DocumentFileService extends Transactional {
29
29
  #configuration = inject(DocumentManagementConfiguration);
30
- #genkit = injectGenkit();
30
+ #documentManagementAncillaryService = inject(DocumentManagementAncillaryService);
31
31
  #fileObjectStorage = inject(ObjectStorage, this.#configuration.fileObjectStorageModule);
32
32
  #filePreviewObjectStorage = inject(ObjectStorage, this.#configuration.filePreviewObjectStorageModule);
33
33
  #fileUploadObjectStorage = inject(ObjectStorage, { module: this.#configuration.fileUploadObjectStorageModule /* , configuration: { lifecycle: { expiration: { after: 5 * secondsPerMinute } } } */ });
@@ -86,7 +86,10 @@ let DocumentFileService = class DocumentFileService extends Transactional {
86
86
  return this.#fileObjectStorage.getContentStream(objectKey);
87
87
  }
88
88
  async getContentUrl(document, download = false) {
89
- return await this.getDocumentFileContentObjectUrl(document, document.title ?? document.id, download);
89
+ const resolvedFilename = await this.#documentManagementAncillaryService.getDocumentFilename(document);
90
+ const fileExtension = getMimeTypeExtensions(document.mimeType)[0] ?? 'bin';
91
+ const filename = isString(resolvedFilename) ? `${resolvedFilename}.${fileExtension}` : resolvedFilename.fullname;
92
+ return await this.getDocumentFileContentObjectUrl(document, filename, download);
90
93
  }
91
94
  /** Gets the underlying object storage object for the document file */
92
95
  async getObject(document) {
@@ -132,14 +135,12 @@ let DocumentFileService = class DocumentFileService extends Transactional {
132
135
  await this.#filePreviewObjectStorage.uploadObject(key, image, { contentLength: image.length, contentType: 'image/webp' });
133
136
  }
134
137
  }
135
- async getDocumentFileContentObjectUrl(document, title, download) {
138
+ async getDocumentFileContentObjectUrl(document, filename, download) {
136
139
  const key = getObjectKey(document.id);
137
- const fileExtension = getMimeTypeExtensions(document.mimeType)[0] ?? 'bin';
138
140
  const disposition = download ? 'attachment' : 'inline';
139
- const filename = `${title}.${fileExtension}`;
140
141
  return await this.#fileObjectStorage.getDownloadUrl(key, currentTimestamp() + (5 * millisecondsPerMinute), {
141
- 'Response-Content-Type': document.mimeType,
142
- 'Response-Content-Disposition': `${disposition}; filename = "${encodeURIComponent(filename)}"`,
142
+ 'Content-Type': document.mimeType,
143
+ 'Content-Disposition': `${disposition}; filename = "${encodeURIComponent(filename)}"`,
143
144
  });
144
145
  }
145
146
  };
@@ -1,5 +1,9 @@
1
- import type { DocumentCollection } from '../../models/index.js';
1
+ import type { Document, DocumentCollection } from '../../models/index.js';
2
2
  import type { DocumentCollectionMetadata } from '../../service-models/index.js';
3
+ export type DocumentFileNameResult = string | {
4
+ /** The full name of the document, including extension. */
5
+ fullname: string;
6
+ };
3
7
  export declare abstract class DocumentManagementAncillaryService {
4
8
  /**
5
9
  * Resolves application-specific metadata for a list of document collections.
@@ -7,4 +11,11 @@ export declare abstract class DocumentManagementAncillaryService {
7
11
  * @returns A promise that resolves to an array of DocumentCollectionMetadata, corresponding to the input collections.
8
12
  */
9
13
  abstract resolveMetadata(tenantId: string, collections: DocumentCollection[]): DocumentCollectionMetadata[] | Promise<DocumentCollectionMetadata[]>;
14
+ /**
15
+ * Resolve the file name for a document, which will be used when downloading the document file. The file extension is automatically determined based on the document's MIME type, so the returned file name should not include the extension.
16
+ * @param document The Document entity for which to resolve the file name.
17
+ * @param title An optional title provided by the api call.
18
+ * @returns The resolved file name for the document, without the file extension.
19
+ */
20
+ getDocumentFilename(document: Document): DocumentFileNameResult | Promise<DocumentFileNameResult>;
10
21
  }
@@ -1,2 +1,11 @@
1
1
  export class DocumentManagementAncillaryService {
2
+ /**
3
+ * Resolve the file name for a document, which will be used when downloading the document file. The file extension is automatically determined based on the document's MIME type, so the returned file name should not include the extension.
4
+ * @param document The Document entity for which to resolve the file name.
5
+ * @param title An optional title provided by the api call.
6
+ * @returns The resolved file name for the document, without the file extension.
7
+ */
8
+ getDocumentFilename(document) {
9
+ return document.title ?? document.originalFileName ?? document.id;
10
+ }
2
11
  }
@@ -12,7 +12,7 @@ export declare class TemporaryFile implements AsyncDisposable {
12
12
  /**
13
13
  * Prevents the temporary file from being deleted on disposal.
14
14
  */
15
- keep(): void;
15
+ keep(): this;
16
16
  read(): Promise<Uint8Array>;
17
17
  readText(): Promise<string>;
18
18
  readStream(): ReadableStream<Uint8Array>;
@@ -25,5 +25,6 @@ export declare class TemporaryFile implements AsyncDisposable {
25
25
  moveTo(path: string, keep?: boolean): Promise<void>;
26
26
  delete(): Promise<void>;
27
27
  size(): Promise<number>;
28
+ dispose(): Promise<void>;
28
29
  [Symbol.asyncDispose](): Promise<void>;
29
30
  }
@@ -36,6 +36,7 @@ export class TemporaryFile {
36
36
  */
37
37
  keep() {
38
38
  this.#keep = true;
39
+ return this;
39
40
  }
40
41
  async read() {
41
42
  return await readFile(this.#path);
@@ -69,7 +70,7 @@ export class TemporaryFile {
69
70
  const result = await stat(this.#path);
70
71
  return result.size;
71
72
  }
72
- async [Symbol.asyncDispose]() {
73
+ async dispose() {
73
74
  if (this.#keep) {
74
75
  return;
75
76
  }
@@ -78,4 +79,7 @@ export class TemporaryFile {
78
79
  }
79
80
  catch { }
80
81
  }
82
+ async [Symbol.asyncDispose]() {
83
+ await this.dispose();
84
+ }
81
85
  }
@@ -240,16 +240,16 @@ let S3ObjectStorage = S3ObjectStorage_1 = class S3ObjectStorage extends ObjectSt
240
240
  async getDownloadUrl(key, expirationTimestamp, responseHeaders) {
241
241
  const bucketKey = this.getBucketKey(key);
242
242
  const expiration = getExpiration(expirationTimestamp);
243
- const expiresHeader = responseHeaders?.['Expires'];
243
+ const expiresHeader = responseHeaders?.['Expires'] ?? responseHeaders?.['Response-Expires'];
244
244
  const mappedExpiresHeader = isDefined(expiresHeader) ? (isDate(expiresHeader) ? expiresHeader : new Date(expiresHeader)) : undefined;
245
245
  return await getSignedUrl(this.client, new GetObjectCommand({
246
246
  Bucket: this.bucket,
247
247
  Key: bucketKey,
248
- ResponseContentType: responseHeaders?.['Content-Type'],
249
- ResponseContentDisposition: responseHeaders?.['Content-Disposition'],
250
- ResponseCacheControl: responseHeaders?.['Cache-Control'],
251
- ResponseContentLanguage: responseHeaders?.['Content-Language'],
252
- ResponseContentEncoding: responseHeaders?.['Content-Encoding'],
248
+ ResponseContentType: responseHeaders?.['Content-Type'] ?? responseHeaders?.['Response-Content-Type'],
249
+ ResponseContentDisposition: responseHeaders?.['Content-Disposition'] ?? responseHeaders?.['Response-Content-Disposition'],
250
+ ResponseCacheControl: responseHeaders?.['Cache-Control'] ?? responseHeaders?.['Response-Cache-Control'],
251
+ ResponseContentLanguage: responseHeaders?.['Content-Language'] ?? responseHeaders?.['Response-Content-Language'],
252
+ ResponseContentEncoding: responseHeaders?.['Content-Encoding'] ?? responseHeaders?.['Response-Content-Encoding'],
253
253
  ResponseExpires: mappedExpiresHeader,
254
254
  }), { expiresIn: expiration });
255
255
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tstdl/base",
3
- "version": "0.93.128",
3
+ "version": "0.93.130",
4
4
  "author": "Patrick Hein",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -162,8 +162,8 @@
162
162
  "file-type": "^21.3",
163
163
  "genkit": "^1.28",
164
164
  "handlebars": "^4.7",
165
- "@aws-sdk/client-s3": "^3.990",
166
- "@aws-sdk/s3-request-presigner": "^3.990",
165
+ "@aws-sdk/client-s3": "^3.991",
166
+ "@aws-sdk/s3-request-presigner": "^3.991",
167
167
  "mjml": "^4.18",
168
168
  "nodemailer": "^8.0",
169
169
  "pg": "^8.18",
@@ -199,7 +199,7 @@
199
199
  "typedoc-plugin-markdown": "4.10",
200
200
  "typedoc-plugin-missing-exports": "4.1",
201
201
  "typescript": "5.9",
202
- "typescript-eslint": "8.55",
202
+ "typescript-eslint": "8.56",
203
203
  "vite-tsconfig-paths": "6.1",
204
204
  "vitest": "4.0"
205
205
  },
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,187 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
53
+ import { describe, expect, it } from 'vitest';
54
+ import { TemporaryFile } from '../../file/server/temporary-file.js';
55
+ import { spawnCommand } from '../../process/spawn.js';
56
+ import { extractPdfPages, getPdfPageCount, mergePdfs, overlayPdfs, pdfToImage } from '../utils.js';
57
+ describe('pdf utils', () => {
58
+ async function createPdf(pages) {
59
+ const file = TemporaryFile.create('.pdf');
60
+ const source = Array.from({ length: pages }, (_, i) => `Page ${i + 1}`).join('\n#pagebreak()\n');
61
+ const process = await spawnCommand('typst', ['compile', '-', file.path]);
62
+ await process.write(source);
63
+ await process.wait();
64
+ return file;
65
+ }
66
+ it('getPdfPageCount should return correct page count', async () => {
67
+ const env_1 = { stack: [], error: void 0, hasError: false };
68
+ try {
69
+ const pdf = __addDisposableResource(env_1, await createPdf(5), true);
70
+ const count = await getPdfPageCount(pdf.path);
71
+ expect(count).toBe(5);
72
+ }
73
+ catch (e_1) {
74
+ env_1.error = e_1;
75
+ env_1.hasError = true;
76
+ }
77
+ finally {
78
+ const result_1 = __disposeResources(env_1);
79
+ if (result_1)
80
+ await result_1;
81
+ }
82
+ });
83
+ it('extractPdfPages should extract specific pages', async () => {
84
+ const env_2 = { stack: [], error: void 0, hasError: false };
85
+ try {
86
+ const pdf = __addDisposableResource(env_2, await createPdf(10), true);
87
+ const extracted = __addDisposableResource(env_2, await extractPdfPages(pdf.path, [1, { from: 3, to: 5 }]), true);
88
+ const count = await getPdfPageCount(extracted.path);
89
+ expect(count).toBe(4); // 1 + (5-3+1) = 4
90
+ }
91
+ catch (e_2) {
92
+ env_2.error = e_2;
93
+ env_2.hasError = true;
94
+ }
95
+ finally {
96
+ const result_2 = __disposeResources(env_2);
97
+ if (result_2)
98
+ await result_2;
99
+ }
100
+ });
101
+ it('mergePdfs should handle different input types and page ranges', async () => {
102
+ const env_3 = { stack: [], error: void 0, hasError: false };
103
+ try {
104
+ const pdf1 = __addDisposableResource(env_3, await createPdf(2), true);
105
+ const pdf2 = __addDisposableResource(env_3, await createPdf(3), true);
106
+ const pdf2Bytes = await pdf1.read();
107
+ // Test with path, Uint8Array, and page range object
108
+ const merged = __addDisposableResource(env_3, await mergePdfs([
109
+ pdf1.path,
110
+ pdf2Bytes,
111
+ { input: pdf2.path, pages: [1, { from: 2, to: 3 }] },
112
+ ]), true);
113
+ const count = await getPdfPageCount(merged.path);
114
+ expect(count).toBe(2 + 2 + 3); // 2 (path) + 2 (bytes) + 3 (page range)
115
+ }
116
+ catch (e_3) {
117
+ env_3.error = e_3;
118
+ env_3.hasError = true;
119
+ }
120
+ finally {
121
+ const result_3 = __disposeResources(env_3);
122
+ if (result_3)
123
+ await result_3;
124
+ }
125
+ });
126
+ it('pdfToImage should work with binary inputs', async () => {
127
+ const env_4 = { stack: [], error: void 0, hasError: false };
128
+ try {
129
+ const pdf = __addDisposableResource(env_4, await createPdf(1), true);
130
+ const pdfBytes = await pdf.read();
131
+ // pdftocairo fails in some environments if no fonts are available,
132
+ // but here we just want to test the branching logic of writing bytes
133
+ const result = await pdfToImage(pdfBytes, 1, 100, 'png');
134
+ expect(result).toBeInstanceOf(Uint8Array);
135
+ }
136
+ catch (e_4) {
137
+ env_4.error = e_4;
138
+ env_4.hasError = true;
139
+ }
140
+ finally {
141
+ const result_4 = __disposeResources(env_4);
142
+ if (result_4)
143
+ await result_4;
144
+ }
145
+ });
146
+ it('overlayPdfs should overlay two pdfs', async () => {
147
+ const env_5 = { stack: [], error: void 0, hasError: false };
148
+ try {
149
+ const pdf1 = __addDisposableResource(env_5, await createPdf(1), true);
150
+ const pdf2 = __addDisposableResource(env_5, await createPdf(1), true);
151
+ const overlaid = __addDisposableResource(env_5, await overlayPdfs(pdf1.path, pdf2.path), true);
152
+ const count = await getPdfPageCount(overlaid.path);
153
+ expect(count).toBe(1);
154
+ }
155
+ catch (e_5) {
156
+ env_5.error = e_5;
157
+ env_5.hasError = true;
158
+ }
159
+ finally {
160
+ const result_5 = __disposeResources(env_5);
161
+ if (result_5)
162
+ await result_5;
163
+ }
164
+ });
165
+ it('overlayPdfs with repeat should overlay and respect repeat option', async () => {
166
+ const env_6 = { stack: [], error: void 0, hasError: false };
167
+ try {
168
+ const pdf1 = __addDisposableResource(env_6, await createPdf(1), true);
169
+ const pdf2 = __addDisposableResource(env_6, await createPdf(1), true);
170
+ const overlaid = __addDisposableResource(env_6, await overlayPdfs(pdf1.path, pdf2.path, { repeat: true }), true);
171
+ const count = await getPdfPageCount(overlaid.path);
172
+ expect(count).toBe(1);
173
+ }
174
+ catch (e_6) {
175
+ env_6.error = e_6;
176
+ env_6.hasError = true;
177
+ }
178
+ finally {
179
+ const result_6 = __disposeResources(env_6);
180
+ if (result_6)
181
+ await result_6;
182
+ }
183
+ });
184
+ it('should throw descriptive error on qpdf failure', async () => {
185
+ await expect(getPdfPageCount('non-existent.pdf')).rejects.toThrow(/qpdf: open non-existent.pdf: No such file or directory/);
186
+ });
187
+ });
package/pdf/utils.d.ts CHANGED
@@ -1,6 +1,23 @@
1
- export declare function getPdfPageCount(file: string | Uint8Array | ReadableStream<Uint8Array>): Promise<number>;
2
- export declare function mergePdfs(pdfs: (string | Uint8Array | ReadableStream<Uint8Array>)[]): Promise<Uint8Array>;
3
- export declare function mergePdfsStream(pdfs: (string | Uint8Array | ReadableStream<Uint8Array>)[]): Promise<ReadableStream<Uint8Array>>;
1
+ import { TemporaryFile } from '../file/server/temporary-file.js';
2
+ export type PdfInput = string | {
3
+ file: string;
4
+ } | Uint8Array | ReadableStream<Uint8Array>;
5
+ export type PdfInputWithPageRange = string | {
6
+ file: string;
7
+ } | Uint8Array | ReadableStream<Uint8Array> | {
8
+ input: PdfInput;
9
+ pages: PageRange[];
10
+ };
11
+ export type PageRange = number | {
12
+ from?: number;
13
+ to?: number;
14
+ };
15
+ export declare function getPdfPageCount(file: PdfInput): Promise<number>;
16
+ export declare function extractPdfPages(file: PdfInput, pages: PageRange[]): Promise<TemporaryFile>;
17
+ export declare function mergePdfs(sourceFiles: PdfInputWithPageRange[]): Promise<TemporaryFile>;
18
+ export declare function overlayPdfs(base: PdfInput, overlay: PdfInput, options?: {
19
+ repeat?: boolean;
20
+ }): Promise<TemporaryFile>;
4
21
  /**
5
22
  * Convert a PDF page to an image.
6
23
  * @param file The PDF file to convert.
@@ -10,3 +27,7 @@ export declare function mergePdfsStream(pdfs: (string | Uint8Array | ReadableStr
10
27
  * @returns The converted image as a byte array.
11
28
  */
12
29
  export declare function pdfToImage(file: string | Uint8Array | ReadableStream<Uint8Array>, page: number, size: number, format: 'png' | 'jpeg' | 'tiff' | 'ps' | 'eps' | 'pdf' | 'svg'): Promise<Uint8Array>;
30
+ export declare function isInputWithPageRange(input: PdfInputWithPageRange): input is {
31
+ input: PdfInput;
32
+ pages: PageRange[];
33
+ };
package/pdf/utils.js CHANGED
@@ -51,22 +51,17 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
51
51
  return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
52
  });
53
53
  import { TemporaryFile } from '../file/server/temporary-file.js';
54
- import { spawnCommand } from '../process/spawn.js';
55
- import { isNotString, isString } from '../utils/type-guards.js';
54
+ import { spawnCommand, spawnWaitCommand } from '../process/spawn.js';
55
+ import { hasOwnProperty } from '../utils/object/object.js';
56
+ import { isNotString, isNumber, isObject, isReadableStream, isString, isUint8Array, isUndefined } from '../utils/type-guards.js';
56
57
  export async function getPdfPageCount(file) {
57
58
  const env_1 = { stack: [], error: void 0, hasError: false };
58
59
  try {
59
- const fileIsPath = isString(file);
60
- const tmpFile = __addDisposableResource(env_1, fileIsPath ? undefined : await TemporaryFile.from(file), true);
61
- const path = fileIsPath ? file : tmpFile.path;
62
- const process = await spawnCommand('qpdf', ['--show-npages', path]);
63
- const { code } = await process.wait();
64
- if (code != 0) {
65
- const errorOutput = await process.readError();
66
- throw new Error(errorOutput.trim());
67
- }
68
- const output = await process.readOutput();
69
- return Number(output);
60
+ const stack = __addDisposableResource(env_1, new AsyncDisposableStack(), true);
61
+ const [sourceFile] = await getPdfSourceFiles([file], stack);
62
+ const process = await spawnCommand('qpdf', ['--show-npages', sourceFile]);
63
+ const result = await process.waitRead('string');
64
+ return Number(result.output);
70
65
  }
71
66
  catch (e_1) {
72
67
  env_1.error = e_1;
@@ -78,14 +73,15 @@ export async function getPdfPageCount(file) {
78
73
  await result_1;
79
74
  }
80
75
  }
81
- export async function mergePdfs(pdfs) {
76
+ export async function extractPdfPages(file, pages) {
82
77
  const env_2 = { stack: [], error: void 0, hasError: false };
83
78
  try {
84
79
  const stack = __addDisposableResource(env_2, new AsyncDisposableStack(), true);
85
- const resultFile = __addDisposableResource(env_2, TemporaryFile.create(), true);
86
- const sourceFiles = await getPdfSourceFiles(pdfs, stack);
87
- await pdfunite(sourceFiles, resultFile);
88
- return await resultFile.read();
80
+ const [sourceFile] = await getPdfSourceFiles([file], stack);
81
+ const resultFile = TemporaryFile.create();
82
+ const pagesString = toQpdfPageRangeString(pages);
83
+ await spawnWaitCommand('qpdf', ['--empty', '--pages', sourceFile, pagesString, '--', resultFile.path]);
84
+ return resultFile.keep();
89
85
  }
90
86
  catch (e_2) {
91
87
  env_2.error = e_2;
@@ -97,14 +93,23 @@ export async function mergePdfs(pdfs) {
97
93
  await result_2;
98
94
  }
99
95
  }
100
- export async function mergePdfsStream(pdfs) {
96
+ export async function mergePdfs(sourceFiles) {
101
97
  const env_3 = { stack: [], error: void 0, hasError: false };
102
98
  try {
103
99
  const stack = __addDisposableResource(env_3, new AsyncDisposableStack(), true);
104
100
  const resultFile = __addDisposableResource(env_3, TemporaryFile.create(), true);
105
- const sourceFiles = await getPdfSourceFiles(pdfs, stack);
106
- await pdfunite(sourceFiles, resultFile);
107
- return resultFile.readStream();
101
+ const sourceFilePaths = await getPdfSourceFiles(sourceFiles, stack);
102
+ const pages = sourceFiles.map((source) => isInputWithPageRange(source) ? source.pages : undefined);
103
+ const sourceArguments = sourceFilePaths.flatMap((path, index) => {
104
+ const pageRanges = pages[index];
105
+ if (isUndefined(pageRanges)) {
106
+ return [path];
107
+ }
108
+ const pagesString = toQpdfPageRangeString(pageRanges);
109
+ return [path, pagesString];
110
+ });
111
+ await spawnWaitCommand('qpdf', ['--empty', '--pages', ...sourceArguments, '--', resultFile.path]);
112
+ return resultFile.keep();
108
113
  }
109
114
  catch (e_3) {
110
115
  env_3.error = e_3;
@@ -116,22 +121,28 @@ export async function mergePdfsStream(pdfs) {
116
121
  await result_3;
117
122
  }
118
123
  }
119
- async function getPdfSourceFiles(pdfs, stack) {
120
- return await Promise.all(pdfs.map(async (pdf) => {
121
- if (isString(pdf)) {
122
- return pdf;
124
+ export async function overlayPdfs(base, overlay, options) {
125
+ const env_4 = { stack: [], error: void 0, hasError: false };
126
+ try {
127
+ const stack = __addDisposableResource(env_4, new AsyncDisposableStack(), true);
128
+ const resultFile = __addDisposableResource(env_4, TemporaryFile.create(), true);
129
+ const [baseFilePath, overlayFilePath] = await getPdfSourceFiles([base, overlay], stack);
130
+ const args = [baseFilePath, '--overlay', overlayFilePath];
131
+ if (options?.repeat) {
132
+ args.push('--repeat=1');
123
133
  }
124
- const tmpFile = await TemporaryFile.from(pdf);
125
- stack.use(tmpFile);
126
- return tmpFile.path;
127
- }));
128
- }
129
- async function pdfunite(sourceFiles, resultFile) {
130
- const process = await spawnCommand('pdfunite', [...sourceFiles, resultFile.path]);
131
- const { code } = await process.wait();
132
- if (code != 0) {
133
- const errorOutput = await process.readError();
134
- throw new Error(errorOutput);
134
+ args.push('--', resultFile.path);
135
+ await spawnWaitCommand('qpdf', args);
136
+ return resultFile.keep();
137
+ }
138
+ catch (e_4) {
139
+ env_4.error = e_4;
140
+ env_4.hasError = true;
141
+ }
142
+ finally {
143
+ const result_4 = __disposeResources(env_4);
144
+ if (result_4)
145
+ await result_4;
135
146
  }
136
147
  }
137
148
  /**
@@ -145,9 +156,40 @@ async function pdfunite(sourceFiles, resultFile) {
145
156
  export async function pdfToImage(file, page, size, format) {
146
157
  const path = isString(file) ? file : '-';
147
158
  const process = await spawnCommand('pdftocairo', ['-f', String(page), '-l', String(page), '-scale-to', String(size), '-singlefile', `-${format}`, path, '-']);
148
- process.handleNonZeroExitCode();
149
159
  if (isNotString(file)) {
150
160
  process.writeInBackground(file);
151
161
  }
152
162
  return await process.readOutputBytes();
153
163
  }
164
+ function toQpdfPageRangeString(pages) {
165
+ return pages.map((page) => {
166
+ if (isNumber(page)) {
167
+ return String(page);
168
+ }
169
+ else {
170
+ const from = page.from ?? '1';
171
+ const to = page.to ?? 'z';
172
+ return `${from}-${to}`;
173
+ }
174
+ }).join(',');
175
+ }
176
+ async function getPdfSourceFiles(input, stack) {
177
+ return await Promise.all(input.map(async (pdf) => {
178
+ if (isUint8Array(pdf) || isReadableStream(pdf)) {
179
+ const tmpFile = await TemporaryFile.from(pdf);
180
+ stack.use(tmpFile);
181
+ return tmpFile.path;
182
+ }
183
+ if (isString(pdf)) {
184
+ return pdf;
185
+ }
186
+ if (isInputWithPageRange(pdf)) {
187
+ const [path] = await getPdfSourceFiles([pdf.input], stack);
188
+ return path;
189
+ }
190
+ return pdf.file;
191
+ }));
192
+ }
193
+ export function isInputWithPageRange(input) {
194
+ return isObject(input) && hasOwnProperty(input, 'input') && hasOwnProperty(input, 'pages');
195
+ }
@@ -18,7 +18,7 @@ export type SpawnOptions = {
18
18
  workingDirectory?: string;
19
19
  environment?: Record<string, string>;
20
20
  };
21
- export type SpawnCommandResult = TransformStream<Uint8Array, Uint8Array> & {
21
+ export type SpawnCommandResult = TransformStream<Uint8Array, Uint8Array<ArrayBuffer>> & {
22
22
  process: ChildProcessWithoutNullStreams;
23
23
  stderr: ReadableStream<Uint8Array>;
24
24
  write(chunk: ReadableStream<Uint8Array> | Uint8Array | string, options?: StreamPipeOptions): Promise<void>;
package/process/spawn.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { dynamicImport } from '../import.js';
2
2
  import { LazyPromise } from '../promise/lazy-promise.js';
3
- import { decodeTextStream, encodeUtf8Stream } from '../utils/encoding.js';
4
- import { readBinaryStream, readTextStream } from '../utils/stream/stream-reader.js';
3
+ import { decodeText, encodeUtf8Stream } from '../utils/encoding.js';
4
+ import { readBinaryStream } from '../utils/stream/stream-reader.js';
5
5
  import { toReadableStream } from '../utils/stream/to-readable-stream.js';
6
- import { assertNotNullOrUndefinedPass, isArray, isReadableStream, isString, isUint8Array } from '../utils/type-guards.js';
6
+ import { assertNotNullOrUndefinedPass, isArray, isNotNull, isReadableStream, isString, isUint8Array } from '../utils/type-guards.js';
7
7
  export async function spawnWaitCommand(command, argsOrOptions, optionsOrNothing) {
8
- const [args, options] = isArray(argsOrOptions) ? [argsOrOptions, optionsOrNothing] : [undefined, argsOrOptions];
8
+ const [args, options] = isArray(argsOrOptions) ? [argsOrOptions, optionsOrNothing] : [argsOrOptions?.arguments, argsOrOptions];
9
9
  const process = await spawnCommand(command, args, options);
10
10
  return await process.wait({ throwOnNonZeroExitCode: options?.throwOnNonZeroExitCode });
11
11
  }
@@ -17,7 +17,7 @@ export async function spawnWaitReadCommand(format, command, argsOrOptions, optio
17
17
  export async function spawnCommand(command, argsOrOptions, optionsOrNothing) {
18
18
  const { spawn } = await dynamicImport('node:child_process');
19
19
  const { Readable, Writable } = await dynamicImport('node:stream');
20
- const [args, options] = isArray(argsOrOptions) ? [argsOrOptions, optionsOrNothing] : [undefined, argsOrOptions];
20
+ const [args, options] = isArray(argsOrOptions) ? [argsOrOptions, optionsOrNothing] : [argsOrOptions?.arguments, argsOrOptions];
21
21
  const process = spawn(command, args, { stdio: 'pipe', cwd: options?.workingDirectory, env: options?.environment });
22
22
  await Promise.race([
23
23
  new Promise((resolve) => process.on('spawn', resolve)),
@@ -40,29 +40,41 @@ export async function spawnCommand(command, argsOrOptions, optionsOrNothing) {
40
40
  /** Writes data in the background. Must be used with care because of potential unhandled errors */
41
41
  function writeInBackground(data, options) {
42
42
  write(data, options).catch((error) => {
43
- readable.cancel(error).catch(() => { });
44
- writable.abort(error).catch(() => { });
43
+ void readable.cancel(error).catch(() => { });
44
+ void writable.abort(error).catch(() => { });
45
+ void stderr.cancel(error).catch(() => { });
45
46
  });
46
47
  }
48
+ const outputBytes = new LazyPromise(async () => await readBinaryStream(readable));
49
+ const errorBytes = new LazyPromise(async () => await readBinaryStream(stderr));
50
+ const outputText = new LazyPromise(async () => decodeText(await outputBytes));
51
+ const errorText = new LazyPromise(async () => decodeText(await errorBytes));
47
52
  async function readOutputBytes() {
48
- return await readBinaryStream(readable);
53
+ return await outputBytes;
49
54
  }
50
55
  async function readOutput() {
51
- return await readTextStream(readable.pipeThrough(decodeTextStream()));
56
+ return await outputText;
52
57
  }
53
58
  async function readErrorBytes() {
54
- return await readBinaryStream(stderr);
59
+ return await errorBytes;
55
60
  }
56
61
  async function readError() {
57
- return await readTextStream(stderr.pipeThrough(decodeTextStream()));
62
+ return await errorText;
58
63
  }
59
- const signalPromise = new Promise((resolve) => process.on('close', (code, signal) => resolve({ code, signal })));
64
+ const signalPromise = new Promise((resolve) => process.on('close', (code, signal) => {
65
+ const result = { code, signal };
66
+ resolve(result);
67
+ if ((code != 0) || isNotNull(signal)) {
68
+ void handleNonZeroExitCode();
69
+ }
70
+ }));
60
71
  const nonZeroExitCodeError = new LazyPromise(async () => {
61
72
  const result = await signalPromise;
62
- if (result.code != 0) {
73
+ if ((result.code != 0) || isNotNull(result.signal)) {
63
74
  try {
64
- const errorOutput = await readError();
65
- return new Error(errorOutput.trim());
75
+ const errorOutput = await errorText;
76
+ const message = errorOutput.trim();
77
+ return new Error(message.length > 0 ? message : `Process exited with code ${result.code} and signal ${result.signal}.`);
66
78
  }
67
79
  catch {
68
80
  return new Error(`Process exited with code ${result.code} and signal ${result.signal}.`);
@@ -75,12 +87,12 @@ export async function spawnCommand(command, argsOrOptions, optionsOrNothing) {
75
87
  if (error) {
76
88
  await writable.abort(error).catch(() => { });
77
89
  await readable.cancel(error).catch(() => { });
90
+ await stderr.cancel(error).catch(() => { });
78
91
  }
79
92
  }
80
93
  async function wait({ throwOnNonZeroExitCode = true } = {}) {
81
94
  const result = await signalPromise;
82
- const handleNonZeroExitCode = (result.code != 0) && throwOnNonZeroExitCode;
83
- if (handleNonZeroExitCode) {
95
+ if (((result.code != 0) || isNotNull(result.signal)) && throwOnNonZeroExitCode) {
84
96
  const error = await nonZeroExitCodeError;
85
97
  throw assertNotNullOrUndefinedPass(error);
86
98
  }
@@ -88,10 +100,14 @@ export async function spawnCommand(command, argsOrOptions, optionsOrNothing) {
88
100
  }
89
101
  async function waitRead(format, { throwOnNonZeroExitCode = true } = {}) {
90
102
  const [result, output, error] = await Promise.all([
91
- wait({ throwOnNonZeroExitCode }),
92
- (format === 'string') ? readOutput() : readOutputBytes(),
93
- (format === 'string') ? readError() : readErrorBytes(),
103
+ wait({ throwOnNonZeroExitCode: false }),
104
+ (format === 'string') ? outputText : outputBytes,
105
+ (format === 'string') ? errorText : errorBytes,
94
106
  ]);
107
+ if (((result.code != 0) || isNotNull(result.signal)) && throwOnNonZeroExitCode) {
108
+ const error = await nonZeroExitCodeError;
109
+ throw assertNotNullOrUndefinedPass(error);
110
+ }
95
111
  return {
96
112
  ...result,
97
113
  output: output,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,182 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { encodeUtf8 } from '../../utils/encoding.js';
3
+ import { toReadableStream } from '../../utils/stream/to-readable-stream.js';
4
+ import { spawnCommand, spawnWaitCommand, spawnWaitReadCommand } from '../spawn.js';
5
+ describe('spawn', () => {
6
+ describe('spawnCommand', () => {
7
+ it('should spawn a command and wait for completion', async () => {
8
+ const process = await spawnCommand('sh', ['-c', 'exit 0']);
9
+ const result = await process.wait();
10
+ expect(result.code).toBe(0);
11
+ });
12
+ it('should throw on non-zero exit code', async () => {
13
+ const process = await spawnCommand('sh', ['-c', 'echo "error message" >&2; exit 1']);
14
+ await expect(process.wait()).rejects.toThrow('error message');
15
+ });
16
+ it('should handle process terminated by signal', async () => {
17
+ const process = await spawnCommand('sh', ['-c', 'kill -9 $$']);
18
+ await expect(process.wait()).rejects.toThrow('Process exited with code null and signal SIGKILL');
19
+ });
20
+ it('should buffer stdout and allow multiple reads', async () => {
21
+ const process = await spawnCommand('sh', ['-c', 'echo "hello world"']);
22
+ await process.wait();
23
+ const output1 = await process.readOutput();
24
+ const output2 = await process.readOutput();
25
+ const outputBytes = await process.readOutputBytes();
26
+ expect(output1.trim()).toBe('hello world');
27
+ expect(output1).toBe(output2);
28
+ expect(new TextDecoder().decode(outputBytes).trim()).toBe('hello world');
29
+ });
30
+ it('should buffer stderr and allow multiple reads', async () => {
31
+ const process = await spawnCommand('sh', ['-c', 'echo "error message" >&2']);
32
+ await process.wait();
33
+ const error1 = await process.readError();
34
+ const error2 = await process.readError();
35
+ const errorBytes = await process.readErrorBytes();
36
+ expect(error1.trim()).toBe('error message');
37
+ expect(error1).toBe(error2);
38
+ expect(new TextDecoder().decode(errorBytes).trim()).toBe('error message');
39
+ });
40
+ it('should handle different write data types', async () => {
41
+ const process1 = await spawnCommand('cat');
42
+ await process1.write(toReadableStream(encodeUtf8('stream\n')));
43
+ expect(await process1.readOutput()).toBe('stream\n');
44
+ const process2 = await spawnCommand('cat');
45
+ await process2.write(encodeUtf8('bytes\n'));
46
+ expect(await process2.readOutput()).toBe('bytes\n');
47
+ const process3 = await spawnCommand('cat');
48
+ await process3.write('string\n');
49
+ expect(await process3.readOutput()).toBe('string\n');
50
+ });
51
+ it('should handle background writes and error propagation', async () => {
52
+ const process = await spawnCommand('cat');
53
+ process.writeInBackground('test data');
54
+ const result = await process.waitRead('string');
55
+ expect(result.output).toBe('test data');
56
+ });
57
+ it('should handle errors in writeInBackground', async () => {
58
+ const process = await spawnCommand('sh', ['-c', 'exit 0']);
59
+ await process.writable.close();
60
+ process.writeInBackground('this should fail');
61
+ });
62
+ it('should handle non-zero exit code with empty stderr', async () => {
63
+ const process = await spawnCommand('sh', ['-c', 'exit 1']);
64
+ await expect(process.wait()).rejects.toThrow('Process exited with code 1 and signal null.');
65
+ });
66
+ it('should support manual handleNonZeroExitCode', async () => {
67
+ const process = await spawnCommand('sh', ['-c', 'exit 1']);
68
+ process.handleNonZeroExitCode();
69
+ await expect(process.wait()).rejects.toThrow();
70
+ });
71
+ it('should handle errors when reading error text in nonZeroExitCodeError', async () => {
72
+ // Mock TextDecoder to throw
73
+ const originalTextDecoder = global.TextDecoder;
74
+ global.TextDecoder = vi.fn().mockImplementation(() => ({
75
+ decode: () => { throw new Error('mock error'); },
76
+ }));
77
+ try {
78
+ const process = await spawnCommand('sh', ['-c', 'exit 1']);
79
+ await expect(process.wait()).rejects.toThrow('Process exited with code 1 and signal null.');
80
+ }
81
+ finally {
82
+ global.TextDecoder = originalTextDecoder;
83
+ }
84
+ });
85
+ it('should support passing options as second argument', async () => {
86
+ const process = await spawnCommand('sh', { arguments: ['-c', 'exit 0'] });
87
+ const result = await process.wait();
88
+ expect(result.code).toBe(0);
89
+ });
90
+ it('should support separate args and options', async () => {
91
+ const process = await spawnCommand('sh', ['-c', 'exit 0'], { workingDirectory: '.' });
92
+ const result = await process.wait();
93
+ expect(result.code).toBe(0);
94
+ });
95
+ it('should handle explicit undefined options', async () => {
96
+ const process = await spawnCommand('sh', ['-c', 'exit 0'], undefined);
97
+ const result = await process.wait();
98
+ expect(result.code).toBe(0);
99
+ });
100
+ it('should wait without options', async () => {
101
+ const process = await spawnCommand('sh', ['-c', 'exit 0']);
102
+ const result = await process.wait(undefined);
103
+ expect(result.code).toBe(0);
104
+ });
105
+ it('should handle command only', async () => {
106
+ const process = await spawnCommand('ls');
107
+ const result = await process.wait();
108
+ expect(result.code).toBe(0);
109
+ });
110
+ });
111
+ describe('spawnWaitCommand', () => {
112
+ it('should wait for command and return result', async () => {
113
+ const result = await spawnWaitCommand('sh', ['-c', 'exit 0']);
114
+ expect(result.code).toBe(0);
115
+ });
116
+ it('should throw on error by default', async () => {
117
+ await expect(spawnWaitCommand('sh', ['-c', 'exit 1'])).rejects.toThrow();
118
+ });
119
+ it('should support separate args and options', async () => {
120
+ const result = await spawnWaitCommand('sh', ['-c', 'exit 0'], { workingDirectory: '.' });
121
+ expect(result.code).toBe(0);
122
+ });
123
+ it('should support passing options directly', async () => {
124
+ const result = await spawnWaitCommand('sh', { arguments: ['-c', 'exit 0'] });
125
+ expect(result.code).toBe(0);
126
+ });
127
+ it('should support command only', async () => {
128
+ const result = await spawnWaitCommand('ls');
129
+ expect(result.code).toBe(0);
130
+ });
131
+ it('should handle explicit undefined options', async () => {
132
+ const result = await spawnWaitCommand('sh', ['-c', 'exit 0'], undefined);
133
+ expect(result.code).toBe(0);
134
+ });
135
+ it('should NOT throw on error if throwOnNonZeroExitCode is false', async () => {
136
+ const result = await spawnWaitCommand('sh', ['-c', 'exit 1'], { throwOnNonZeroExitCode: false });
137
+ expect(result.code).toBe(1);
138
+ });
139
+ });
140
+ describe('spawnWaitReadCommand', () => {
141
+ it('should wait and read output', async () => {
142
+ const result = await spawnWaitReadCommand('string', 'sh', ['-c', 'echo "test"']);
143
+ expect(result.output.trim()).toBe('test');
144
+ });
145
+ it('should read binary output', async () => {
146
+ const result = await spawnWaitReadCommand('binary', 'sh', ['-c', 'echo "test"']);
147
+ expect(result.output).toBeInstanceOf(Uint8Array);
148
+ });
149
+ it('should include error in exception', async () => {
150
+ await expect(spawnWaitReadCommand('string', 'sh', ['-c', 'echo "fail" >&2; exit 1'])).rejects.toThrow('fail');
151
+ });
152
+ it('should support options object', async () => {
153
+ const result = await spawnWaitReadCommand('string', 'sh', { arguments: ['-c', 'echo "test"'] });
154
+ expect(result.output.trim()).toBe('test');
155
+ });
156
+ it('should support separate args and options', async () => {
157
+ const result = await spawnWaitReadCommand('string', 'sh', ['-c', 'echo "test"'], { workingDirectory: '.' });
158
+ expect(result.output.trim()).toBe('test');
159
+ });
160
+ it('should support command only', async () => {
161
+ const result = await spawnWaitReadCommand('string', 'ls');
162
+ expect(result.code).toBe(0);
163
+ });
164
+ it('should NOT throw on error if throwOnNonZeroExitCode is false', async () => {
165
+ const result = await spawnWaitReadCommand('string', 'sh', ['-c', 'echo "fail" >&2; exit 1'], { throwOnNonZeroExitCode: false });
166
+ expect(result.code).toBe(1);
167
+ expect(result.error.trim()).toBe('fail');
168
+ });
169
+ it('should handle explicit undefined options', async () => {
170
+ const result = await spawnWaitReadCommand('string', 'sh', ['-c', 'echo "test"'], undefined);
171
+ expect(result.output.trim()).toBe('test');
172
+ });
173
+ it('should wait and read binary output without options', async () => {
174
+ const result = await spawnWaitReadCommand('binary', 'sh', ['-c', 'echo "test"']);
175
+ expect(result.output).toBeInstanceOf(Uint8Array);
176
+ });
177
+ it('should wait and read with explicit undefined options', async () => {
178
+ const result = await spawnWaitReadCommand('string', 'sh', ['-c', 'echo "test"'], undefined);
179
+ expect(result.output.trim()).toBe('test');
180
+ });
181
+ });
182
+ });
package/renderer/d2.js CHANGED
@@ -51,18 +51,7 @@ export async function renderD2(source, options) {
51
51
  args.push('--scale', options.scale.toString());
52
52
  }
53
53
  const process = await spawnCommand('d2', args);
54
- const [{ code, output, error }] = await Promise.all([
55
- process.waitRead('binary', { throwOnNonZeroExitCode: false }),
56
- process.write(source),
57
- ]);
58
- const errorString = decodeText(error);
59
- if (code !== 0) {
60
- throw new Error(`
61
- D2 rendering failed with exit code ${code}.
62
-
63
- Error Output:
64
- ${errorString}
65
- `.trim());
66
- }
67
- return output;
54
+ process.writeInBackground(source);
55
+ const result = await process.waitRead('binary');
56
+ return result.output;
68
57
  }
@@ -41,18 +41,7 @@ export async function renderGraphviz(source, options) {
41
41
  args.push(`-q${level}`);
42
42
  }
43
43
  const process = await spawnCommand(engine, args);
44
- const [{ code, output, error }] = await Promise.all([
45
- process.waitRead('binary', { throwOnNonZeroExitCode: false }),
46
- process.write(source),
47
- ]);
48
- const errorString = decodeText(error);
49
- if (code !== 0) {
50
- throw new Error(`
51
- Graphviz rendering failed with exit code ${code}.
52
-
53
- Error Output:
54
- ${errorString}
55
- `.trim());
56
- }
57
- return output;
44
+ process.writeInBackground(source);
45
+ const result = await process.waitRead('binary');
46
+ return result.output;
58
47
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { renderD2 } from '../d2.js';
3
+ import { renderGraphviz } from '../graphviz.js';
4
+ import { renderTypst } from '../typst.js';
5
+ describe('renderer', () => {
6
+ describe('renderGraphviz', () => {
7
+ it('should render a simple graph with options', async () => {
8
+ const source = 'graph { a -- b }';
9
+ const result = await renderGraphviz(source, {
10
+ format: 'svg',
11
+ engine: 'dot',
12
+ graphAttributes: { rankdir: 'LR' },
13
+ nodeAttributes: { color: 'red' },
14
+ edgeAttributes: { style: 'dashed' },
15
+ scale: 2,
16
+ quiet: true,
17
+ });
18
+ const svg = new TextDecoder().decode(result);
19
+ expect(svg).toContain('<svg');
20
+ expect(svg).toContain('stroke="red"');
21
+ expect(svg).toContain('stroke-dasharray');
22
+ });
23
+ it('should throw on invalid graphviz source', async () => {
24
+ const source = 'invalid graph {';
25
+ await expect(renderGraphviz(source)).rejects.toThrow();
26
+ });
27
+ });
28
+ describe('renderD2', () => {
29
+ it('should render a simple d2 diagram with options', async () => {
30
+ const source = 'x -> y';
31
+ const result = await renderD2(source, {
32
+ format: 'svg',
33
+ layout: 'dagre',
34
+ theme: 1,
35
+ darkTheme: 200,
36
+ pad: 20,
37
+ sketch: true,
38
+ center: true,
39
+ animateInterval: 1000,
40
+ noXmlTag: true,
41
+ salt: 'test',
42
+ omitVersion: true,
43
+ target: '*',
44
+ scale: 0.5,
45
+ });
46
+ const svg = new TextDecoder().decode(result);
47
+ expect(svg).toContain('<svg');
48
+ });
49
+ it('should throw on invalid d2 source', async () => {
50
+ const source = 'invalid {';
51
+ await expect(renderD2(source)).rejects.toThrow();
52
+ });
53
+ });
54
+ describe('renderTypst', () => {
55
+ it('should render a simple typst document with options', async () => {
56
+ const source = 'Hello World';
57
+ const result = await renderTypst(source, {
58
+ format: 'svg',
59
+ ppi: 300,
60
+ pages: ['1', { from: 1, to: 1 }],
61
+ ignoreSystemFonts: true,
62
+ creationTimestamp: Date.now(),
63
+ root: '/',
64
+ fontPaths: ['/tmp'],
65
+ pdfStandard: '1.7',
66
+ });
67
+ const svg = new TextDecoder().decode(result);
68
+ expect(svg).toContain('<svg');
69
+ expect(svg).toContain('typst-text');
70
+ });
71
+ it('should render to docx (via pandoc) with options', async () => {
72
+ const source = 'Hello World';
73
+ // pandoc --from typst --to docx --output - -
74
+ const result = await renderTypst(source, {
75
+ format: 'docx',
76
+ docxReferenceFile: undefined, // Testing the branch
77
+ });
78
+ expect(result).toBeInstanceOf(Uint8Array);
79
+ // Docx is a zip file, it should start with PK
80
+ expect(result[0]).toBe(0x50); // P
81
+ expect(result[1]).toBe(0x4B); // K
82
+ });
83
+ it('should throw on invalid typst source', async () => {
84
+ const source = '#invalid_func()';
85
+ await expect(renderTypst(source)).rejects.toThrow();
86
+ });
87
+ });
88
+ });
@@ -43,6 +43,11 @@ export type TypstRenderOptions = {
43
43
  * One (or multiple comma-separated) PDF standards that Typst will enforce conformance with.
44
44
  */
45
45
  pdfStandard?: LiteralUnion<'1.4' | '1.5' | '1.6' | '1.7' | '2.0' | 'a-1b' | 'a-1a' | 'a-2b' | 'a-2u' | 'a-2a' | 'a-3b' | 'a-3u' | 'a-3a' | 'a-4' | 'a-4f' | 'a-4e' | 'ua-1', string>;
46
+ /**
47
+ * A reference file to use when rendering to docx format. This allows customizing styles, fonts, etc. in the generated docx file. The file should be a valid docx file that serves as a template for the output.
48
+ * The reference file can be generated with `pandoc --print-default-data-file reference.docx`
49
+ */
50
+ docxReferenceFile?: string;
46
51
  };
47
52
  /**
48
53
  * Renders Typst source code to a file in the specified format.
package/renderer/typst.js CHANGED
@@ -14,10 +14,9 @@ import { isDefined, isNumber, isString } from '../utils/type-guards.js';
14
14
  export async function renderTypst(source, options) {
15
15
  const format = options?.format ?? 'pdf';
16
16
  const command = (format == 'docx') ? 'pandoc' : 'typst';
17
- let args = (format == 'docx')
18
- ? ['--from', 'typst', '--to', 'docx', '--output']
19
- : ['compile', '--format', format];
17
+ let args = [];
20
18
  if (command == 'typst') {
19
+ args = ['compile', '--format', format];
21
20
  if (isDefined(options?.root)) {
22
21
  args.push('--root', options.root);
23
22
  }
@@ -44,19 +43,17 @@ export async function renderTypst(source, options) {
44
43
  : `${page.from ?? ''}-${page.to ?? ''}`);
45
44
  args.push('--pages', pageParts.join(','));
46
45
  }
46
+ args.push('-', '-');
47
47
  }
48
- args.push('-', '-');
49
- const process = await spawnCommand(command, args);
50
- const [{ code, output, error }] = await Promise.all([
51
- process.waitRead('binary', { throwOnNonZeroExitCode: false }),
52
- process.write(source),
53
- ]);
54
- const errorString = decodeText(error);
55
- if (code !== 0) {
56
- throw new Error(`
57
- Typst compilation failed with exit code ${code}.\n
58
- Error Output:\n${errorString}
59
- `.trim());
48
+ else if (command == 'pandoc') {
49
+ args = ['--from', 'typst', '--to', 'docx'];
50
+ if (isDefined(options?.docxReferenceFile)) {
51
+ args.push('--reference-doc', options.docxReferenceFile);
52
+ }
53
+ args.push('--output', '-', '-');
60
54
  }
61
- return output;
55
+ const process = await spawnCommand(command, args);
56
+ process.writeInBackground(source);
57
+ const result = await process.waitRead('binary');
58
+ return result.output;
62
59
  }
package/test5.js CHANGED
@@ -1,61 +1,11 @@
1
1
  import './polyfills.js';
2
- import { buildPrompts, orderedList } from './ai/index.js';
3
- import { fewShotPrompt } from './ai/prompts/steering.js';
4
2
  import { Application } from './application/application.js';
5
3
  import { provideModule, provideSignalHandler } from './application/index.js';
6
4
  import { PrettyPrintLogFormatter } from './logger/index.js';
7
5
  import { provideConsoleLogTransport } from './logger/transports/console.js';
6
+ import { mergePdfs } from './pdf/utils.js';
8
7
  async function main(_cancellationSignal) {
9
- const prompt = buildPrompts({
10
- baseSystemInstructions: { Role: 'You are a helpful assistant.' },
11
- baseUserInstructions: 'Please process the following data.',
12
- additionalSystemInstructions: 'Make sure to follow the user instructions carefully.',
13
- additionalUserInstructions: [
14
- 'The data is in JSON format.',
15
- fewShotPrompt([
16
- {
17
- input: { a: 1, b: 2 },
18
- output: 3,
19
- reason: 'This is a positive example showing that the function should add the two numbers.',
20
- },
21
- {
22
- input: { a: 1, b: 2 },
23
- output: 4,
24
- isNegative: true,
25
- reason: 'This is a negative example showing that the function should NOT return 4 for these inputs.',
26
- },
27
- ]),
28
- {
29
- Foo: {
30
- Bar: 'This is a nested instruction example.',
31
- Baz: {
32
- Qux: 'Make sure to handle nested instructions properly.',
33
- Bux: orderedList('This is an ordered list of instructions', [
34
- 'First instruction',
35
- 'Second instruction',
36
- 'Third instruction',
37
- ]),
38
- },
39
- },
40
- },
41
- ],
42
- data: {
43
- context: {
44
- task: 'Our favorite hikes together',
45
- location: 'Boulder',
46
- season: 'spring_2025',
47
- },
48
- friends: ['ana', 'luis', 'sam'],
49
- hikes: [
50
- { id: 1, name: 'Blue Lake Trail', distanceKm: 7.5, elevationGain: 320, companion: 'ana', wasSunny: true },
51
- { id: 2, name: 'Ridge Overlook', distanceKm: 9.2, elevationGain: 540, companion: 'luis', wasSunny: false },
52
- { id: 3, name: 'Wildflower Loop', distanceKm: 5.1, elevationGain: 180, companion: 'sam', wasSunny: true },
53
- ],
54
- },
55
- });
56
- console.log(prompt.systemPrompt[0].text);
57
- console.log();
58
- console.log(prompt.userPrompt[0].text);
8
+ await mergePdfs(['asd', 'bsa']);
59
9
  }
60
10
  Application.run('Test', [
61
11
  provideConsoleLogTransport(PrettyPrintLogFormatter),