@assembly-js/app-foundation 0.4.3 → 0.5.1
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 +6 -0
- package/CLAUDE.md +2 -1
- package/bin/foundation-exports.mjs +2 -2
- package/dist/pdf.d.ts +40 -0
- package/dist/pdf.js +272 -10
- package/package.json +2 -1
- package/skills/assembly-storage/SKILL.md +20 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,12 @@ 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.1
|
|
23
|
+
- [guidance] The build agent now detects PDF form fields with `detectPdfFields` and gives XFA, text-only and empty PDFs their own UI instead of reporting "no fields".
|
|
24
|
+
|
|
25
|
+
## 0.5.0
|
|
26
|
+
- [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`.
|
|
27
|
+
|
|
22
28
|
## 0.4.3
|
|
23
29
|
- [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
30
|
|
package/CLAUDE.md
CHANGED
|
@@ -657,7 +657,7 @@ Server components, route handlers, and server actions run in **Node on Vercel**
|
|
|
657
657
|
|
|
658
658
|
- **Visual work** (draw a chart, render on a canvas) is **client** work — load the library with `next/dynamic` + `ssr: false`, never in server code.
|
|
659
659
|
- **Fuzzy extraction** (pull fields from a document, classify, summarize) is usually better done through the **AI proxy** (see `assembly-ai`) than a parsing lib — no native binding, no serverless hazard.
|
|
660
|
-
- **Genuine server-side data work** (transform an image, parse bytes) must use a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `app.config.ts` (bundling breaks the binding). A library that locates files at runtime — a worker script, a native binding, font data — by a path computed from its own location breaks after deploy: the bundler relocates its code and the deployed function only has the files reachable from literal imports, so the lookup fails even though preview has the full `node_modules`. Prefer a self-contained server build. **PDF text: `extractPdfText` from `@assembly-js/app-foundation/server`** — never `pdf-parse` or `pdfjs-dist` on the server; both look their worker and canvas up this way and fail on every deployed upload.
|
|
660
|
+
- **Genuine server-side data work** (transform an image, parse bytes) must use a Node-native library: add native/CommonJS packages (e.g. `sharp`) to `serverExternalPackages` in `app.config.ts` (bundling breaks the binding). A library that locates files at runtime — a worker script, a native binding, font data — by a path computed from its own location breaks after deploy: the bundler relocates its code and the deployed function only has the files reachable from literal imports, so the lookup fails even though preview has the full `node_modules`. Prefer a self-contained server build. **PDF text and form fields: `extractPdfText` and `detectPdfFields` from `@assembly-js/app-foundation/server`** — never `pdf-parse` or `pdfjs-dist` on the server; both look their worker and canvas up this way and fail on every deployed upload. Detect fields on the server, not with pdf-lib or pdfjs in the browser: the helper already covers XFA forms and text-only PDFs, which a browser-side AcroForm read reports as "no fields".
|
|
661
661
|
- **Browser-only packages** (touch `window`/`document`/`DOMMatrix`/`HTMLCanvasElement` at import *or during operations*) can't run server-side at all — `serverExternalPackages` doesn't help and dynamic `import()` only defers the crash. Use a Node-native alternative or move the work client-side.
|
|
662
662
|
|
|
663
663
|
---
|
|
@@ -1506,6 +1506,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1506
1506
|
- ❌ Don't edit `next.config.ts` — it's the platform-owned wrapper that keeps the preview working (cross-origin hydration, WebSocket-blocked networks), and `bun run build` fails if it's rewritten. Your Next configuration belongs in `app.config.ts`.
|
|
1507
1507
|
- ❌ Don't import a native or browser-only library into server code without the serverless setup — `bun run build` passes but the deployed route crashes at request time. Decide the layer first per "Server runs in serverless Node" above.
|
|
1508
1508
|
- ❌ Don't preview a PDF (or any file) in an `<iframe>`, `<embed>` or `<object>` — the portal frame is sandboxed and Chrome won't run its PDF viewer inside it, so the preview renders blank while the build passes. Open the pre-signed URL in a new tab (`assembly-storage` Pattern A) or render pages to a canvas client-side with `next/dynamic` + `ssr: false`.
|
|
1509
|
+
- ❌ Don't build a PDF-to-form feature on AcroForm fields alone. Call `detectPdfFields` and branch on `method`: `none` and `xfa` need a visible explanation plus a manual add-field path, and `text` fields are suggestions the user must be able to edit before saving. Plenty of real forms are Word exports, scans or XFA, so an AcroForm-only app tells those users their form is empty.
|
|
1509
1510
|
- ❌ Don't call a change verified, working, or safe to publish because `tsc --noEmit` and `bun run build` passed — name the checks that ran and the signed-in screens left unopened. See "What a passing build proves" above.
|
|
1510
1511
|
- ❌ Don't pass a function — or an object that holds one, like a question/field config with `visibleWhen`, `validate`, `render`, or `format` callbacks — as a prop from a server component or page into a `'use client'` component. Props cross that boundary as serialized data, so the render throws `Functions cannot be passed directly to Client Components` and the page 500s for every visitor no matter what the record holds. `tsc --noEmit` and `bun run build` both pass. Keep such a config in a shared module the client component imports itself, or express the logic as data (`{ showWhen: { field: 'entityType', equals: 'llc' } }`) and interpret it client-side.
|
|
1511
1512
|
- ❌ Don't call a function imported from a `'use client'` file from a server component, layout, or server action — e.g. an answer formatter defined in the client component file that the `/n` notification page also renders. The server gets a client reference, not the function, so the render throws and the route 500s for every visitor; `tsc --noEmit` passes. A `'use client'` file should export only components and hooks; put a shared helper in a plain module (`lib/…`) both sides import. `bun run build` fails on this (`check:client-boundary`).
|
|
@@ -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
|
-
//
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.5.1",
|
|
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
|
}
|
|
@@ -151,7 +151,9 @@ export async function POST(request: NextRequest) {
|
|
|
151
151
|
if (!res.ok) throw new Error('S3 fetch failed: ' + res.status);
|
|
152
152
|
const buffer = Buffer.from(await res.arrayBuffer());
|
|
153
153
|
// ...process the buffer here; the file is already stored, no second upload.
|
|
154
|
-
// PDF text: const { text } = await extractPdfText(buffer)
|
|
154
|
+
// PDF text: const { text } = await extractPdfText(buffer) (empty text = scanned PDF, no text layer).
|
|
155
|
+
// PDF form fields: const detection = await detectPdfFields(buffer, { assembly }) — see below.
|
|
156
|
+
// Both come from '@assembly-js/app-foundation/server'.
|
|
155
157
|
return NextResponse.json({ ok: true });
|
|
156
158
|
} catch (err) {
|
|
157
159
|
return handleRouteError(err, 'Processing failed');
|
|
@@ -163,6 +165,23 @@ The bytes still cross the network into your function (the cap is on the inbound
|
|
|
163
165
|
`fetch`), but they arrive via S3 GET, which has no Lambda payload limit. You also get the stored `key` for
|
|
164
166
|
free — no second upload.
|
|
165
167
|
|
|
168
|
+
### Detecting form fields in an uploaded PDF
|
|
169
|
+
|
|
170
|
+
A PDF-to-form feature calls the helper in the processing route and lets `method` decide the UI. Passing
|
|
171
|
+
`assembly` (from `getAssemblyForRequest(request)`) lets a text-only PDF get AI-suggested fields; without
|
|
172
|
+
it, `text` comes back with no fields. A file that is not a PDF throws `PdfUnreadableError`, whose message
|
|
173
|
+
is safe to show.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
const detection = await detectPdfFields(buffer, { assembly });
|
|
177
|
+
switch (detection.method) {
|
|
178
|
+
case 'acroform': break; // prefill the form from detection.fields
|
|
179
|
+
case 'text': break; // show detection.fields as editable suggestions the user confirms before saving; if empty, say none were suggested and offer manual add-field
|
|
180
|
+
case 'xfa': break; // say this form type can't be read automatically, then offer manual add-field
|
|
181
|
+
case 'none': break; // say no fields or text were found (likely a scan), then offer manual add-field
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
166
185
|
## What NOT to do
|
|
167
186
|
|
|
168
187
|
- Do NOT add `Authorization`/`X-API-Key` to the S3 PUT — extra headers cause `SignatureDoesNotMatch`.
|