@assembly-js/app-foundation 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,9 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.5.0
23
+ - [new] `detectPdfFields` from `@assembly-js/app-foundation/server` finds the fields on a PDF form: AcroForm fields with their page, kind and options; an XFA form reported as such; or, for a PDF with only text, fields suggested by Assembly AI when an Assembly client is passed, with the page text returned either way. A file that is not a PDF throws `PdfUnreadableError`.
24
+
22
25
  ## 0.4.3
23
26
  - [guidance] The build agent now knows `viewerClient` works for cross-app calls made by a client contact, and keeps `installerClient` for team members, cron, webhooks and background work.
24
27
 
@@ -30,7 +30,7 @@ export const NON_CLIENT_EXPORTS = new Set([
30
30
  'InternalUserAccess', 'LEGACY_APP_IDS', 'LocationReporter', 'MintFailure', 'MintOutcome', 'MintSessionOptions', 'MintedAppBearer',
31
31
  'MintedBearer', 'NETWORK_CAPTURE_MESSAGE_TYPE', 'NetworkCaptureHeaderFingerprint',
32
32
  'NetworkCaptureRecord', 'NotifyInternalUsersInput', 'PAINTED_BEACON_EVENT', 'PAINTED_BEACON_PATH',
33
- 'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'PrincipalAllow', 'PrincipalKind',
33
+ 'PORTAL_ORIGIN_COOKIE_NAME', 'PdfText', 'DetectedPdfField', 'PdfFieldDetection', 'PdfFieldDetectionMethod', 'PdfFieldKind', 'PdfUnreadableError', 'PrincipalAllow', 'PrincipalKind',
34
34
  'Provider', 'ProxyOpts', 'RENEW_OUTCOME_EVENT', 'RenewOutcome', 'ReplaceChildRowsResult',
35
35
  'RequestSource', 'ResolveResourceOpts', 'ResourceError', 'ResourceFieldMap',
36
36
  'ResourceOption', 'ResourcePage', 'ScheduleCadence', 'SlackChannel',
@@ -42,7 +42,7 @@ export const NON_CLIENT_EXPORTS = new Set([
42
42
  'clearClientNotifications', 'collectHeaders', 'collectXhrResponseHeaders',
43
43
  'cookielessReexchangeHeaders', 'cookiesAreBlocked', 'createAssemblyProxy',
44
44
  'createAssemblyReexchangeHandler', 'cronRoute',
45
- 'decodeBearer', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
45
+ 'decodeBearer', 'detectPdfFields', 'expandClientsByCompany', 'extractPdfText', 'fetchSessionFlags',
46
46
  'findResource', 'formatAirtableRecords', 'generateAppClient', 'getAirtableTableSchema', 'getCapturedRecords',
47
47
  'getClientAccessScope', 'getCookielessBearer', 'getDownloadUrl', 'getInstalledAppIds', 'getIntegration',
48
48
  'getInternalUserAccess', 'getLabels', 'getSessionIdentity', 'getUploadUrl',
package/dist/pdf.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { AssemblyAPI } from '@assembly-js/node-sdk';
1
2
  export interface PdfText {
2
3
  /** Every extracted page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
3
4
  text: string;
@@ -9,3 +10,42 @@ export interface PdfText {
9
10
  export declare function extractPdfText(data: ArrayBuffer | Uint8Array, opts?: {
10
11
  maxPages?: number;
11
12
  }): Promise<PdfText>;
13
+ export type PdfFieldDetectionMethod = 'acroform' | 'text' | 'xfa' | 'none';
14
+ export type PdfFieldKind = 'text' | 'multiline' | 'checkbox' | 'radio' | 'dropdown' | 'date' | 'signature' | 'unknown';
15
+ export interface DetectedPdfField {
16
+ /** AcroForm field name, or a slug derived from the label for text detection. */
17
+ name: string;
18
+ /** Human label: the AcroForm name humanized, or the label the AI read. */
19
+ label: string;
20
+ kind: PdfFieldKind;
21
+ /** 1-based page number, null when it could not be resolved. */
22
+ page: number | null;
23
+ /** Radio and dropdown choices. */
24
+ options?: string[];
25
+ /** The AcroForm required flag, when the field sets it. */
26
+ required?: boolean;
27
+ source: 'acroform' | 'text';
28
+ }
29
+ export interface PdfFieldDetection {
30
+ method: PdfFieldDetectionMethod;
31
+ fields: DetectedPdfField[];
32
+ pages: number;
33
+ /** The page text when method is 'text' or 'none', so the app can run its own step. */
34
+ text?: string;
35
+ warnings: string[];
36
+ }
37
+ /** The bytes could not be parsed as a PDF; the message is safe to show to the user. */
38
+ export declare class PdfUnreadableError extends Error {
39
+ constructor(options?: {
40
+ cause?: unknown;
41
+ });
42
+ }
43
+ /**
44
+ * Finds the fields a person has to fill in on a PDF form: its AcroForm fields when
45
+ * it has them, otherwise fields read from its text by Assembly AI when `assembly`
46
+ * is given. Throws PdfUnreadableError when the bytes are not a readable PDF.
47
+ * Server side only: the AI step runs through the app's Assembly client.
48
+ */
49
+ export declare function detectPdfFields(data: ArrayBuffer | Uint8Array, options?: {
50
+ assembly?: AssemblyAPI;
51
+ }): Promise<PdfFieldDetection>;
package/dist/pdf.js CHANGED
@@ -1,15 +1,12 @@
1
- // unpdf ships a self-contained serverless build of pdf.js: the worker is inlined
2
- // and it never loads @napi-rs/canvas. pdf-parse and pdfjs-dist locate both at
3
- // runtime by paths computed from their own location, which the deploy tracer
4
- // can't follow, so they pass in the sandbox and 500 once published. Loaded
5
- // lazily so routes that never parse a PDF don't carry the bundle.
6
- export async function extractPdfText(data, opts) {
7
- const { getDocumentProxy, extractText } = await import('unpdf');
8
- // pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
9
- const bytes = data instanceof ArrayBuffer
1
+ // pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
2
+ function toBytes(data) {
3
+ return data instanceof ArrayBuffer
10
4
  ? new Uint8Array(data)
11
5
  : new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
12
- const pdf = await getDocumentProxy(bytes);
6
+ }
7
+ export async function extractPdfText(data, opts) {
8
+ const { getDocumentProxy, extractText } = await import('unpdf');
9
+ const pdf = await getDocumentProxy(toBytes(data));
13
10
  try {
14
11
  const total = pdf.numPages;
15
12
  const maxPages = opts?.maxPages;
@@ -41,3 +38,268 @@ export async function extractPdfText(data, opts) {
41
38
  await pdf.loadingTask.destroy();
42
39
  }
43
40
  }
41
+ /** The bytes could not be parsed as a PDF; the message is safe to show to the user. */
42
+ export class PdfUnreadableError extends Error {
43
+ constructor(options) {
44
+ super('This file could not be opened as a PDF. It may be damaged, or not a PDF at all.', options);
45
+ this.name = 'PdfUnreadableError';
46
+ }
47
+ }
48
+ const PDF_FIELD_DETECTION_MODEL = 'gpt-4o-mini';
49
+ // A form's labels fit well inside this; the cap keeps a long PDF within one request.
50
+ const PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE = 4000;
51
+ const PDF_FIELD_DETECTION_PROMPT = [
52
+ 'You read the text of a PDF form and list the fields a person would have to fill in.',
53
+ 'Return one entry per blank, checkbox, choice or signature line, in reading order.',
54
+ 'Use the label printed next to the blank. Do not invent fields that are not in the text.',
55
+ 'page is the number from the "--- Page N ---" marker the field appears under, or null if unclear.',
56
+ 'options lists the printed choices for radio and dropdown fields, and is empty otherwise.',
57
+ ].join('\n');
58
+ const AI_CHAT_COMPLETIONS_PATH = '/v1/ai/openai/chat/completions';
59
+ const PDF_FIELD_KINDS = [
60
+ 'text',
61
+ 'multiline',
62
+ 'checkbox',
63
+ 'radio',
64
+ 'dropdown',
65
+ 'date',
66
+ 'signature',
67
+ 'unknown',
68
+ ];
69
+ const AI_RESPONSE_FORMAT = {
70
+ type: 'json_schema',
71
+ json_schema: {
72
+ name: 'pdf_form_fields',
73
+ strict: true,
74
+ schema: {
75
+ type: 'object',
76
+ additionalProperties: false,
77
+ required: ['fields'],
78
+ properties: {
79
+ fields: {
80
+ type: 'array',
81
+ items: {
82
+ type: 'object',
83
+ additionalProperties: false,
84
+ required: ['label', 'kind', 'page', 'options'],
85
+ properties: {
86
+ label: { type: 'string' },
87
+ kind: { type: 'string', enum: PDF_FIELD_KINDS },
88
+ page: { type: ['integer', 'null'] },
89
+ options: { type: 'array', items: { type: 'string' } },
90
+ },
91
+ },
92
+ },
93
+ },
94
+ },
95
+ },
96
+ };
97
+ /**
98
+ * Finds the fields a person has to fill in on a PDF form: its AcroForm fields when
99
+ * it has them, otherwise fields read from its text by Assembly AI when `assembly`
100
+ * is given. Throws PdfUnreadableError when the bytes are not a readable PDF.
101
+ * Server side only: the AI step runs through the app's Assembly client.
102
+ */
103
+ export async function detectPdfFields(data, options) {
104
+ const bytes = toBytes(data);
105
+ const pdfLib = await import('pdf-lib');
106
+ let doc;
107
+ try {
108
+ doc = await pdfLib.PDFDocument.load(bytes, { ignoreEncryption: true });
109
+ }
110
+ catch (cause) {
111
+ throw new PdfUnreadableError({ cause });
112
+ }
113
+ const pages = doc.getPageCount();
114
+ // XFA forms look fillable in Acrobat but pdf-lib reads only AcroForm fields, so
115
+ // without this the app would silently report a fillable form as having none.
116
+ // Checked before getForm(), which strips the XFA entry from the catalog.
117
+ const hasXfa = doc.catalog.getAcroForm()?.dict.has(pdfLib.PDFName.of('XFA')) ?? false;
118
+ // pdf-lib cannot decrypt, so an encrypted form's field names and choices would come
119
+ // back as ciphertext; pdf.js can, so such a PDF goes on to text detection instead.
120
+ const warnings = doc.isEncrypted ? ['encrypted PDF: form fields read from its text instead'] : [];
121
+ // A Print or Submit button is not a field a person fills in, so a PDF whose only
122
+ // widgets are buttons still goes on to text detection.
123
+ const fields = doc.isEncrypted
124
+ ? []
125
+ : doc
126
+ .getForm()
127
+ .getFields()
128
+ .filter((field) => !(field instanceof pdfLib.PDFButton));
129
+ if (fields.length > 0)
130
+ return readAcroFormFields(doc, fields, pdfLib);
131
+ if (hasXfa)
132
+ return { method: 'xfa', fields: [], pages, warnings };
133
+ // pdf.js may detach the buffer it is handed, so it gets its own copy.
134
+ const pageTexts = await extractPageTexts(bytes.slice());
135
+ const text = pageTexts.join('\n');
136
+ if (text.trim() === '')
137
+ return { method: 'none', fields: [], pages, text, warnings };
138
+ if (!options?.assembly)
139
+ return { method: 'text', fields: [], pages, text, warnings };
140
+ const suggested = await suggestFieldsFromText(options.assembly, pageTexts);
141
+ return {
142
+ method: 'text',
143
+ fields: suggested.fields,
144
+ pages,
145
+ text,
146
+ warnings: [...warnings, ...suggested.warnings],
147
+ };
148
+ }
149
+ function readAcroFormFields(doc, fields, pdfLib) {
150
+ const pageByRef = new Map();
151
+ const pageByWidget = new Map();
152
+ const rotationByPage = new Map();
153
+ doc.getPages().forEach((page, index) => {
154
+ pageByRef.set(page.ref, index + 1);
155
+ rotationByPage.set(index + 1, ((page.getRotation().angle % 360) + 360) % 360);
156
+ // Some generators omit a widget's /P, so the page's Annots are the fallback.
157
+ const annots = page.node.Annots();
158
+ for (let i = 0; annots && i < annots.size(); i++) {
159
+ const annot = annots.lookup(i);
160
+ if (annot instanceof pdfLib.PDFDict)
161
+ pageByWidget.set(annot, index + 1);
162
+ }
163
+ });
164
+ const warnings = [];
165
+ const located = [];
166
+ for (const field of fields) {
167
+ const name = field.getName();
168
+ const widget = field.acroField.getWidgets()[0];
169
+ const pageRef = widget?.P();
170
+ const page = (pageRef && pageByRef.get(pageRef)) || (widget && pageByWidget.get(widget.dict)) || null;
171
+ if (page === null)
172
+ warnings.push(`page for field ${name} unknown`);
173
+ const rect = widget?.getRectangle() ?? { x: 0, y: 0 };
174
+ const detected = {
175
+ name,
176
+ label: humanizeFieldName(name),
177
+ kind: acroFieldKind(field, pdfLib),
178
+ page,
179
+ source: 'acroform',
180
+ };
181
+ if (field instanceof pdfLib.PDFRadioGroup ||
182
+ field instanceof pdfLib.PDFDropdown ||
183
+ field instanceof pdfLib.PDFOptionList) {
184
+ detected.options = field.getOptions();
185
+ }
186
+ if (field.isRequired())
187
+ detected.required = true;
188
+ located.push({ field: detected, ...readingPosition(rect, page ? rotationByPage.get(page) : 0) });
189
+ }
190
+ // Unknown pages go last.
191
+ located.sort((a, b) => (a.field.page ?? Infinity) - (b.field.page ?? Infinity) || a.down - b.down || a.across - b.across);
192
+ return { method: 'acroform', fields: located.map((l) => l.field), pages: doc.getPageCount(), warnings };
193
+ }
194
+ // Sort keys in the page as displayed: `down` grows top to bottom, `across` left to
195
+ // right. PDF y grows upward, and a page's /Rotate turns it clockwise for display,
196
+ // so on a landscape page rotated 90 degrees the visual top is the stored left edge.
197
+ function readingPosition(rect, rotation = 0) {
198
+ switch (rotation) {
199
+ case 90:
200
+ return { down: rect.x, across: rect.y };
201
+ case 180:
202
+ return { down: rect.y, across: -rect.x };
203
+ case 270:
204
+ return { down: -rect.x, across: -rect.y };
205
+ default:
206
+ return { down: -rect.y, across: rect.x };
207
+ }
208
+ }
209
+ function acroFieldKind(field, pdfLib) {
210
+ if (field instanceof pdfLib.PDFTextField)
211
+ return field.isMultiline() ? 'multiline' : 'text';
212
+ if (field instanceof pdfLib.PDFCheckBox)
213
+ return 'checkbox';
214
+ if (field instanceof pdfLib.PDFRadioGroup)
215
+ return 'radio';
216
+ if (field instanceof pdfLib.PDFDropdown || field instanceof pdfLib.PDFOptionList)
217
+ return 'dropdown';
218
+ if (field instanceof pdfLib.PDFSignature)
219
+ return 'signature';
220
+ return 'unknown';
221
+ }
222
+ // `form1[0].page1[0].full_name[0]` → `Full name`: the last segment is the field's
223
+ // own name, the rest is the authoring tool's container hierarchy.
224
+ function humanizeFieldName(name) {
225
+ const words = (name.split('.').pop() ?? '')
226
+ .replace(/\[\d+\]/g, '')
227
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
228
+ .replace(/[_\-\s]+/g, ' ')
229
+ .trim()
230
+ .toLowerCase();
231
+ return words ? words[0].toUpperCase() + words.slice(1) : name;
232
+ }
233
+ function slugify(label) {
234
+ return (label
235
+ .toLowerCase()
236
+ .replace(/[^a-z0-9]+/g, '_')
237
+ .replace(/^_+|_+$/g, '') || 'field');
238
+ }
239
+ async function extractPageTexts(bytes) {
240
+ const { getDocumentProxy, extractText } = await import('unpdf');
241
+ const pdf = await getDocumentProxy(bytes);
242
+ try {
243
+ const { text } = await extractText(pdf, { mergePages: false });
244
+ return text;
245
+ }
246
+ finally {
247
+ await pdf.loadingTask.destroy();
248
+ }
249
+ }
250
+ async function suggestFieldsFromText(assembly, pageTexts) {
251
+ const warnings = [];
252
+ const prompt = pageTexts
253
+ .map((pageText, index) => {
254
+ let body = pageText;
255
+ if (body.length > PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE) {
256
+ body = body.slice(0, PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE);
257
+ warnings.push(`page ${index + 1} text truncated to ${PDF_FIELD_DETECTION_MAX_CHARS_PER_PAGE} characters`);
258
+ }
259
+ return `--- Page ${index + 1} ---\n${body}`;
260
+ })
261
+ .join('\n\n');
262
+ let suggested;
263
+ try {
264
+ const response = await assembly.fetch(AI_CHAT_COMPLETIONS_PATH, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/json' },
267
+ body: JSON.stringify({
268
+ model: PDF_FIELD_DETECTION_MODEL,
269
+ messages: [
270
+ { role: 'system', content: PDF_FIELD_DETECTION_PROMPT },
271
+ { role: 'user', content: prompt },
272
+ ],
273
+ response_format: AI_RESPONSE_FORMAT,
274
+ stream: false,
275
+ }),
276
+ });
277
+ if (!response.ok)
278
+ throw new Error(String(response.status));
279
+ const completion = await response.json();
280
+ suggested = JSON.parse(completion.choices[0].message.content).fields;
281
+ if (!Array.isArray(suggested))
282
+ throw new Error('no fields in response');
283
+ }
284
+ catch (err) {
285
+ warnings.push(`AI suggestion failed: ${err instanceof Error ? err.message : String(err)}`);
286
+ return { fields: [], warnings };
287
+ }
288
+ const usedNames = new Set();
289
+ const fields = suggested.map((s) => {
290
+ const base = slugify(s.label);
291
+ let name = base;
292
+ for (let n = 2; usedNames.has(name); n++)
293
+ name = `${base}_${n}`;
294
+ usedNames.add(name);
295
+ const page = typeof s.page === 'number' && s.page >= 1 && s.page <= pageTexts.length ? s.page : null;
296
+ if (page === null)
297
+ warnings.push(`page for field ${name} unknown`);
298
+ const kind = PDF_FIELD_KINDS.includes(s.kind) ? s.kind : 'unknown';
299
+ const field = { name, label: s.label, kind, page, source: 'text' };
300
+ if ((kind === 'radio' || kind === 'dropdown') && s.options?.length)
301
+ field.options = s.options;
302
+ return field;
303
+ });
304
+ return { fields, warnings };
305
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -78,6 +78,7 @@
78
78
  "dependencies": {
79
79
  "openapi-fetch": "^0.17.0",
80
80
  "openapi-typescript": "^7.13.0",
81
+ "pdf-lib": "^1.17.1",
81
82
  "unpdf": "^1.9.1"
82
83
  }
83
84
  }