Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ stirling/
customFiles/
configs/

# Local dev runtime dirs created by bootRun under the module dir (hold the locked H2 DB,
# downloaded models, logs); never build input. Root-level patterns above don't match these.
**/configs/
app/core/customFiles/
app/core/logs/
app/core/pipeline/

# Claude Code workspace
.claude/

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ public void init() {
addEndpointToGroup("Java", "pdf-to-epub");
addEndpointToGroup("Java", "eml-to-pdf");
addEndpointToGroup("Java", "handleData");
addEndpointToGroup("Java", "form-detection");
addEndpointToGroup("rar", "pdf-to-cbr");

// Javascript
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ public class RuntimePathConfig {
// Tesseract data path
private final String tessDataPath;

// Auto Form Detection model directory
private final String formDetectionModelPath;

private final List<ApplicationProperties.ProcessExecutor.UnoServerEndpoint> unoServerEndpoints;

// Pipeline paths
Expand Down Expand Up @@ -131,6 +134,22 @@ public RuntimePathConfig(ApplicationProperties properties) {

log.info("Using Tesseract data path: {}", this.tessDataPath);

// Auto Form Detection model directory (kept under <configs> so it survives
// restarts/updates)
String configuredModelDir =
properties.getFormDetection() != null
? properties.getFormDetection().getModelDir()
: null;
this.formDetectionModelPath =
StringUtils.isNotBlank(configuredModelDir)
? configuredModelDir
: Path.of(
InstallationPathConfig.getConfigPath(),
"models",
"form-detection")
.toString();
log.info("Using Auto Form Detection model path: {}", this.formDetectionModelPath);

ApplicationProperties.ProcessExecutor processExecutor = properties.getProcessExecutor();
int libreOfficeLimit = 1;
if (processExecutor != null && processExecutor.getSessionLimit() != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ public class ApplicationProperties {
private ProcessExecutor processExecutor = new ProcessExecutor();
private PdfEditor pdfEditor = new PdfEditor();
private AiEngine aiEngine = new AiEngine();
private FormDetection formDetection = new FormDetection();
private Mcp mcp = new Mcp();
private InternalApi internalApi = new InternalApi();
private Cluster cluster = new Cluster();
Expand Down Expand Up @@ -297,6 +298,37 @@ public static class AiEngine {
private int longRunningTimeoutSeconds = 600;
}

/**
* Auto Form Detection settings. The model itself is downloaded on demand by an admin (see
* {@code /api/v1/ai/form-detection-model/*}); only lightweight pointers are persisted here.
*/
@Data
public static class FormDetection {
/** Master on/off switch for the whole feature (admin-controlled). */
private boolean enabled = true;

/**
* Where detection runs: {@code auto} (browser first, server fallback), {@code browser}
* (in-browser WASM only - the PDF never leaves the device), or {@code server} (backend
* inference). Read by the frontend tool to choose its pipeline.
*/
private String executionMode = "auto";

/** Id of the installed model; blank means none installed. */
private String activeModelId = "";

/** Optional override dir; blank uses {@code <configs>/models/form-detection}. */
private String modelDir = "";

/**
* Read-only dir of models baked into the image (e.g. the Docker server image pre-downloads
* FFDNet-S here). On startup any {@code <catalogId>.onnx} found here is copied into the
* writable model dir if not already present, and activated if no model is active - so the
* feature works out-of-the-box. Blank (default) disables seeding.
*/
private String preinstalledModelDir = "";
}

/**
* Model Context Protocol (MCP) server configuration. All keys live under the top-level {@code
* mcp.*} prefix. {@link #enabled} defaults to {@code false}: when off, no MCP beans are wired,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -944,6 +944,81 @@ private PDAcroForm getAcroFormSafely(PDDocument document) {
}
}

/**
* Create new AcroForm fields from a list of definitions (used by Auto Form Detection). Reuses
* the same field-creation and appearance logic as the rest of this class, and creates the
* AcroForm (with a Helvetica default resource) when the document has none. Field names are made
* unique against any existing fields.
*/
public void addFields(PDDocument document, List<NewFormFieldDefinition> definitions)
throws IOException {
if (document == null || definitions == null || definitions.isEmpty()) {
return;
}
PDDocumentCatalog documentCatalog = document.getDocumentCatalog();
PDAcroForm acroForm = documentCatalog.getAcroForm();
if (acroForm == null) {
acroForm = new PDAcroForm(document);
PDResources dr = new PDResources();
dr.put(COSName.getPDFName("Helv"), new PDType1Font(Standard14Fonts.FontName.HELVETICA));
acroForm.setDefaultResources(dr);
acroForm.setNeedAppearances(true);
documentCatalog.setAcroForm(acroForm);
}

Set<String> existingNames = new java.util.HashSet<>();
for (PDField field : acroForm.getFieldTree()) {
if (field.getPartialName() != null) {
existingNames.add(field.getPartialName());
}
}

int pageCount = document.getNumberOfPages();
for (NewFormFieldDefinition definition : definitions) {
Integer pageIndex = definition.pageIndex();
if (pageIndex == null
|| pageIndex < 0
|| pageIndex >= pageCount
|| definition.x() == null
|| definition.y() == null
|| definition.width() == null
|| definition.height() == null) {
continue;
}
PDPage page = document.getPage(pageIndex);
PDRectangle rectangle =
new PDRectangle(
definition.x(),
definition.y(),
definition.width(),
definition.height());
FormFieldTypeSupport handler = FormFieldTypeSupport.forTypeName(definition.type());
if (handler == null || handler.doesNotsupportsDefinitionCreation()) {
handler = FormFieldTypeSupport.TEXT;
}
String baseName =
(definition.name() != null && !definition.name().isBlank())
? definition.name()
: handler.typeName() + "_" + (pageIndex + 1);
String uniqueName = generateUniqueFieldName(baseName, existingNames);
existingNames.add(uniqueName);
try {
createNewField(
handler,
acroForm,
page,
rectangle,
uniqueName,
definition,
definition.options());
} catch (Exception e) {
log.warn("Failed to create detected field '{}': {}", uniqueName, e.getMessage());
}
}

ensureAppearances(acroForm);
}

public String filterSingleChoiceSelection(
String selection, List<String> allowedOptions, String fieldName) {
if (selection == null || selection.trim().isEmpty()) return null;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package stirling.software.common.util;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Test;

import stirling.software.common.util.FormUtils.NewFormFieldDefinition;

class FormUtilsAddFieldsTest {

private NewFormFieldDefinition def(String type, int page, float x, float y, float w, float h) {
return new NewFormFieldDefinition(
null, null, type, page, x, y, w, h, false, null, null, null, null);
}

@Test
void createsTextAndCheckboxFieldsOnPagelessDocument() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(new PDRectangle(612, 792)));

FormUtils.addFields(
doc,
List.of(
def("text", 0, 100f, 700f, 200f, 20f),
def("checkbox", 0, 100f, 650f, 15f, 15f)));

PDAcroForm form = doc.getDocumentCatalog().getAcroForm();
assertNotNull(form, "AcroForm should be created");

List<PDField> fields = new ArrayList<>();
form.getFieldTree().forEach(fields::add);
assertEquals(2, fields.size());

boolean hasText = fields.stream().anyMatch(f -> f instanceof PDTextField);
boolean hasCheckbox = fields.stream().anyMatch(f -> f instanceof PDCheckBox);
assertTrue(hasText, "expected a text field");
assertTrue(hasCheckbox, "expected a checkbox field");
}
}

@Test
void skipsOutOfRangePageAndKeepsNamesUnique() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(new PDRectangle(612, 792)));

FormUtils.addFields(
doc,
List.of(
def("text", 0, 10f, 10f, 50f, 12f),
def("text", 0, 10f, 40f, 50f, 12f),
def("text", 5, 10f, 70f, 50f, 12f))); // page 5 out of range -> skipped

PDAcroForm form = doc.getDocumentCatalog().getAcroForm();
List<PDField> fields = new ArrayList<>();
form.getFieldTree().forEach(fields::add);
assertEquals(2, fields.size());

long distinctNames = fields.stream().map(PDField::getPartialName).distinct().count();
assertEquals(2, distinctNames, "field names must be unique");
}
}

@Test
void noOpOnEmptyDefinitions() throws IOException {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage(new PDRectangle(612, 792)));
FormUtils.addFields(doc, List.of());
// no AcroForm forced into existence when there is nothing to add
assertEquals(null, doc.getDocumentCatalog().getAcroForm());
}
}
}
7 changes: 7 additions & 0 deletions app/core/src/main/resources/settings.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,13 @@ aiEngine:
url: http://localhost:5001 # URL of the Python AI engine
timeoutSeconds: 120 # Timeout in seconds for AI engine requests

formDetection:
enabled: true # Master on/off switch for the Auto Form Detection feature
executionMode: auto # Where detection runs: 'auto' (browser first, server fallback), 'browser' (in-browser WASM only, PDF never leaves the device), or 'server' (backend inference)
activeModelId: "" # Id of the installed Auto Form Detection model (set automatically after an admin installs one)
modelDir: "" # Optional override directory for downloaded .onnx models; blank uses <configs>/models/form-detection
preinstalledModelDir: "" # Read-only dir of models baked into the image (Docker pre-downloads FFDNet-S here); seeded into the model dir on startup. Blank disables.

policies:
# Folder automations can read from and write to the directories you allow here, so treat this as a
# security boundary. Leave allowedFolderRoots empty (default) to disable folder sources/outputs
Expand Down
18 changes: 18 additions & 0 deletions app/proprietary/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ ext {
jwtVersion = '0.13.0'
awsSdkVersion = '2.44.12'
testcontainersMinioVersion = '1.21.4'
// CPU build; self-extracting natives for linux/win/mac x64+arm64. Keep major.minor aligned
// with the frontend onnxruntime-web pin so the same .onnx runs identically on both paths.
// Must be >=1.21 to load opset-22 models: the FFDNet exports are stamped ai.onnx opset 22,
// which 1.19/1.20 hard-reject (ORT_FAIL "support is till opset 21"). 1.26.0 matches the
// onnxruntime build the upstream CommonForms inference reference runs on.
onnxruntimeVersion = '1.26.0'
}

bootRun {
Expand Down Expand Up @@ -72,6 +78,18 @@ dependencies {

implementation 'com.google.code.gson:gson:2.13.2'

// ONNX Runtime (Java) for SERVER-SIDE Auto Form Detection inference.
// Compiled against everywhere so the module always builds, but the multi-platform native
// (~42MB) is only BUNDLED when explicitly requested via -PbundleOnnxRuntime=true - i.e. the
// Docker server image (which then slims it to one Linux arch, ~8MB). Desktop/local/core builds
// ship WITHOUT it: server-side detection cleanly reports "unavailable" and the in-browser
// (onnxruntime-web) engine handles detection instead. Tests always get it.
compileOnly "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
testImplementation "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
if ((project.findProperty('bundleOnnxRuntime') ?: 'false').toString().toBoolean()) {
runtimeOnly "com.microsoft.onnxruntime:onnxruntime:$onnxruntimeVersion"
}

api 'io.micrometer:micrometer-registry-prometheus'

api "io.jsonwebtoken:jjwt-api:$jwtVersion"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package stirling.software.proprietary.formdetection.catalog;

import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import jakarta.annotation.PostConstruct;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import stirling.software.proprietary.formdetection.model.ModelCatalogEntry;

import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;

/** Loads the curated Auto Form Detection model catalog from a bundled JSON resource. */
@Slf4j
@Service
@RequiredArgsConstructor
public class ModelCatalogService {

private static final String CATALOG_RESOURCE = "formdetection/model-catalog.json";

private final ObjectMapper objectMapper;

private volatile List<ModelCatalogEntry> entries = List.of();
private volatile Map<String, ModelCatalogEntry> byId = Map.of();

@PostConstruct
void load() {
try (InputStream is = new ClassPathResource(CATALOG_RESOURCE).getInputStream()) {
List<ModelCatalogEntry> loaded =
objectMapper.readValue(is, new TypeReference<List<ModelCatalogEntry>>() {});
Map<String, ModelCatalogEntry> map = new LinkedHashMap<>();
for (ModelCatalogEntry entry : loaded) {
if (entry.getId() != null && !entry.getId().isBlank()) {
map.put(entry.getId(), entry);
}
}
this.entries = List.copyOf(map.values());
this.byId = Map.copyOf(map);
log.info("Loaded {} Auto Form Detection model catalog entries", entries.size());
} catch (Exception e) {
log.error(
"Failed to load Auto Form Detection model catalog from {}",
CATALOG_RESOURCE,
e);
}
}

public List<ModelCatalogEntry> getAll() {
return entries;
}

public Optional<ModelCatalogEntry> getById(String id) {
return Optional.ofNullable(id == null ? null : byId.get(id));
}
}
Loading
Loading