Apply detected fields with PDFBox instead of pdf-lib

This commit is contained in:
Anthony Stirling
2026-08-28 17:08:12 +01:00
parent 8de0e144e2
commit 0215fa8b8e
12 changed files with 116 additions and 170 deletions
@@ -1065,8 +1065,8 @@ public class FormUtils {
definition.width(),
definition.height());
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(definition.type());
// Coerced by name, not capability: detection results can also be applied client-side
// with pdf-lib, which cannot create signature widgets, so both paths emit text.
// Signature has no definition-creation path here, so it lands as text. PDFBox can build
// a real PDSignatureField, so this is worth revisiting for both callers.
if (handler == null
|| handler == FormFieldTypeSupport.SIGNATURE
|| handler.doesNotsupportsDefinitionCreation()) {
@@ -185,7 +185,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Content-Disposition",
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
"X-Stirling-Skipped-Field-Edits-Total",
"X-Stirling-Detected-Fields")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -233,7 +234,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Content-Disposition",
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
"X-Stirling-Skipped-Field-Edits-Total",
"X-Stirling-Detected-Fields")
.allowCredentials(true)
.maxAge(3600);
} else {
@@ -262,7 +264,8 @@ public class WebMvcConfig implements WebMvcConfigurer {
"Content-Disposition",
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total")
"X-Stirling-Skipped-Field-Edits-Total",
"X-Stirling-Detected-Fields")
.allowCredentials(true)
.maxAge(3600);
}
@@ -2,11 +2,14 @@ package stirling.software.proprietary.formdetection.controller;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeSet;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -32,6 +35,8 @@ import stirling.software.proprietary.formdetection.model.DetectedField;
import stirling.software.proprietary.formdetection.render.PageRasterizer;
import stirling.software.proprietary.formdetection.service.FormDetectionService;
import tools.jackson.databind.ObjectMapper;
/**
* Detection endpoint, behind the {@code form-detection} key that is disabled until a model is
* installed. Returns detected fields, or the applied PDF when {@code applyToPdf=true}.
@@ -44,9 +49,13 @@ import stirling.software.proprietary.formdetection.service.FormDetectionService;
@Tag(name = "Auto Form Detection")
public class FormDetectionController {
/** Carries the field counts alongside the PDF, so one request feeds the results panel. */
static final String SUMMARY_HEADER = "X-Stirling-Detected-Fields";
private final FormDetectionService detection;
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final ObjectMapper objectMapper;
@PostMapping(value = "/detect", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
@@ -101,8 +110,13 @@ public class FormDetectionController {
defs.add(toDefinition(f));
}
FormUtils.addFields(document, defs);
return WebResponseUtils.pdfDocToWebResponse(
document, baseName(file) + ".pdf", tempFileManager);
ResponseEntity<Resource> pdf =
WebResponseUtils.pdfDocToWebResponse(
document, baseName(file) + ".pdf", tempFileManager);
return ResponseEntity.status(pdf.getStatusCode())
.headers(pdf.getHeaders())
.header(SUMMARY_HEADER, summaryHeader(detections))
.body(pdf.getBody());
} catch (IOException e) {
log.debug("Auto Form Detection could not apply fields: {}", e.getMessage());
return ResponseEntity.badRequest()
@@ -141,6 +155,21 @@ public class FormDetectionController {
null);
}
/** Compact JSON of what was added; a header keeps it to one request and one inference. */
private String summaryHeader(List<DetectedField> detections) {
Map<String, Integer> byType = new LinkedHashMap<>();
TreeSet<Integer> pages = new TreeSet<>();
for (DetectedField f : detections) {
byType.merge(f.type(), 1, Integer::sum);
pages.add(f.page());
}
return objectMapper.writeValueAsString(
Map.of(
"total", detections.size(),
"byType", byType,
"pagesWithFields", pages.size()));
}
private static String baseName(MultipartFile file) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
@@ -215,7 +215,8 @@ public class SecurityConfiguration {
"Content-Disposition",
"Content-Type",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
"X-Stirling-Skipped-Field-Edits-Total",
"X-Stirling-Detected-Fields"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
@@ -20,6 +20,8 @@ import stirling.software.proprietary.formdetection.render.PageRasterizer;
import stirling.software.proprietary.formdetection.service.FormDetectionModelManager;
import stirling.software.proprietary.formdetection.service.FormDetectionService;
import tools.jackson.databind.ObjectMapper;
class FormDetectionControllerTest {
private MockMvc mvc(
@@ -32,7 +34,8 @@ class FormDetectionControllerTest {
new FormDetectionController(
new FormDetectionService(manager, detector, rasterizer),
Mockito.mock(CustomPDFDocumentFactory.class),
Mockito.mock(TempFileManager.class));
Mockito.mock(TempFileManager.class),
new ObjectMapper());
return MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -371,7 +371,8 @@ public class SupabaseSecurityConfig {
List.of(
"WWW-Authenticate",
"X-Stirling-Skipped-Field-Edits",
"X-Stirling-Skipped-Field-Edits-Total"));
"X-Stirling-Skipped-Field-Edits-Total",
"X-Stirling-Detected-Fields"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -3,15 +3,6 @@ import { beforeEach, describe, expect, it, vi, type Mock } from "vitest";
vi.mock("@app/services/apiClient", () => ({
default: { get: vi.fn(), post: vi.fn() },
}));
vi.mock("@app/services/formDetection/progress", () => ({
emitStage: vi.fn(),
emitSummary: vi.fn(),
summarizeFields: vi.fn(() => ({})),
}));
vi.mock("@app/services/formDetection/applyFields", () => ({
applyFields: vi.fn(async () => new Uint8Array([1, 2, 3])),
}));
vi.mock("@app/hooks/useFormDetectionModelStatus", () => ({}));
vi.mock("@app/hooks/tools/shared/useToolOperation", () => ({
ToolType: { custom: "custom" },
useToolOperation: vi.fn(),
@@ -22,9 +13,8 @@ vi.mock("react-i18next", () => ({
}),
}));
import { expectConsole } from "@app/tests/failOnConsole";
import apiClient from "@app/services/apiClient";
import { applyFields } from "@app/services/formDetection/applyFields";
import { onSummary } from "@app/services/formDetection/progress";
import { autoFormDetectionOperationConfig } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
import { defaultParameters } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionParameters";
@@ -34,23 +24,31 @@ function pdfFile(): File {
return new File(["%PDF-1.4 dummy"], "doc.pdf", { type: "application/pdf" });
}
function respond(headers: Record<string, string> = {}) {
(apiClient.post as Mock).mockResolvedValue({
data: new Blob(["%PDF-1.4 applied"]),
headers,
});
}
const process = autoFormDetectionOperationConfig.customProcessor;
describe("processAutoFormDetection", () => {
beforeEach(() => {
vi.clearAllMocks();
(apiClient.post as Mock).mockResolvedValue({ data: { detections: [] } });
respond();
});
it("asks the server for fields, then applies them locally", async () => {
it("asks the server to apply the fields and returns the PDF it sends back", async () => {
const { files } = await process(defaultParameters, [pdfFile()]);
expect(apiClient.post).toHaveBeenCalledTimes(1);
const [url, body] = (apiClient.post as Mock).mock.calls[0];
const [url, body, config] = (apiClient.post as Mock).mock.calls[0];
expect(url).toBe(DETECT_ENDPOINT);
expect((body as FormData).get("applyToPdf")).toBe("false");
expect(applyFields).toHaveBeenCalledTimes(1);
expect((body as FormData).get("applyToPdf")).toBe("true");
expect(config).toMatchObject({ responseType: "blob" });
expect(files[0].name).toBe("doc_form.pdf");
expect(files[0].type).toBe("application/pdf");
});
it("sends the sensitivity's confidence threshold", async () => {
@@ -60,31 +58,31 @@ describe("processAutoFormDetection", () => {
expect(body.get("confThreshold")).toBe("0.45");
});
it("does not retry when the detect request itself fails", async () => {
(apiClient.post as Mock).mockRejectedValueOnce(new Error("503 no model"));
it("publishes the summary the server reported", async () => {
respond({
"x-stirling-detected-fields":
'{"total":11,"byType":{"text":8,"checkbox":3},"pagesWithFields":1}',
});
const seen: unknown[] = [];
const stop = onSummary((s) => seen.push(s));
await expect(process(defaultParameters, [pdfFile()])).rejects.toThrow(
"503 no model",
);
expect(apiClient.post).toHaveBeenCalledTimes(1);
expect(applyFields).not.toHaveBeenCalled();
await process(defaultParameters, [pdfFile()]);
stop();
expect(seen).toEqual([
{ total: 11, byType: { text: 8, checkbox: 3 }, pagesWithFields: 1 },
]);
});
it("re-requests a server-applied PDF when applying locally fails", async () => {
expectConsole.warn(/applying fields locally failed/);
(applyFields as Mock).mockRejectedValueOnce(new Error("bad xref"));
(apiClient.post as Mock).mockResolvedValueOnce({
data: { detections: [] },
});
(apiClient.post as Mock).mockResolvedValueOnce({
data: new Blob(["%PDF-1.4 applied"]),
});
it("still returns the PDF when the summary header is missing or malformed", async () => {
respond({ "x-stirling-detected-fields": "not json" });
const seen: unknown[] = [];
const stop = onSummary((s) => seen.push(s));
const { files } = await process(defaultParameters, [pdfFile()]);
stop();
expect(apiClient.post).toHaveBeenCalledTimes(2);
const body = (apiClient.post as Mock).mock.calls[1][1] as FormData;
expect(body.get("applyToPdf")).toBe("true");
expect(seen).toEqual([]);
expect(files[0].name).toBe("doc_form.pdf");
});
});
@@ -9,9 +9,8 @@ import { createStandardErrorHandler } from "@app/utils/toolErrorHandler";
import {
emitStage,
emitSummary,
summarizeFields,
parseSummary,
} from "@app/services/formDetection/progress";
import { DetectedField } from "@app/services/formDetection/types";
import {
AutoFormDetectionParameters,
defaultParameters,
@@ -23,11 +22,12 @@ const DETECT_ENDPOINT = "/api/v1/form/form-detection/detect";
export const buildAutoFormDetectionFormData = (
parameters: AutoFormDetectionParameters,
file: File,
applyToPdf: boolean,
): FormData => {
const formData = new FormData();
formData.append("file", file);
formData.append("applyToPdf", String(applyToPdf));
// The server writes the AcroForm with PDFBox and reports what it added in a header, so one
// request covers both the fillable PDF and the counts the results panel shows.
formData.append("applyToPdf", "true");
const confidence = resolveConfidence(parameters);
if (typeof confidence === "number") {
formData.append("confThreshold", String(confidence));
@@ -35,29 +35,11 @@ export const buildAutoFormDetectionFormData = (
return formData;
};
function asPdf(data: BlobPart, source: File): File {
function asPdf(data: Blob, source: File): File {
const base = (source.name || "document").replace(/\.pdf$/i, "");
return new File([data], `${base}_form.pdf`, { type: "application/pdf" });
}
/**
* The one place detection is asked for. A detector running in the browser would substitute here
* without the caller changing, since the wire contract is plain geometry.
*/
export async function detectFields(
parameters: AutoFormDetectionParameters,
file: File,
): Promise<DetectedField[]> {
// Ask for the field list rather than a finished PDF so the summary panel has counts to show;
// applying the fields here also spares the server a second parse of the same file.
const res = await apiClient.post(
DETECT_ENDPOINT,
buildAutoFormDetectionFormData(parameters, file, false),
);
return ((res.data as { detections?: DetectedField[] })?.detections ??
[]) as DetectedField[];
}
async function processAutoFormDetection(
parameters: AutoFormDetectionParameters,
files: File[],
@@ -67,31 +49,18 @@ async function processAutoFormDetection(
try {
emitStage({ kind: "starting" });
emitStage({ kind: "uploading" });
const fields = await detectFields(parameters, file);
const res = await apiClient.post(
DETECT_ENDPOINT,
buildAutoFormDetectionFormData(parameters, file),
{ responseType: "blob" },
);
emitStage({ kind: "applying" });
try {
const { applyFields } =
await import("@app/services/formDetection/applyFields");
const bytes = await file.arrayBuffer();
const appliedPdf = await applyFields(bytes, fields);
emitSummary(summarizeFields(fields));
return { files: [asPdf(new Uint8Array(appliedPdf), file)] };
} catch (e) {
// Guards the local apply only: pdf-lib rejects some documents PDFBox accepts. A failed
// detect must not land here, or every server error costs a second upload and inference.
console.warn(
"[AutoFormDetection] applying fields locally failed; asking the server to apply them",
e,
);
const res = await apiClient.post(
DETECT_ENDPOINT,
buildAutoFormDetectionFormData(parameters, file, true),
{ responseType: "blob" },
);
return { files: [asPdf(res.data as Blob, file)] };
const summary = parseSummary(res.headers?.["x-stirling-detected-fields"]);
if (summary) {
emitSummary(summary);
}
return { files: [asPdf(res.data as Blob, file)] };
} finally {
emitStage({ kind: "done" });
}
@@ -1,51 +0,0 @@
// Turn detected fields into a real AcroForm with @cantoo/pdf-lib.
// Rects arrive in PDF points, bottom-left origin - exactly what addToPage wants.
import { PDFDocument } from "@cantoo/pdf-lib";
import { DetectedField } from "@app/services/formDetection/types";
export async function applyFields(
pdfBytes: ArrayBuffer | Uint8Array,
fields: DetectedField[],
): Promise<Uint8Array> {
const pdfDoc = await PDFDocument.load(pdfBytes, {
ignoreEncryption: true,
throwOnInvalidObject: false,
});
const form = pdfDoc.getForm();
const pages = pdfDoc.getPages();
const counts: Record<string, number> = {};
for (const f of fields) {
const page = pages[f.page];
if (!page) continue;
const r = f.rectInPdfPoints;
if (r.w <= 0 || r.h <= 0) continue;
const kind =
f.type === "checkbox"
? "checkbox"
: f.type === "signature"
? "signature"
: "text";
counts[kind] = (counts[kind] ?? 0) + 1;
const name = `${kind}_${f.page + 1}_${counts[kind]}`;
try {
if (kind === "checkbox") {
const cb = form.createCheckBox(name);
cb.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
} else {
// Signature and radio both land here: FormUtils.addFields coerces them to text too,
// so a document is identical whichever side applied the fields.
const tf = form.createTextField(name);
tf.addToPage(page, { x: r.x, y: r.y, width: r.w, height: r.h });
}
} catch {
// Skip a field that fails to add (e.g. a duplicate name) rather than abort the whole doc.
}
}
return pdfDoc.save();
}
@@ -1,5 +1,3 @@
import { DetectedField } from "@app/services/formDetection/types";
export type DetectionStage =
| { kind: "starting" }
| { kind: "uploading" }
@@ -36,16 +34,18 @@ export function onSummary(cb: (s: DetectionSummary) => void): () => void {
return () => window.removeEventListener(SUMMARY_EVENT, handler);
}
export function summarizeFields(fields: DetectedField[]): DetectionSummary {
const byType: Record<string, number> = {};
const pages = new Set<number>();
for (const f of fields) {
byType[f.type] = (byType[f.type] ?? 0) + 1;
pages.add(f.page);
/** Parse the server's X-Detected-Fields header; a missing or malformed one shows nothing. */
export function parseSummary(header: unknown): DetectionSummary | null {
if (typeof header !== "string" || header.length === 0) return null;
try {
const raw = JSON.parse(header) as Partial<DetectionSummary>;
if (typeof raw.total !== "number") return null;
return {
total: raw.total,
byType: raw.byType ?? {},
pagesWithFields: raw.pagesWithFields ?? 0,
};
} catch {
return null;
}
return {
total: fields.length,
byType,
pagesWithFields: pages.size,
};
}
@@ -1,15 +0,0 @@
// Output schema for Auto Form Detection; mirrors the server /detect response.
export interface RectPt {
x: number;
y: number;
w: number;
h: number;
}
export interface DetectedField {
type: string;
page: number;
rectInPdfPoints: RectPt;
confidence: number;
}
@@ -5,13 +5,21 @@ import {
screen,
waitFor,
} from "@testing-library/react";
import type React from "react";
import { MantineProvider } from "@mantine/core";
import { HttpError } from "@portal/api/http";
import { Integrations } from "@portal/views/Integrations";
import type { IntegrationConfig } from "@portal/api/integrations";
// env="test" drops Mantine's transitions; otherwise a pending one fires after the environment
// is torn down and vitest reports "window is not defined" against whichever file ran last.
const TestProvider = ({ children }: { children: React.ReactNode }) => (
<MantineProvider env="test">{children}</MantineProvider>
);
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
baseRender(ui, { wrapper: TestProvider });
vi.mock("react-i18next", () => ({
useTranslation: () => ({