Report the fields actually written and close an unload race

This commit is contained in:
Anthony Stirling
2026-08-28 19:14:59 +01:00
parent 0215fa8b8e
commit a0988e4201
8 changed files with 60 additions and 66 deletions
@@ -1014,11 +1014,12 @@ public class FormUtils {
* Create AcroForm fields from definitions, uniquifying names against existing fields. Creates
* the AcroForm with a Helvetica default resource when the document has none.
*/
public void addFields(PDDocument document, List<NewFormFieldDefinition> definitions)
throws IOException {
public List<CreatedField> addFields(
PDDocument document, List<NewFormFieldDefinition> definitions) throws IOException {
if (document == null || definitions == null || definitions.isEmpty()) {
return;
return List.of();
}
List<CreatedField> created = new ArrayList<>();
PDDocumentCatalog documentCatalog = document.getDocumentCatalog();
PDAcroForm acroForm = documentCatalog.getAcroForm();
boolean priorNeedAppearances =
@@ -1087,10 +1088,11 @@ public class FormUtils {
uniqueName,
definition,
definition.options());
PDField created = acroForm.getField(uniqueName);
if (created != null) {
createdFields.add(created);
PDField field = acroForm.getField(uniqueName);
if (field != null) {
createdFields.add(field);
createdButtons.add(Map.entry(uniqueName, definition));
created.add(new CreatedField(handler.typeName(), pageIndex));
}
} catch (Exception e) {
log.warn("Failed to create detected field '{}': {}", uniqueName, e.getMessage());
@@ -1100,8 +1102,12 @@ public class FormUtils {
applyButtonAppearances(document, acroForm, createdButtons);
// Refresh only what we added; regenerating pre-existing fields could alter their look.
ensureAppearances(acroForm, createdFields, priorNeedAppearances);
return List.copyOf(created);
}
/** A field that was actually written, with the type it ended up as after any coercion. */
public record CreatedField(String type, int pageIndex) {}
public String filterSingleChoiceSelection(
String selection, List<String> allowedOptions, String fieldName) {
if (selection == null || selection.trim().isEmpty()) return null;
@@ -109,13 +109,13 @@ public class FormDetectionController {
for (DetectedField f : detections) {
defs.add(toDefinition(f));
}
FormUtils.addFields(document, defs);
List<FormUtils.CreatedField> written = FormUtils.addFields(document, defs);
ResponseEntity<Resource> pdf =
WebResponseUtils.pdfDocToWebResponse(
document, baseName(file) + ".pdf", tempFileManager);
return ResponseEntity.status(pdf.getStatusCode())
.headers(pdf.getHeaders())
.header(SUMMARY_HEADER, summaryHeader(detections))
.header(SUMMARY_HEADER, summaryHeader(written))
.body(pdf.getBody());
} catch (IOException e) {
log.debug("Auto Form Detection could not apply fields: {}", e.getMessage());
@@ -155,17 +155,20 @@ 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) {
/**
* Compact JSON of the fields actually written, so the panel cannot over-report ones that
* addFields skipped, and reports the type each ended up as rather than the detected one.
*/
private String summaryHeader(List<FormUtils.CreatedField> written) {
Map<String, Integer> byType = new LinkedHashMap<>();
TreeSet<Integer> pages = new TreeSet<>();
for (DetectedField f : detections) {
for (FormUtils.CreatedField f : written) {
byType.merge(f.type(), 1, Integer::sum);
pages.add(f.page());
pages.add(f.pageIndex());
}
return objectMapper.writeValueAsString(
Map.of(
"total", detections.size(),
"total", written.size(),
"byType", byType,
"pagesWithFields", pages.size()));
}
@@ -54,11 +54,18 @@ public class OnnxFormDetector implements UnloadableModel {
concurrency.acquireUninterruptibly();
lock.readLock().lock();
try {
// Re-read under the lock: an uninstall between ensureLoaded and here closes the
// session, and dereferencing it then would be a 500 rather than the usual 503.
OrtSession current = session;
String input = inputName;
if (current == null || input == null) {
throw new IllegalStateException("Model was unloaded while the request was running");
}
OrtEnvironment env = OrtEnvironment.getEnvironment();
long[] shape = {1, 3, inputSize, inputSize};
try (OnnxTensor tensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(chw), shape);
OrtSession.Result results =
session.run(Collections.singletonMap(inputName, tensor))) {
current.run(Collections.singletonMap(input, tensor))) {
Map<String, Yolo.RawOutput> outputs = new LinkedHashMap<>();
for (Map.Entry<String, OnnxValue> entry : results) {
outputs.put(entry.getKey(), toRawOutput(entry.getKey(), entry.getValue()));
@@ -2115,9 +2115,7 @@ text = "Text fields"
failed = "An error occurred while detecting form fields."
[autoFormDetection.progress]
applying = "Building fillable fields..."
starting = "Preparing detection..."
uploading = "Analyzing your document..."
detecting = "Analyzing your document..."
[autoFormDetection.results]
title = "Review fillable PDF"
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { Stack, Text } from "@mantine/core";
import { Group, Loader, Stack, Text } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ProgressBar } from "@app/ui/ProgressBar";
import { DetectionStage, onStage } from "@app/services/formDetection/progress";
export default function DetectionProgressPanel({
@@ -19,42 +18,19 @@ export default function DetectionProgressPanel({
if (!active || !stage || stage.kind === "done") return null;
let value = 0.05;
let label = t(
"autoFormDetection.progress.starting",
"Preparing detection...",
);
switch (stage.kind) {
case "uploading":
value = 0.35;
label = t(
"autoFormDetection.progress.uploading",
"Analyzing your document...",
);
break;
case "applying":
value = 0.9;
label = t(
"autoFormDetection.progress.applying",
"Building fillable fields...",
);
break;
case "starting":
value = 0.05;
label = t(
"autoFormDetection.progress.starting",
"Preparing detection...",
);
break;
}
// Detection is one request of unknown length, so a spinner is honest where a percentage
// would have to be invented.
return (
<Stack gap={6} mx="md" mt="sm">
<ProgressBar value={value} label={label} />
<Text size="xs" c="dimmed">
{label}
</Text>
<Group gap={8} wrap="nowrap">
<Loader size="xs" />
<Text size="xs" c="dimmed">
{t(
"autoFormDetection.progress.detecting",
"Analyzing your document...",
)}
</Text>
</Group>
</Stack>
);
}
@@ -13,6 +13,7 @@ vi.mock("react-i18next", () => ({
}),
}));
import { expectConsole } from "@app/tests/failOnConsole";
import apiClient from "@app/services/apiClient";
import { onSummary } from "@app/services/formDetection/progress";
import { autoFormDetectionOperationConfig } from "@app/hooks/tools/autoFormDetection/useAutoFormDetectionOperation";
@@ -24,7 +25,12 @@ function pdfFile(): File {
return new File(["%PDF-1.4 dummy"], "doc.pdf", { type: "application/pdf" });
}
function respond(headers: Record<string, string> = {}) {
const SUMMARY =
'{"total":11,"byType":{"text":8,"checkbox":3},"pagesWithFields":1}';
function respond(
headers: Record<string, string> = { "x-stirling-detected-fields": SUMMARY },
) {
(apiClient.post as Mock).mockResolvedValue({
data: new Blob(["%PDF-1.4 applied"]),
headers,
@@ -59,10 +65,7 @@ describe("processAutoFormDetection", () => {
});
it("publishes the summary the server reported", async () => {
respond({
"x-stirling-detected-fields":
'{"total":11,"byType":{"text":8,"checkbox":3},"pagesWithFields":1}',
});
respond({ "x-stirling-detected-fields": SUMMARY });
const seen: unknown[] = [];
const stop = onSummary((s) => seen.push(s));
@@ -75,6 +78,7 @@ describe("processAutoFormDetection", () => {
});
it("still returns the PDF when the summary header is missing or malformed", async () => {
expectConsole.warn(/no X-Stirling-Detected-Fields header/);
respond({ "x-stirling-detected-fields": "not json" });
const seen: unknown[] = [];
const stop = onSummary((s) => seen.push(s));
@@ -47,18 +47,22 @@ async function processAutoFormDetection(
const file = files[0];
try {
emitStage({ kind: "starting" });
emitStage({ kind: "uploading" });
emitStage({ kind: "detecting" });
const res = await apiClient.post(
DETECT_ENDPOINT,
buildAutoFormDetectionFormData(parameters, file),
{ responseType: "blob" },
);
emitStage({ kind: "applying" });
const summary = parseSummary(res.headers?.["x-stirling-detected-fields"]);
if (summary) {
emitSummary(summary);
} else {
// Not fatal - the PDF is still correct - but the results panel needs the header, so a
// proxy that drops it turns into a silently missing summary without this.
console.warn(
"[AutoFormDetection] no X-Stirling-Detected-Fields header; skipping the results summary",
);
}
return { files: [asPdf(res.data as Blob, file)] };
} finally {
@@ -1,8 +1,4 @@
export type DetectionStage =
| { kind: "starting" }
| { kind: "uploading" }
| { kind: "applying" }
| { kind: "done" };
export type DetectionStage = { kind: "detecting" } | { kind: "done" };
export interface DetectionSummary {
total: number;