@formio/uag 2.0.0-rc.6 → 2.0.0-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +8 -6
  2. package/bin/uag.js +48 -5
  3. package/lib/UAGFormInterface.d.ts +12 -0
  4. package/lib/UAGFormInterface.js +30 -3
  5. package/lib/UAGProjectInterface.d.ts +9 -1
  6. package/lib/UAGProjectInterface.js +14 -3
  7. package/lib/agent/anonymous.js +1 -1
  8. package/lib/agent/engines.d.ts +10 -0
  9. package/lib/agent/engines.js +15 -1
  10. package/lib/agent/errors.d.ts +9 -0
  11. package/lib/agent/errors.js +20 -0
  12. package/lib/agent/index.d.ts +1 -1
  13. package/lib/agent/index.js +2 -1
  14. package/lib/agent/remote.d.ts +5 -1
  15. package/lib/agent/remote.js +25 -2
  16. package/lib/agent/router.d.ts +32 -2
  17. package/lib/agent/router.js +17 -11
  18. package/lib/agent/stubs.d.ts +13 -2
  19. package/lib/agent/stubs.js +18 -5
  20. package/lib/agent/tools.d.ts +24 -3
  21. package/lib/agent/tools.js +47 -4
  22. package/lib/config.d.ts +31 -2
  23. package/lib/documents/convert.d.ts +38 -0
  24. package/lib/documents/convert.js +151 -0
  25. package/lib/documents/converters.d.ts +28 -0
  26. package/lib/documents/converters.js +149 -0
  27. package/lib/documents/detect.d.ts +31 -0
  28. package/lib/documents/detect.js +169 -0
  29. package/lib/documents/index.d.ts +9 -0
  30. package/lib/documents/index.js +43 -0
  31. package/lib/documents/mime.d.ts +39 -0
  32. package/lib/documents/mime.js +100 -0
  33. package/lib/documents/plugins.d.ts +18 -0
  34. package/lib/documents/plugins.js +65 -0
  35. package/lib/documents/readers.d.ts +19 -0
  36. package/lib/documents/readers.js +64 -0
  37. package/lib/documents/routes.d.ts +52 -0
  38. package/lib/documents/routes.js +99 -0
  39. package/lib/documents/types.d.ts +126 -0
  40. package/lib/documents/types.js +2 -0
  41. package/lib/documents/uagJs.d.ts +17 -0
  42. package/lib/documents/uagJs.js +157 -0
  43. package/lib/formflow.d.ts +4 -1
  44. package/lib/formflow.js +2 -2
  45. package/lib/index.d.ts +50 -6
  46. package/lib/index.js +151 -15
  47. package/lib/markdown.d.ts +1 -0
  48. package/lib/markdown.js +166 -0
  49. package/lib/modules/api.d.ts +22 -0
  50. package/lib/modules/api.js +100 -0
  51. package/lib/modules/index.d.ts +4 -0
  52. package/lib/modules/index.js +24 -0
  53. package/lib/modules/routes.d.ts +14 -0
  54. package/lib/modules/routes.js +21 -0
  55. package/lib/modules/types.d.ts +68 -0
  56. package/lib/modules/types.js +5 -0
  57. package/lib/pluginFiles.d.ts +28 -0
  58. package/lib/pluginFiles.js +101 -0
  59. package/lib/plugins.d.ts +17 -0
  60. package/lib/plugins.js +93 -0
  61. package/lib/richText.d.ts +20 -0
  62. package/lib/richText.js +99 -0
  63. package/lib/router.d.ts +9 -0
  64. package/lib/router.js +19 -0
  65. package/lib/testing.d.ts +74 -0
  66. package/lib/testing.js +175 -0
  67. package/lib/tools/index.d.ts +2 -1
  68. package/lib/tools/index.js +5 -2
  69. package/lib/tools/readDocument.d.ts +17 -0
  70. package/lib/tools/readDocument.js +99 -0
  71. package/lib/tools/submissionUpdate.js +3 -2
  72. package/lib/tools/utils.d.ts +40 -0
  73. package/lib/tools/utils.js +16 -1
  74. package/package.json +10 -4
  75. package/testing.d.ts +1 -0
  76. package/testing.js +2 -0
@@ -1,3 +1,4 @@
1
+ import type { GenerateRequest } from '@formio/uag-sdk';
1
2
  /**
2
3
  * Stand-in engines for tests and local development. They answer instantly with
3
4
  * predictable values, so the Agent API can be exercised end to end without
@@ -6,7 +7,9 @@
6
7
  * any instance name.
7
8
  */
8
9
  export declare const StubDecisionEngine: Readonly<{
9
- type: string;
10
+ type: "engine";
11
+ name: string;
12
+ title: string;
10
13
  kinds: "decision"[];
11
14
  apiVersion: 1;
12
15
  description: string;
@@ -14,8 +17,16 @@ export declare const StubDecisionEngine: Readonly<{
14
17
  calls: number;
15
18
  };
16
19
  }>;
20
+ /** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
21
+ export declare const STUB_GENERATE_REQUESTS_KEPT = 20;
22
+ /** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
23
+ export declare const stubGenerateRequests: Array<Omit<GenerateRequest, 'signal' | 'onProgress'> & {
24
+ engine: string;
25
+ }>;
17
26
  export declare const StubAgentEngine: Readonly<{
18
- type: string;
27
+ type: "engine";
28
+ name: string;
29
+ title: string;
19
30
  kinds: "agent"[];
20
31
  apiVersion: 1;
21
32
  description: string;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.StubAgentEngine = exports.StubDecisionEngine = void 0;
3
+ exports.StubAgentEngine = exports.stubGenerateRequests = exports.STUB_GENERATE_REQUESTS_KEPT = exports.StubDecisionEngine = void 0;
4
4
  const uag_sdk_1 = require("@formio/uag-sdk");
5
5
  const formflow_1 = require("../formflow");
6
6
  /**
@@ -11,16 +11,29 @@ const formflow_1 = require("../formflow");
11
11
  * any instance name.
12
12
  */
13
13
  exports.StubDecisionEngine = (0, uag_sdk_1.defineEngine)({
14
- type: 'stub-decision',
14
+ type: 'engine',
15
+ name: 'stub-decision',
16
+ title: 'Stub decision engine',
15
17
  kinds: ['decision'],
16
18
  apiVersion: uag_sdk_1.ENGINE_API_VERSION,
17
19
  description: 'Answers every decision with a fixed, predictable value. For tests and local development.',
18
20
  create: ({ name }) => (0, formflow_1.stubDecisionEngine)({ name }),
19
21
  });
22
+ /** How many generate requests the stub agent engines keep: a UAG running them for a demo never grows without bound. */
23
+ exports.STUB_GENERATE_REQUESTS_KEPT = 20;
24
+ /** The last generate requests the stub agent engines received (STUB_GENERATE_REQUESTS_KEPT), oldest first, for tests. */
25
+ exports.stubGenerateRequests = [];
20
26
  exports.StubAgentEngine = (0, uag_sdk_1.defineEngine)({
21
- type: 'stub-agent',
27
+ type: 'engine',
28
+ name: 'stub-agent',
29
+ title: 'Stub agent engine',
22
30
  kinds: ['agent'],
23
31
  apiVersion: uag_sdk_1.ENGINE_API_VERSION,
24
- description: 'Extracts nothing. For tests and local development.',
25
- create: ({ name }) => (0, formflow_1.stubAgentEngine)({ name }),
32
+ description: 'Extracts nothing; generates a predictable answer from the text it is given. For tests and local development.',
33
+ // Text only; its generate answer shows what reached it. Observing its requests keeps it a built-in stub.
34
+ create: ({ name }) => (0, formflow_1.stubAgentEngine)({ name, onGenerate: (request) => {
35
+ exports.stubGenerateRequests.push({ ...request, engine: name });
36
+ if (exports.stubGenerateRequests.length > exports.STUB_GENERATE_REQUESTS_KEPT)
37
+ exports.stubGenerateRequests.splice(0, exports.stubGenerateRequests.length - exports.STUB_GENERATE_REQUESTS_KEPT);
38
+ } }),
26
39
  });
@@ -1,10 +1,31 @@
1
1
  import { FormflowTool } from "../formflow";
2
+ import type { AuthRequest } from "@formio/appserver";
2
3
  import type { UAGProjectInterface } from "../UAGProjectInterface";
3
- import { ToolInfo } from "../tools/utils";
4
- /** Every tool the UAG serves, including custom tools from configuration. */
4
+ import { ToolEntry, ToolInfo } from "../tools/utils";
5
+ /** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
6
+ export declare function setToolModule(entry: object, api: any): void;
7
+ /**
8
+ * A `config.tools` entry as a tool for the project: a bound tool's static
9
+ * fields, overlaid with what its `bind(project)` returns. A tool without
10
+ * `bind` (the 1.x shape) is used as it is.
11
+ */
12
+ export declare function resolveTool(project: UAGProjectInterface, entry: ToolEntry): Promise<ToolInfo | undefined>;
13
+ /**
14
+ * Every tool the UAG serves, including custom tools from configuration, each
15
+ * entry of which is a tool, optionally bound to the project.
16
+ */
5
17
  export declare function allTools(project: UAGProjectInterface): Promise<ToolInfo[]>;
18
+ /**
19
+ * Run a tool with its arguments and the `ToolExtra` for the project: the same
20
+ * call on `/mcp` and for Agent API research. `context` is whatever else the
21
+ * caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
22
+ */
23
+ export declare function runTool(tool: ToolInfo, project: UAGProjectInterface, args: any, context: {
24
+ authInfo: AuthRequest;
25
+ [key: string]: any;
26
+ }): any;
6
27
  /**
7
28
  * An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
8
29
  * validated with its own schema, and read-only only when annotated so.
9
30
  */
10
- export declare function formflowTool(tool: ToolInfo): FormflowTool;
31
+ export declare function formflowTool(tool: ToolInfo, project: UAGProjectInterface): FormflowTool;
@@ -3,19 +3,62 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.setToolModule = setToolModule;
7
+ exports.resolveTool = resolveTool;
6
8
  exports.allTools = allTools;
9
+ exports.runTool = runTool;
7
10
  exports.formflowTool = formflowTool;
8
11
  const zod_1 = __importDefault(require("zod"));
9
12
  const tools_1 = require("../tools");
10
- /** Every tool the UAG serves, including custom tools from configuration. */
13
+ const utils_1 = require("../tools/utils");
14
+ /** The module API of the module each `config.tools` entry came from, for its `extra.module`. */
15
+ const toolModules = new WeakMap();
16
+ /** Record the module a tool came from: its `execute` gets the module's API as `extra.module`. */
17
+ function setToolModule(entry, api) {
18
+ if (entry && typeof entry === 'object')
19
+ toolModules.set(entry, api);
20
+ }
21
+ /**
22
+ * A `config.tools` entry as a tool for the project: a bound tool's static
23
+ * fields, overlaid with what its `bind(project)` returns. A tool without
24
+ * `bind` (the 1.x shape) is used as it is.
25
+ */
26
+ async function resolveTool(project, entry) {
27
+ if (!entry || typeof entry.bind !== 'function')
28
+ return entry;
29
+ const { bind, ...fixed } = entry;
30
+ const tool = { ...fixed, ...((await bind(project)) || {}) };
31
+ if (toolModules.has(entry))
32
+ toolModules.set(tool, toolModules.get(entry));
33
+ return tool;
34
+ }
35
+ /**
36
+ * Every tool the UAG serves, including custom tools from configuration, each
37
+ * entry of which is a tool, optionally bound to the project.
38
+ */
11
39
  async function allTools(project) {
12
- return [...await (0, tools_1.getTools)(project), ...(project.config?.tools || [])].filter((tool) => !!tool?.name);
40
+ (0, utils_1.assertToolEntries)(project.config?.tools);
41
+ const custom = await Promise.all((project.config?.tools || []).map((entry) => resolveTool(project, entry)));
42
+ return [...await (0, tools_1.getTools)(project), ...custom].filter((tool) => !!tool?.name);
43
+ }
44
+ /**
45
+ * Run a tool with its arguments and the `ToolExtra` for the project: the same
46
+ * call on `/mcp` and for Agent API research. `context` is whatever else the
47
+ * caller has (on `/mcp`, the MCP SDK's request context, with `authInfo`).
48
+ */
49
+ function runTool(tool, project, args, context) {
50
+ if (!tool.execute) {
51
+ return { content: [{ type: 'text', text: `The tool "${tool.name}" has no implementation.` }], isError: true };
52
+ }
53
+ const module = toolModules.get(tool);
54
+ const extra = { ...context, authInfo: context.authInfo, project, ...(module && { module }) };
55
+ return tool.execute(args ?? {}, extra);
13
56
  }
14
57
  /**
15
58
  * An MCP tool as an Agentic Formflow tool: its schema as JSON, its arguments
16
59
  * validated with its own schema, and read-only only when annotated so.
17
60
  */
18
- function formflowTool(tool) {
61
+ function formflowTool(tool, project) {
19
62
  const schema = zod_1.default.object(tool.inputSchema || {});
20
63
  const inputSchema = zod_1.default.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' });
21
64
  delete inputSchema.$schema;
@@ -28,6 +71,6 @@ function formflowTool(tool) {
28
71
  const parsed = schema.safeParse(args || {});
29
72
  return parsed.success ? { ok: true, value: parsed.data } : { ok: false, error: parsed.error.message };
30
73
  },
31
- execute: async (args, auth) => tool.execute(args, { authInfo: auth })
74
+ execute: async (args, auth) => runTool(tool, project, args, { authInfo: auth })
32
75
  };
33
76
  }
package/lib/config.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ServerConfig } from '@formio/appserver';
2
2
  import type { FormflowAgentApiConfig, FormflowRateLimit, FormflowRoutingConfig } from './formflow';
3
3
  import type { EngineInstanceSpec, EngineTypeDefinition } from '@formio/uag-sdk';
4
- import { ToolInfo } from './tools/utils';
4
+ import type { ToolEntry, ToolInfo } from './tools/utils';
5
5
  import type { UAGDatabaseOptions } from './database';
6
6
  export type UAGForm = {
7
7
  name: string;
@@ -49,6 +49,30 @@ export type UAGToolOverride = {
49
49
  agent_provide?: ToolInfo;
50
50
  agent_decide?: ToolInfo;
51
51
  fetch_external_data?: ToolInfo;
52
+ read_document?: ToolInfo;
53
+ };
54
+ /** `config.documents`: limits and choices of the AI Document module (@formio/uag-extras/modules/ai-document). */
55
+ export type UAGDocumentsConfig = {
56
+ /** Largest uploaded file read, in bytes (25 MB). DOCUMENTS_MAX_FILE_BYTES. */
57
+ maxFileBytes?: number;
58
+ /** Most files one component sends (10). */
59
+ maxFiles?: number;
60
+ /** Most bytes of one request's files (32 MB, the provider request cap). */
61
+ maxTotalBytes?: number;
62
+ /** Most pages once converted (500). DOCUMENTS_MAX_PAGES. */
63
+ maxPages?: number;
64
+ /** Most characters of text once converted (2,000,000). */
65
+ maxTextChars?: number;
66
+ /** One processing request, in ms (600000). DOCUMENTS_TIMEOUT_MS. */
67
+ timeoutMs?: number;
68
+ /** Comment line interval on a streamed response, in ms (15000). */
69
+ heartbeatMs?: number;
70
+ /** Concurrent processing requests per identity (3). */
71
+ perUser?: number;
72
+ /** The engine instance documents use. Default: the first capable agent engine. DOCUMENTS_ENGINE. */
73
+ engine?: string;
74
+ /** Converter name by MIME type, when several can do a step. */
75
+ prefer?: Record<string, string>;
52
76
  };
53
77
  /**
54
78
  * One engine instance: `"<type>"` or `"<name>:<type>"`, or `{ name, type, options }`, where the
@@ -85,6 +109,8 @@ export interface UAGChatConfig {
85
109
  export type UAGAgentApiConfig = FormflowAgentApiConfig & {
86
110
  anonymous?: UAGAnonymousConfig;
87
111
  chat?: UAGChatConfig;
112
+ /** The largest request body an agent operation takes, in bytes (AGENT_BODY_LIMIT). Default 5 MB. */
113
+ bodyLimit?: number;
88
114
  };
89
115
  /**
90
116
  * Express's `trust proxy` from TRUST_PROXY: `true` or `false`, a number of
@@ -98,8 +124,11 @@ export interface UAGConfig extends ServerConfig {
98
124
  baseUrl?: string;
99
125
  loginForm?: string;
100
126
  responseTemplates?: UAGTemplateConfig;
127
+ /** Limits and choices of the AI Document module. */
128
+ documents?: UAGDocumentsConfig;
101
129
  toolOverrides?: UAGToolOverride;
102
- tools?: ToolInfo[];
130
+ /** Custom MCP tools: each a tool, optionally bound to the project with `bind(project)` (see ToolInfo.bind). */
131
+ tools?: ToolEntry[];
103
132
  /**
104
133
  * The engine instances to enable, as in `UAG_ENGINES` (`"jev"`, `"fast:claude"`) or as
105
134
  * `{ name, type, options }`. When unset, `UAG_ENGINES` is used; when neither names any, no
@@ -0,0 +1,38 @@
1
+ import type { PluginContext } from '@formio/appserver';
2
+ import type { Converter, ConverterOutput, Representation } from './types';
3
+ export declare const CONVERTER_DEFAULTS: {
4
+ maxBytes: number;
5
+ timeoutMs: number;
6
+ concurrency: number;
7
+ };
8
+ /** A conversion that did not complete. The message names the converter and the file, never its content. */
9
+ export declare class ConversionError extends Error {
10
+ constructor(message: string);
11
+ }
12
+ export type ConvertOptions = {
13
+ to: Representation;
14
+ settings?: Record<string, any>;
15
+ signal?: AbortSignal;
16
+ log: PluginContext['log'];
17
+ };
18
+ /**
19
+ * Run one conversion: under the converter's size limit, in a temporary
20
+ * directory made for the call and removed after it, aborted at its timeout,
21
+ * and queued behind its other conversions beyond its concurrency.
22
+ */
23
+ export declare function runConverter(converter: Converter, input: {
24
+ buffer: Buffer;
25
+ type: string;
26
+ name: string;
27
+ }, options: ConvertOptions): Promise<ConverterOutput>;
28
+ /**
29
+ * Run a route: each step's output feeds the next (an intermediate step always
30
+ * makes a PDF), with each converter's operator settings.
31
+ */
32
+ export declare function runRoute(steps: Converter[], input: {
33
+ buffer: Buffer;
34
+ type: string;
35
+ name: string;
36
+ }, to: Representation, options: Omit<ConvertOptions, 'to' | 'settings'> & {
37
+ settingsOf?: (converter: Converter) => Record<string, any> | undefined;
38
+ }): Promise<ConverterOutput>;
@@ -0,0 +1,151 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ConversionError = exports.CONVERTER_DEFAULTS = void 0;
7
+ exports.runConverter = runConverter;
8
+ exports.runRoute = runRoute;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const os_1 = __importDefault(require("os"));
11
+ const path_1 = __importDefault(require("path"));
12
+ exports.CONVERTER_DEFAULTS = { maxBytes: 25 * 1024 * 1024, timeoutMs: 120000, concurrency: 2 };
13
+ /** A conversion that did not complete. The message names the converter and the file, never its content. */
14
+ class ConversionError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = 'ConversionError';
18
+ }
19
+ }
20
+ exports.ConversionError = ConversionError;
21
+ /**
22
+ * A queue per converter, so at most `concurrency` of its conversions run at
23
+ * once. A slot is held until its conversion has actually stopped: a converter
24
+ * that ignores its signal still counts after its caller has given up on it.
25
+ */
26
+ class Queue {
27
+ constructor(limit) {
28
+ this.limit = limit;
29
+ this.running = 0;
30
+ this.waiting = [];
31
+ }
32
+ /** Wait for a slot; the returned function frees it (once). */
33
+ async acquire(signal) {
34
+ signal?.throwIfAborted();
35
+ if (this.running < this.limit) {
36
+ this.running++;
37
+ }
38
+ else {
39
+ // A freed slot passes straight to the next waiter, so no new caller can take it in between.
40
+ await new Promise((resolve, reject) => {
41
+ const go = () => { signal?.removeEventListener('abort', cancel); resolve(); };
42
+ const cancel = () => { this.waiting = this.waiting.filter((entry) => entry !== go); reject(signal.reason ?? new Error('aborted')); };
43
+ this.waiting.push(go);
44
+ signal?.addEventListener('abort', cancel, { once: true });
45
+ });
46
+ }
47
+ let released = false;
48
+ return () => {
49
+ if (released)
50
+ return;
51
+ released = true;
52
+ const next = this.waiting.shift();
53
+ if (next)
54
+ next();
55
+ else
56
+ this.running--;
57
+ };
58
+ }
59
+ }
60
+ const queues = new WeakMap();
61
+ /**
62
+ * Run one conversion: under the converter's size limit, in a temporary
63
+ * directory made for the call and removed after it, aborted at its timeout,
64
+ * and queued behind its other conversions beyond its concurrency.
65
+ */
66
+ async function runConverter(converter, input, options) {
67
+ const limits = { ...exports.CONVERTER_DEFAULTS, ...(converter.limits || {}) };
68
+ if (input.buffer.length > limits.maxBytes) {
69
+ throw new ConversionError(`"${input.name}" is larger than the ${converter.name} converter takes (${limits.maxBytes} bytes).`);
70
+ }
71
+ let queue = queues.get(converter);
72
+ if (!queue) {
73
+ queue = new Queue(Math.max(1, limits.concurrency));
74
+ queues.set(converter, queue);
75
+ }
76
+ const release = await queue.acquire(options.signal);
77
+ let tmpdir;
78
+ try {
79
+ options.signal?.throwIfAborted();
80
+ tmpdir = await fs_1.default.promises.mkdtemp(path_1.default.join(os_1.default.tmpdir(), 'uag-convert-'));
81
+ }
82
+ catch (err) {
83
+ release();
84
+ throw err;
85
+ }
86
+ const controller = new AbortController();
87
+ const abort = () => controller.abort(options.signal?.reason);
88
+ options.signal?.addEventListener('abort', abort, { once: true });
89
+ if (options.signal?.aborted)
90
+ abort();
91
+ const work = Promise.resolve().then(() => converter.convert(input, { to: options.to, settings: options.settings || {}, signal: controller.signal, tmpdir, log: options.log }));
92
+ // The slot and the temporary directory are kept until convert() settles, even after a timeout or an abort.
93
+ const stopped = work.then(() => undefined, () => undefined).then(async () => {
94
+ await fs_1.default.promises.rm(tmpdir, { recursive: true, force: true }).catch(() => undefined);
95
+ release();
96
+ });
97
+ let timer;
98
+ let finished = false;
99
+ try {
100
+ const timeout = new Promise((_, reject) => {
101
+ timer = setTimeout(() => {
102
+ const error = new ConversionError(`Converting "${input.name}" with ${converter.name} took longer than ${limits.timeoutMs} ms.`);
103
+ // Abort first: a converter running a process stops it on its signal.
104
+ controller.abort(error);
105
+ reject(error);
106
+ }, limits.timeoutMs);
107
+ });
108
+ const aborted = new Promise((_, reject) => {
109
+ if (controller.signal.aborted)
110
+ reject(controller.signal.reason);
111
+ controller.signal.addEventListener('abort', () => reject(controller.signal.reason), { once: true });
112
+ });
113
+ const output = await Promise.race([work.finally(() => { finished = true; }), timeout, aborted]);
114
+ return checkOutput(converter, input.name, options.to, output);
115
+ }
116
+ finally {
117
+ clearTimeout(timer);
118
+ options.signal?.removeEventListener('abort', abort);
119
+ // A conversion that completed has its directory removed before its result is used.
120
+ if (finished)
121
+ await stopped;
122
+ }
123
+ }
124
+ function checkOutput(converter, name, to, output) {
125
+ if (to === 'pdf') {
126
+ if (!output || !Buffer.isBuffer(output.pdf))
127
+ throw new ConversionError(`The ${converter.name} converter did not produce a PDF for "${name}".`);
128
+ return output;
129
+ }
130
+ if (!output || typeof output.text !== 'string' || !['plain', 'markdown', 'csv'].includes(output.textFormat)) {
131
+ throw new ConversionError(`The ${converter.name} converter did not produce text for "${name}".`);
132
+ }
133
+ return output;
134
+ }
135
+ /**
136
+ * Run a route: each step's output feeds the next (an intermediate step always
137
+ * makes a PDF), with each converter's operator settings.
138
+ */
139
+ async function runRoute(steps, input, to, options) {
140
+ let current = input;
141
+ let output;
142
+ for (let i = 0; i < steps.length; i++) {
143
+ const target = i === steps.length - 1 ? to : 'pdf';
144
+ output = await runConverter(steps[i], current, { ...options, to: target, settings: options.settingsOf?.(steps[i]) });
145
+ if (target === 'pdf')
146
+ current = { buffer: output.pdf, type: 'application/pdf', name: input.name };
147
+ }
148
+ if (!output)
149
+ throw new ConversionError(`No converter step for "${input.name}".`);
150
+ return output;
151
+ }
@@ -0,0 +1,28 @@
1
+ import type { Converter } from './types';
2
+ /** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
3
+ export declare function loadUnpdf(): Promise<typeof import('unpdf')>;
4
+ /** pdf-text: a PDF's text layer, page by page, with unpdf. */
5
+ export declare const pdfTextConverter: Converter;
6
+ /** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
7
+ export declare function decodeText(buffer: Buffer): string;
8
+ /** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
9
+ export declare const textConverter: Converter;
10
+ /**
11
+ * The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
12
+ * decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
13
+ * count is checked from its header first: a small, highly compressed file can
14
+ * describe an image far larger than its size suggests. JPEGs are embedded
15
+ * without being decoded. The converter's `maxPixels` setting changes it
16
+ * (config.plugins.settings.converter['image-pdf'].maxPixels, or
17
+ * DOCUMENTS_MAX_IMAGE_PIXELS).
18
+ */
19
+ export declare const MAX_IMAGE_PIXELS = 50000000;
20
+ /** A PNG's width and height, from its IHDR header; undefined when it has none. */
21
+ export declare function pngSize(buffer: Buffer): {
22
+ width: number;
23
+ height: number;
24
+ } | undefined;
25
+ /** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
26
+ export declare const imagePdfConverter: Converter;
27
+ /** The converters every UAG has. None needs a system binary. */
28
+ export declare const BUILT_IN_CONVERTERS: Converter[];
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BUILT_IN_CONVERTERS = exports.imagePdfConverter = exports.MAX_IMAGE_PIXELS = exports.textConverter = exports.pdfTextConverter = void 0;
4
+ exports.loadUnpdf = loadUnpdf;
5
+ exports.decodeText = decodeText;
6
+ exports.pngSize = pngSize;
7
+ const node_html_markdown_1 = require("node-html-markdown");
8
+ const mime_1 = require("./mime");
9
+ const convert_1 = require("./convert");
10
+ const version_1 = require("../version");
11
+ /**
12
+ * A real dynamic import: TypeScript compiles `import()` in a CommonJS build to
13
+ * `require()`, which cannot load an ES module before Node 22.12.
14
+ */
15
+ const esmImport = new Function('specifier', 'return import(specifier)');
16
+ /** unpdf, loaded on first use: it is an ES module, and carries a PDF.js build. */
17
+ function loadUnpdf() {
18
+ return esmImport('unpdf');
19
+ }
20
+ /** pdf-text: a PDF's text layer, page by page, with unpdf. */
21
+ exports.pdfTextConverter = {
22
+ type: 'converter',
23
+ name: 'pdf-text',
24
+ title: 'PDF text',
25
+ description: 'The text layer of a PDF, page by page. A PDF without one (a scan) yields empty text and a warning.',
26
+ version: version_1.UAG_VERSION,
27
+ apiVersion: 1,
28
+ accepts: [{ type: mime_1.MIME.pdf, ext: ['.pdf'] }],
29
+ produces: ['text'],
30
+ async convert(input, options) {
31
+ options.signal.throwIfAborted();
32
+ const { extractText, getDocumentProxy } = await loadUnpdf();
33
+ const document = await getDocumentProxy(new Uint8Array(input.buffer));
34
+ const { totalPages, text } = await extractText(document, { mergePages: false });
35
+ const pages = text.map((pageText, i) => ({ page: i + 1, text: String(pageText || '').trim() }));
36
+ const all = pages.map((page) => page.text).join('\n\n').trim();
37
+ return {
38
+ text: all,
39
+ textFormat: 'plain',
40
+ pages,
41
+ ...(!all && { warnings: [`"${input.name}" has no text layer (${totalPages} page${totalPages === 1 ? '' : 's'}): it may be a scan.`] })
42
+ };
43
+ }
44
+ };
45
+ /** Decode text: UTF-16 with a byte order mark, else UTF-8 (without its BOM), else Windows-1252 (a superset of Latin-1). */
46
+ function decodeText(buffer) {
47
+ if (buffer[0] === 0xff && buffer[1] === 0xfe)
48
+ return new TextDecoder('utf-16le').decode(buffer.subarray(2));
49
+ if (buffer[0] === 0xfe && buffer[1] === 0xff)
50
+ return new TextDecoder('utf-16be').decode(buffer.subarray(2));
51
+ try {
52
+ return new TextDecoder('utf-8', { fatal: true }).decode(buffer).replace(/^\uFEFF/, '');
53
+ }
54
+ catch {
55
+ return new TextDecoder('windows-1252').decode(buffer);
56
+ }
57
+ }
58
+ const TEXT_FORMATS = {
59
+ [mime_1.MIME.markdown]: 'markdown',
60
+ [mime_1.MIME.html]: 'markdown',
61
+ [mime_1.MIME.csv]: 'csv'
62
+ };
63
+ /** text: plain text, Markdown, CSV, JSON, HTML (as Markdown) and XML. */
64
+ exports.textConverter = {
65
+ type: 'converter',
66
+ name: 'text',
67
+ title: 'Text',
68
+ description: 'Text files as they are; HTML as Markdown.',
69
+ version: version_1.UAG_VERSION,
70
+ apiVersion: 1,
71
+ accepts: [
72
+ { type: mime_1.MIME.text, ext: ['.txt'] },
73
+ { type: mime_1.MIME.markdown, ext: ['.md', '.markdown'] },
74
+ { type: mime_1.MIME.csv, ext: ['.csv'] },
75
+ { type: mime_1.MIME.json, ext: ['.json'] },
76
+ { type: mime_1.MIME.html, ext: ['.html', '.htm'] },
77
+ { type: mime_1.MIME.xml, ext: ['.xml'] }
78
+ ],
79
+ produces: ['text'],
80
+ async convert(input, options) {
81
+ options.signal.throwIfAborted();
82
+ const decoded = decodeText(input.buffer);
83
+ const text = input.type === mime_1.MIME.html ? node_html_markdown_1.NodeHtmlMarkdown.translate(decoded) : decoded;
84
+ return { text, textFormat: TEXT_FORMATS[input.type] || 'plain' };
85
+ }
86
+ };
87
+ /** A4 portrait, in points. */
88
+ const PAGE = { width: 595.28, height: 841.89, margin: 36 };
89
+ /**
90
+ * The most pixels of a PNG image-pdf takes (50 megapixels, about 200 MB once
91
+ * decoded). A PNG is decoded whole, in memory and synchronously, so its pixel
92
+ * count is checked from its header first: a small, highly compressed file can
93
+ * describe an image far larger than its size suggests. JPEGs are embedded
94
+ * without being decoded. The converter's `maxPixels` setting changes it
95
+ * (config.plugins.settings.converter['image-pdf'].maxPixels, or
96
+ * DOCUMENTS_MAX_IMAGE_PIXELS).
97
+ */
98
+ exports.MAX_IMAGE_PIXELS = 50000000;
99
+ /** A PNG's width and height, from its IHDR header; undefined when it has none. */
100
+ function pngSize(buffer) {
101
+ if (buffer.length < 24 || buffer.toString('latin1', 12, 16) !== 'IHDR')
102
+ return undefined;
103
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
104
+ }
105
+ /** image-pdf: PNG and JPEG images into a PDF, one page per image, scaled to fit and never resampled. */
106
+ exports.imagePdfConverter = {
107
+ type: 'converter',
108
+ name: 'image-pdf',
109
+ title: 'Image to PDF',
110
+ description: 'A PNG or JPEG image as a one-page PDF, scaled to fit the page.',
111
+ version: version_1.UAG_VERSION,
112
+ apiVersion: 1,
113
+ accepts: [{ type: mime_1.MIME.png, ext: ['.png'] }, { type: mime_1.MIME.jpeg, ext: ['.jpg', '.jpeg'] }],
114
+ produces: ['pdf'],
115
+ settings: {
116
+ maxPixels: {
117
+ type: 'number',
118
+ env: 'DOCUMENTS_MAX_IMAGE_PIXELS',
119
+ default: exports.MAX_IMAGE_PIXELS,
120
+ description: 'The largest PNG, in pixels (width × height), converted; a larger one is refused before it is decoded.'
121
+ }
122
+ },
123
+ async convert(input, options) {
124
+ options.signal.throwIfAborted();
125
+ if (input.type === mime_1.MIME.png) {
126
+ const size = pngSize(input.buffer);
127
+ if (!size)
128
+ throw new convert_1.ConversionError(`"${input.name}" is not a PNG image the image-pdf converter can read.`);
129
+ const maxPixels = Number(options.settings?.maxPixels) > 0 ? Number(options.settings.maxPixels) : exports.MAX_IMAGE_PIXELS;
130
+ if (size.width * size.height > maxPixels) {
131
+ throw new convert_1.ConversionError(`"${input.name}" is ${size.width}×${size.height} pixels, more than the image-pdf converter takes (${maxPixels} pixels).`);
132
+ }
133
+ }
134
+ const { PDFDocument } = require('pdf-lib');
135
+ const pdf = await PDFDocument.create();
136
+ const image = input.type === mime_1.MIME.png ? await pdf.embedPng(input.buffer) : await pdf.embedJpg(input.buffer);
137
+ const landscape = image.width > image.height;
138
+ const width = landscape ? PAGE.height : PAGE.width;
139
+ const height = landscape ? PAGE.width : PAGE.height;
140
+ const scale = Math.min(1, (width - 2 * PAGE.margin) / image.width, (height - 2 * PAGE.margin) / image.height);
141
+ const page = pdf.addPage([width, height]);
142
+ const drawn = { width: image.width * scale, height: image.height * scale };
143
+ // The image is embedded as it is and only drawn smaller: its pixels are never resampled.
144
+ page.drawImage(image, { x: (width - drawn.width) / 2, y: (height - drawn.height) / 2, ...drawn });
145
+ return { pdf: Buffer.from(await pdf.save()), pages: 1 };
146
+ }
147
+ };
148
+ /** The converters every UAG has. None needs a system binary. */
149
+ exports.BUILT_IN_CONVERTERS = [exports.pdfTextConverter, exports.textConverter, exports.imagePdfConverter];
@@ -0,0 +1,31 @@
1
+ import type { Converter } from './types';
2
+ /** A file refused for what its bytes are. The message names the file, never its content. */
3
+ export declare class DetectionError extends Error {
4
+ constructor(message: string);
5
+ }
6
+ /**
7
+ * The names in a ZIP archive's central directory, read from the end of the
8
+ * buffer only: no entry is ever inflated. Undefined when it is not a readable
9
+ * ZIP archive.
10
+ */
11
+ export declare function zipEntryNames(buffer: Buffer): string[] | undefined;
12
+ /** Whether a buffer is valid UTF-8 (or UTF-16 with a byte order mark) without NUL bytes. */
13
+ export declare function isText(buffer: Buffer): boolean;
14
+ /**
15
+ * Whether a buffer is text in a legacy 8-bit encoding (Latin-1, Windows-1252):
16
+ * no NUL bytes and no control characters other than tab, line breaks, form feed
17
+ * and the DOS end-of-file mark. Spreadsheet and editor exports still write these.
18
+ */
19
+ export declare function isLegacyText(buffer: Buffer): boolean;
20
+ /**
21
+ * Identify a file's type from its bytes. The name and the client's MIME type
22
+ * are hints only: known signatures first, then each converter's `detect`, then
23
+ * text (typed by extension). A detected type that contradicts the family the
24
+ * extension promises is refused.
25
+ */
26
+ export declare function identify(buffer: Buffer, hint: {
27
+ name: string;
28
+ type?: string;
29
+ }, converters?: Converter[]): string;
30
+ /** The extensions known for a type, from the converters that accept it and the usual ones. */
31
+ export declare function extensionsOf(type: string, converters?: Converter[]): string[];