mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Report the fields actually written and close an unload race
This commit is contained in:
@@ -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;
|
||||
|
||||
+10
-7
@@ -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()));
|
||||
}
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
+12
-36
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-5
@@ -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));
|
||||
|
||||
+7
-3
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user