Skip to content

Commit 0215fa8

Browse files
committed
Apply detected fields with PDFBox instead of pdf-lib
1 parent 8de0e14 commit 0215fa8

12 files changed

Lines changed: 116 additions & 170 deletions

File tree

app/common/src/main/java/stirling/software/common/util/FormUtils.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,8 +1065,8 @@ public void addFields(PDDocument document, List<NewFormFieldDefinition> definiti
10651065
definition.width(),
10661066
definition.height());
10671067
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(definition.type());
1068-
// Coerced by name, not capability: detection results can also be applied client-side
1069-
// with pdf-lib, which cannot create signature widgets, so both paths emit text.
1068+
// Signature has no definition-creation path here, so it lands as text. PDFBox can build
1069+
// a real PDSignatureField, so this is worth revisiting for both callers.
10701070
if (handler == null
10711071
|| handler == FormFieldTypeSupport.SIGNATURE
10721072
|| handler.doesNotsupportsDefinitionCreation()) {

app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,8 @@ public void addCorsMappings(CorsRegistry registry) {
185185
"Content-Disposition",
186186
"Content-Type",
187187
"X-Stirling-Skipped-Field-Edits",
188-
"X-Stirling-Skipped-Field-Edits-Total")
188+
"X-Stirling-Skipped-Field-Edits-Total",
189+
"X-Stirling-Detected-Fields")
189190
.allowCredentials(true)
190191
.maxAge(3600);
191192
} else if (hasConfiguredOrigins) {
@@ -233,7 +234,8 @@ public void addCorsMappings(CorsRegistry registry) {
233234
"Content-Disposition",
234235
"Content-Type",
235236
"X-Stirling-Skipped-Field-Edits",
236-
"X-Stirling-Skipped-Field-Edits-Total")
237+
"X-Stirling-Skipped-Field-Edits-Total",
238+
"X-Stirling-Detected-Fields")
237239
.allowCredentials(true)
238240
.maxAge(3600);
239241
} else {
@@ -262,7 +264,8 @@ public void addCorsMappings(CorsRegistry registry) {
262264
"Content-Disposition",
263265
"Content-Type",
264266
"X-Stirling-Skipped-Field-Edits",
265-
"X-Stirling-Skipped-Field-Edits-Total")
267+
"X-Stirling-Skipped-Field-Edits-Total",
268+
"X-Stirling-Detected-Fields")
266269
.allowCredentials(true)
267270
.maxAge(3600);
268271
}

app/proprietary/src/main/java/stirling/software/proprietary/formdetection/controller/FormDetectionController.java

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@
22

33
import java.io.IOException;
44
import java.util.ArrayList;
5+
import java.util.LinkedHashMap;
56
import java.util.List;
67
import java.util.Map;
8+
import java.util.TreeSet;
79

810
import org.apache.pdfbox.pdmodel.PDDocument;
911
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
12+
import org.springframework.core.io.Resource;
1013
import org.springframework.http.HttpStatus;
1114
import org.springframework.http.MediaType;
1215
import org.springframework.http.ResponseEntity;
@@ -32,6 +35,8 @@
3235
import stirling.software.proprietary.formdetection.render.PageRasterizer;
3336
import stirling.software.proprietary.formdetection.service.FormDetectionService;
3437

38+
import tools.jackson.databind.ObjectMapper;
39+
3540
/**
3641
* Detection endpoint, behind the {@code form-detection} key that is disabled until a model is
3742
* installed. Returns detected fields, or the applied PDF when {@code applyToPdf=true}.
@@ -44,9 +49,13 @@
4449
@Tag(name = "Auto Form Detection")
4550
public class FormDetectionController {
4651

52+
/** Carries the field counts alongside the PDF, so one request feeds the results panel. */
53+
static final String SUMMARY_HEADER = "X-Stirling-Detected-Fields";
54+
4755
private final FormDetectionService detection;
4856
private final CustomPDFDocumentFactory pdfDocumentFactory;
4957
private final TempFileManager tempFileManager;
58+
private final ObjectMapper objectMapper;
5059

5160
@PostMapping(value = "/detect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
5261
@Operation(
@@ -101,8 +110,13 @@ public ResponseEntity<?> detect(
101110
defs.add(toDefinition(f));
102111
}
103112
FormUtils.addFields(document, defs);
104-
return WebResponseUtils.pdfDocToWebResponse(
105-
document, baseName(file) + ".pdf", tempFileManager);
113+
ResponseEntity<Resource> pdf =
114+
WebResponseUtils.pdfDocToWebResponse(
115+
document, baseName(file) + ".pdf", tempFileManager);
116+
return ResponseEntity.status(pdf.getStatusCode())
117+
.headers(pdf.getHeaders())
118+
.header(SUMMARY_HEADER, summaryHeader(detections))
119+
.body(pdf.getBody());
106120
} catch (IOException e) {
107121
log.debug("Auto Form Detection could not apply fields: {}", e.getMessage());
108122
return ResponseEntity.badRequest()
@@ -141,6 +155,21 @@ private static NewFormFieldDefinition toDefinition(DetectedField f) {
141155
null);
142156
}
143157

158+
/** Compact JSON of what was added; a header keeps it to one request and one inference. */
159+
private String summaryHeader(List<DetectedField> detections) {
160+
Map<String, Integer> byType = new LinkedHashMap<>();
161+
TreeSet<Integer> pages = new TreeSet<>();
162+
for (DetectedField f : detections) {
163+
byType.merge(f.type(), 1, Integer::sum);
164+
pages.add(f.page());
165+
}
166+
return objectMapper.writeValueAsString(
167+
Map.of(
168+
"total", detections.size(),
169+
"byType", byType,
170+
"pagesWithFields", pages.size()));
171+
}
172+
144173
private static String baseName(MultipartFile file) {
145174
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
146175
if (original == null || original.isBlank()) {

app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/SecurityConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,8 @@ public CorsConfigurationSource corsConfigurationSource() {
215215
"Content-Disposition",
216216
"Content-Type",
217217
"X-Stirling-Skipped-Field-Edits",
218-
"X-Stirling-Skipped-Field-Edits-Total"));
218+
"X-Stirling-Skipped-Field-Edits-Total",
219+
"X-Stirling-Detected-Fields"));
219220

220221
cfg.setAllowCredentials(true);
221222
cfg.setMaxAge(3600L);

app/proprietary/src/test/java/stirling/software/proprietary/formdetection/controller/FormDetectionControllerTest.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
2121
import stirling.software.proprietary.formdetection.service.FormDetectionService;
2222

23+
import tools.jackson.databind.ObjectMapper;
24+
2325
class FormDetectionControllerTest {
2426

2527
private MockMvc mvc(
@@ -32,7 +34,8 @@ private MockMvc mvc(
3234
new FormDetectionController(
3335
new FormDetectionService(manager, detector, rasterizer),
3436
Mockito.mock(CustomPDFDocumentFactory.class),
35-
Mockito.mock(TempFileManager.class));
37+
Mockito.mock(TempFileManager.class),
38+
new ObjectMapper());
3639
return MockMvcBuilders.standaloneSetup(controller).build();
3740
}
3841

app/saas/src/main/java/stirling/software/saas/security/SupabaseSecurityConfig.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,8 @@ CorsConfigurationSource corsConfigurationSource() {
371371
List.of(
372372
"WWW-Authenticate",
373373
"X-Stirling-Skipped-Field-Edits",
374-
"X-Stirling-Skipped-Field-Edits-Total"));
374+
"X-Stirling-Skipped-Field-Edits-Total",
375+
"X-Stirling-Detected-Fields"));
375376
cfg.setAllowCredentials(true);
376377
cfg.setMaxAge(3600L);
377378
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

frontend/editor/src/core/hooks/tools/autoFormDetection/useAutoFormDetectionOperation.test.ts

Lines changed: 33 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
33
vi.mock("@app/services/apiClient", () => ({
44
default: { get: vi.fn(), post: vi.fn() },
55
}));
6-
vi.mock("@app/services/formDetection/progress", () => ({
7-
emitStage: vi.fn(),
8-
emitSummary: vi.fn(),
9-
summarizeFields: vi.fn(() => ({})),
10-
}));
11-
vi.mock("@app/services/formDetection/applyFields", () => ({
12-
applyFields: vi.fn(async () => new Uint8Array([1, 2, 3])),
13-
}));
14-
vi.mock("@app/hooks/useFormDetectionModelStatus", () => ({}));
156
vi.mock("@app/hooks/tools/shared/useToolOperation", () => ({
167
ToolType: { custom: "custom" },
178
useToolOperation: vi.fn(),
@@ -22,9 +13,8 @@ vi.mock("react-i18next", () => ({
2213
}),
2314
}));
2415

25-
import { expectConsole } from "@app/tests/failOnConsole";
2616
import apiClient from "@app/services/apiClient";
27-
import { applyFields } from "@app/services/formDetection/applyFields";
17+
import { onSummary } from "@app/services/formDetection/progress";
2818
import { autoFormDetectionOperationConfig } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
2919
import { defaultParameters } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
3020

@@ -34,23 +24,31 @@ function pdfFile(): File {
3424
return new File(["%PDF-1.4 dummy"], "doc.pdf", { type: "application/pdf" });
3525
}
3626

27+
function respond(headers: Record<string, string> = {}) {
28+
(apiClient.post as Mock).mockResolvedValue({
29+
data: new Blob(["%PDF-1.4 applied"]),
30+
headers,
31+
});
32+
}
33+
3734
const process = autoFormDetectionOperationConfig.customProcessor;
3835

3936
describe("processAutoFormDetection", () => {
4037
beforeEach(() => {
4138
vi.clearAllMocks();
42-
(apiClient.post as Mock).mockResolvedValue({ data: { detections: [] } });
39+
respond();
4340
});
4441

45-
it("asks the server for fields, then applies them locally", async () => {
42+
it("asks the server to apply the fields and returns the PDF it sends back", async () => {
4643
const { files } = await process(defaultParameters, [pdfFile()]);
4744

4845
expect(apiClient.post).toHaveBeenCalledTimes(1);
49-
const [url, body] = (apiClient.post as Mock).mock.calls[0];
46+
const [url, body, config] = (apiClient.post as Mock).mock.calls[0];
5047
expect(url).toBe(DETECT_ENDPOINT);
51-
expect((body as FormData).get("applyToPdf")).toBe("false");
52-
expect(applyFields).toHaveBeenCalledTimes(1);
48+
expect((body as FormData).get("applyToPdf")).toBe("true");
49+
expect(config).toMatchObject({ responseType: "blob" });
5350
expect(files[0].name).toBe("doc_form.pdf");
51+
expect(files[0].type).toBe("application/pdf");
5452
});
5553

5654
it("sends the sensitivity's confidence threshold", async () => {
@@ -60,31 +58,31 @@ describe("processAutoFormDetection", () => {
6058
expect(body.get("confThreshold")).toBe("0.45");
6159
});
6260

63-
it("does not retry when the detect request itself fails", async () => {
64-
(apiClient.post as Mock).mockRejectedValueOnce(new Error("503 no model"));
61+
it("publishes the summary the server reported", async () => {
62+
respond({
63+
"x-stirling-detected-fields":
64+
'{"total":11,"byType":{"text":8,"checkbox":3},"pagesWithFields":1}',
65+
});
66+
const seen: unknown[] = [];
67+
const stop = onSummary((s) => seen.push(s));
6568

66-
await expect(process(defaultParameters, [pdfFile()])).rejects.toThrow(
67-
"503 no model",
68-
);
69-
expect(apiClient.post).toHaveBeenCalledTimes(1);
70-
expect(applyFields).not.toHaveBeenCalled();
69+
await process(defaultParameters, [pdfFile()]);
70+
stop();
71+
72+
expect(seen).toEqual([
73+
{ total: 11, byType: { text: 8, checkbox: 3 }, pagesWithFields: 1 },
74+
]);
7175
});
7276

73-
it("re-requests a server-applied PDF when applying locally fails", async () => {
74-
expectConsole.warn(/applying fields locally failed/);
75-
(applyFields as Mock).mockRejectedValueOnce(new Error("bad xref"));
76-
(apiClient.post as Mock).mockResolvedValueOnce({
77-
data: { detections: [] },
78-
});
79-
(apiClient.post as Mock).mockResolvedValueOnce({
80-
data: new Blob(["%PDF-1.4 applied"]),
81-
});
77+
it("still returns the PDF when the summary header is missing or malformed", async () => {
78+
respond({ "x-stirling-detected-fields": "not json" });
79+
const seen: unknown[] = [];
80+
const stop = onSummary((s) => seen.push(s));
8281

8382
const { files } = await process(defaultParameters, [pdfFile()]);
83+
stop();
8484

85-
expect(apiClient.post).toHaveBeenCalledTimes(2);
86-
const body = (apiClient.post as Mock).mock.calls[1][1] as FormData;
87-
expect(body.get("applyToPdf")).toBe("true");
85+
expect(seen).toEqual([]);
8886
expect(files[0].name).toBe("doc_form.pdf");
8987
});
9088
});

frontend/editor/src/core/hooks/tools/autoFormDetection/useAutoFormDetectionOperation.ts

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,8 @@ import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
99
import {
1010
emitStage,
1111
emitSummary,
12-
summarizeFields,
12+
parseSummary,
1313
} from "@app/services/formDetection/progress";
14-
import { DetectedField } from "@app/services/formDetection/types";
1514
import {
1615
AutoFormDetectionParameters,
1716
defaultParameters,
@@ -23,41 +22,24 @@ const DETECT_ENDPOINT = "/api/v1/form/form-detection/detect";
2322
export const buildAutoFormDetectionFormData = (
2423
parameters: AutoFormDetectionParameters,
2524
file: File,
26-
applyToPdf: boolean,
2725
): FormData => {
2826
const formData = new FormData();
2927
formData.append("file", file);
30-
formData.append("applyToPdf", String(applyToPdf));
28+
// The server writes the AcroForm with PDFBox and reports what it added in a header, so one
29+
// request covers both the fillable PDF and the counts the results panel shows.
30+
formData.append("applyToPdf", "true");
3131
const confidence = resolveConfidence(parameters);
3232
if (typeof confidence === "number") {
3333
formData.append("confThreshold", String(confidence));
3434
}
3535
return formData;
3636
};
3737

38-
function asPdf(data: BlobPart, source: File): File {
38+
function asPdf(data: Blob, source: File): File {
3939
const base = (source.name || "document").replace(/\.pdf$/i, "");
4040
return new File([data], `${base}_form.pdf`, { type: "application/pdf" });
4141
}
4242

43-
/**
44-
* The one place detection is asked for. A detector running in the browser would substitute here
45-
* without the caller changing, since the wire contract is plain geometry.
46-
*/
47-
export async function detectFields(
48-
parameters: AutoFormDetectionParameters,
49-
file: File,
50-
): Promise<DetectedField[]> {
51-
// Ask for the field list rather than a finished PDF so the summary panel has counts to show;
52-
// applying the fields here also spares the server a second parse of the same file.
53-
const res = await apiClient.post(
54-
DETECT_ENDPOINT,
55-
buildAutoFormDetectionFormData(parameters, file, false),
56-
);
57-
return ((res.data as { detections?: DetectedField[] })?.detections ??
58-
[]) as DetectedField[];
59-
}
60-
6143
async function processAutoFormDetection(
6244
parameters: AutoFormDetectionParameters,
6345
files: File[],
@@ -67,31 +49,18 @@ async function processAutoFormDetection(
6749
try {
6850
emitStage({ kind: "starting" });
6951
emitStage({ kind: "uploading" });
70-
const fields = await detectFields(parameters, file);
52+
const res = await apiClient.post(
53+
DETECT_ENDPOINT,
54+
buildAutoFormDetectionFormData(parameters, file),
55+
{ responseType: "blob" },
56+
);
7157

7258
emitStage({ kind: "applying" });
73-
try {
74-
const { applyFields } =
75-
await import("@app/services/formDetection/applyFields");
76-
const bytes = await file.arrayBuffer();
77-
const appliedPdf = await applyFields(bytes, fields);
78-
79-
emitSummary(summarizeFields(fields));
80-
return { files: [asPdf(new Uint8Array(appliedPdf), file)] };
81-
} catch (e) {
82-
// Guards the local apply only: pdf-lib rejects some documents PDFBox accepts. A failed
83-
// detect must not land here, or every server error costs a second upload and inference.
84-
console.warn(
85-
"[AutoFormDetection] applying fields locally failed; asking the server to apply them",
86-
e,
87-
);
88-
const res = await apiClient.post(
89-
DETECT_ENDPOINT,
90-
buildAutoFormDetectionFormData(parameters, file, true),
91-
{ responseType: "blob" },
92-
);
93-
return { files: [asPdf(res.data as Blob, file)] };
59+
const summary = parseSummary(res.headers?.["x-stirling-detected-fields"]);
60+
if (summary) {
61+
emitSummary(summary);
9462
}
63+
return { files: [asPdf(res.data as Blob, file)] };
9564
} finally {
9665
emitStage({ kind: "done" });
9766
}

0 commit comments

Comments
 (0)