diff --git a/docs/usage.md b/docs/usage.md
index 0dc13b07a..20d4c1189 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -654,6 +654,19 @@ happened while it was still encrypted, that original version will likewise be mi
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
multiple PDF types with different passwords, create separate workflows for each use case.
+##### Remote OCR {#workflow-action-remote-ocr}
+
+"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
+locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
+to `workflow_only` then add this action to a workflow that matches only the documents you
+want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
+a **Consumption Started** trigger.
+
+The action takes no options, its presence is what enables remote OCR for a matching document.
+
+If the remote engine is not configured, or does not support the document's file type, the document is
+processed locally instead and a warning is written to the log.
+
#### Workflow placeholders
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
@@ -1098,7 +1111,8 @@ or page limitations (e.g. with a free tier).
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
-unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents.
+unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
+engine to particular documents.
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
index 0e3329ea3..3827c41e6 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html
@@ -455,6 +455,13 @@
}
+ @case (WorkflowActionType.RemoteOcr) {
+
+
+
The document will be sent to the configured remote OCR service. May incur costs.
+
+
+ }
}
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
index bc7be5fad..dfe713c6d 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.spec.ts
@@ -29,6 +29,7 @@ import {
DocumentSource,
WorkflowTriggerType,
} from 'src/app/data/workflow-trigger'
+import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
@@ -224,7 +225,12 @@ describe('WorkflowEditDialogComponent', () => {
).toEqual('Document Added')
expect(component.getTriggerTypeOptionName(null)).toEqual('')
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
- expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
+ // Remote OCR is absent until the workflow has a consumption trigger
+ expect(component.actionTypeOptions).toEqual(
+ WORKFLOW_ACTION_OPTIONS.filter(
+ (a) => a.id !== WorkflowActionType.RemoteOcr
+ )
+ )
expect(
component.getActionTypeOptionName(WorkflowActionType.Assignment)
).toEqual('Assignment')
@@ -237,7 +243,104 @@ describe('WorkflowEditDialogComponent', () => {
jest.spyOn(settingsService, 'get').mockReturnValue(false)
component.ngOnInit()
expect(component.actionTypeOptions).toEqual(
- WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
+ WORKFLOW_ACTION_OPTIONS.filter(
+ (a) =>
+ a.id !== WorkflowActionType.Email &&
+ a.id !== WorkflowActionType.RemoteOcr
+ )
+ )
+ })
+
+ it('should offer remote OCR only for consumption workflows', () => {
+ jest.spyOn(settingsService, 'get').mockReturnValue(true)
+
+ // A consumption trigger makes the action reachable
+ component.object = {
+ name: 'Workflow 1',
+ order: 0,
+ enabled: true,
+ triggers: [{ type: WorkflowTriggerType.Consumption }],
+ actions: [],
+ } as Workflow
+ component.ngOnInit()
+ expect(component.actionTypeOptions.map((a) => a.id)).toContain(
+ WorkflowActionType.RemoteOcr
+ )
+
+ // Any other trigger type runs after the document has been parsed
+ component.object = {
+ name: 'Workflow 2',
+ order: 0,
+ enabled: true,
+ triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
+ actions: [],
+ } as Workflow
+ component.ngOnInit()
+ expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
+ WorkflowActionType.RemoteOcr
+ )
+ })
+
+ it('should offer remote OCR on a trigger added to a new workflow', () => {
+ jest.spyOn(settingsService, 'get').mockReturnValue(true)
+ component.ngOnInit()
+
+ // Nothing for the action to apply to yet
+ expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
+ WorkflowActionType.RemoteOcr
+ )
+
+ // addTrigger creates the form field with emitEvent false, so the options
+ // have to be computed on read rather than cached from valueChanges
+ component.addTrigger()
+ expect(component.actionTypeOptions.map((a) => a.id)).toContain(
+ WorkflowActionType.RemoteOcr
+ )
+
+ // Switching that trigger to a type that runs after parsing removes it
+ component.triggerFields
+ .at(0)
+ .get('type')
+ .setValue(WorkflowTriggerType.DocumentAdded)
+ expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
+ WorkflowActionType.RemoteOcr
+ )
+ })
+
+ it('should keep remote OCR listed when an action already uses it', () => {
+ jest.spyOn(settingsService, 'get').mockReturnValue(true)
+
+ // Otherwise changing the trigger would silently blank the selection
+ component.object = {
+ name: 'Workflow 1',
+ order: 0,
+ enabled: true,
+ triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
+ actions: [{ type: WorkflowActionType.RemoteOcr }],
+ } as Workflow
+ component.ngOnInit()
+
+ expect(component.actionTypeOptions.map((a) => a.id)).toContain(
+ WorkflowActionType.RemoteOcr
+ )
+ })
+
+ it('should not offer remote OCR when no engine is configured', () => {
+ jest
+ .spyOn(settingsService, 'get')
+ .mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
+
+ component.object = {
+ name: 'Workflow 1',
+ order: 0,
+ enabled: true,
+ triggers: [{ type: WorkflowTriggerType.Consumption }],
+ actions: [],
+ } as Workflow
+ component.ngOnInit()
+
+ expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
+ WorkflowActionType.RemoteOcr
)
})
diff --git a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
index 8e8dd6ef7..ed66e61dc 100644
--- a/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
+++ b/src-ui/src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts
@@ -148,6 +148,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.MoveToTrash,
name: $localize`Move to trash`,
},
+ {
+ id: WorkflowActionType.RemoteOcr,
+ name: $localize`Remote OCR`,
+ },
]
export enum TriggerFilterType {
@@ -504,8 +508,6 @@ export class WorkflowEditDialogComponent
expandedItem: number = null
- readonly allowedActionTypes = signal([])
-
private readonly triggerFilterOptionsMap = new WeakMap<
FormArray,
TriggerFilterOption[]
@@ -548,13 +550,40 @@ export class WorkflowEditDialogComponent
this.checkRemovalActionFields.bind(this)
)
this.checkRemovalActionFields(this.objectForm.value)
- this.allowedActionTypes.set(
- this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
- ? WORKFLOW_ACTION_OPTIONS
- : WORKFLOW_ACTION_OPTIONS.filter(
- (a) => a.id !== WorkflowActionType.Email
- )
- )
+ }
+
+ private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
+
+ private getAllowedActionTypes() {
+ let allowed = WORKFLOW_ACTION_OPTIONS
+
+ if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
+ allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
+ }
+
+ // Remote OCR is decided before the document is parsed, so it is only
+ // offered for workflows that run at consumption.
+ const formWorkflow: Workflow = this.objectForm?.value
+ const remoteOcrUsable =
+ this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
+ (formWorkflow?.triggers?.some(
+ (trigger) => trigger.type === WorkflowTriggerType.Consumption
+ ) ||
+ formWorkflow?.actions?.some(
+ (action) => action.type === WorkflowActionType.RemoteOcr
+ ))
+ if (!remoteOcrUsable) {
+ allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
+ }
+
+ if (
+ this.allowedActionTypes?.length === allowed.length &&
+ this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
+ ) {
+ return this.allowedActionTypes
+ }
+ this.allowedActionTypes = allowed
+ return allowed
}
private checkRemovalActionFields(formWorkflow: Workflow) {
@@ -1279,7 +1308,8 @@ export class WorkflowEditDialogComponent
get actionTypeOptions() {
this.settingsService.trackChanges()
- return this.allowedActionTypes()
+ // Computed on read rather than cached
+ return this.getAllowedActionTypes()
}
getActionTypeOptionName(type: WorkflowActionType): string {
diff --git a/src-ui/src/app/data/workflow-action.ts b/src-ui/src/app/data/workflow-action.ts
index 5ddaeba7e..09ef5418f 100644
--- a/src-ui/src/app/data/workflow-action.ts
+++ b/src-ui/src/app/data/workflow-action.ts
@@ -7,6 +7,7 @@ export enum WorkflowActionType {
Webhook = 4,
PasswordRemoval = 5,
MoveToTrash = 6,
+ RemoteOcr = 7,
}
export interface WorkflowActionEmail extends ObjectWithId {
diff --git a/src/documents/consumer.py b/src/documents/consumer.py
index 683f8f11a..79d0cfa8b 100644
--- a/src/documents/consumer.py
+++ b/src/documents/consumer.py
@@ -473,6 +473,16 @@ class ConsumerPlugin(
f"Unsupported mime type {mime_type}",
)
+ if self.metadata.remote_ocr and not getattr(
+ parser_class,
+ "uses_remote_service",
+ False,
+ ):
+ self.log.warning(
+ "Remote OCR was requested for this document but no remote "
+ "parser is available for it, processing locally instead.",
+ )
+
# Notify all listeners that we're going to do some work.
document_consumption_started.send(
diff --git a/src/documents/migrations/0024_alter_workflowaction_type.py b/src/documents/migrations/0024_alter_workflowaction_type.py
new file mode 100644
index 000000000..781c0516e
--- /dev/null
+++ b/src/documents/migrations/0024_alter_workflowaction_type.py
@@ -0,0 +1,30 @@
+# Generated by Django 5.2.16 on 2026-08-10 17:27
+
+from django.db import migrations
+from django.db import models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("documents", "0023_savedview_icon"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="workflowaction",
+ name="type",
+ field=models.PositiveSmallIntegerField(
+ choices=[
+ (1, "Assignment"),
+ (2, "Removal"),
+ (3, "Email"),
+ (4, "Webhook"),
+ (5, "Password removal"),
+ (6, "Move to trash"),
+ (7, "Remote OCR"),
+ ],
+ default=1,
+ verbose_name="Workflow Action Type",
+ ),
+ ),
+ ]
diff --git a/src/documents/models.py b/src/documents/models.py
index 01b87ba56..2e46bfc57 100644
--- a/src/documents/models.py
+++ b/src/documents/models.py
@@ -1670,6 +1670,10 @@ class WorkflowAction(models.Model):
6,
_("Move to trash"),
)
+ REMOTE_OCR = (
+ 7,
+ _("Remote OCR"),
+ )
type = models.PositiveSmallIntegerField(
_("Workflow Action Type"),
diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py
index 2c653b40d..c9dc2845b 100644
--- a/src/documents/serialisers.py
+++ b/src/documents/serialisers.py
@@ -3312,6 +3312,41 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
"actions",
]
+ def validate(self, attrs):
+ attrs = super().validate(attrs)
+
+ if "actions" in attrs:
+ has_remote_ocr_action = any(
+ action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
+ for action in attrs["actions"]
+ )
+ else:
+ has_remote_ocr_action = self.instance is not None and (
+ self.instance.actions.filter(
+ type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ ).exists()
+ )
+
+ if "triggers" in attrs:
+ has_consumption_trigger = any(
+ trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
+ for trigger in attrs["triggers"]
+ )
+ else:
+ has_consumption_trigger = self.instance is not None and (
+ self.instance.triggers.filter(
+ type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ ).exists()
+ )
+
+ # Remote OCR can only work with consumption triggers
+ if has_remote_ocr_action and not has_consumption_trigger:
+ raise serializers.ValidationError(
+ "Remote OCR actions require a consumption started trigger",
+ )
+
+ return attrs
+
def update_triggers_and_actions(
self,
instance: Workflow,
diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py
index 66de47ac4..87fa47643 100644
--- a/src/documents/signals/handlers.py
+++ b/src/documents/signals/handlers.py
@@ -973,6 +973,17 @@ def run_workflows(
)
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
has_move_to_trash_action = True
+ elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR:
+ if use_overrides and overrides:
+ overrides.remote_ocr = True
+ else:
+ # If a workflow has a consumption trigger *and* another type,
+ # the document has already been parsed by the time the other one fires
+ logger.debug(
+ "Remote OCR action only applies to consumption "
+ "triggers, ignoring",
+ extra={"group": logging_group},
+ )
if not use_overrides:
# limit title to 128 characters
diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py
index f3d51d686..788207751 100644
--- a/src/documents/tests/test_api_workflows.py
+++ b/src/documents/tests/test_api_workflows.py
@@ -506,6 +506,141 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(Workflow.objects.count(), 1)
+ def test_api_create_remote_ocr_action_requires_consumption_trigger(
+ self,
+ ) -> None:
+ """
+ GIVEN:
+ - API request to create a workflow with a remote OCR action
+ - No consumption started trigger, so the action could never run
+ WHEN:
+ - API is called
+ THEN:
+ - Correct HTTP 400 response
+ - No objects are created
+ """
+ existing_count = Workflow.objects.count()
+
+ response = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "name": "Remote OCR too late",
+ "order": 1,
+ "triggers": [
+ {
+ "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
+ },
+ ],
+ "actions": [
+ {
+ "type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ },
+ ],
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(Workflow.objects.count(), existing_count)
+
+ def test_api_create_remote_ocr_action_with_consumption_trigger(self) -> None:
+ """
+ GIVEN:
+ - API request to create a workflow with a remote OCR action
+ - A consumption started trigger alongside another trigger type
+ WHEN:
+ - API is called
+ THEN:
+ - The workflow is created, the action applies to consumption only
+ """
+ response = self.client.post(
+ self.ENDPOINT,
+ json.dumps(
+ {
+ "name": "Remote OCR on consume",
+ "order": 1,
+ "triggers": [
+ {
+ "type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ "filter_filename": "*.pdf",
+ },
+ {
+ "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
+ },
+ ],
+ "actions": [
+ {
+ "type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ },
+ ],
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_201_CREATED)
+
+ def test_api_partial_update_adds_remote_ocr_action(self) -> None:
+ """
+ GIVEN:
+ - An existing workflow with a consumption started trigger
+ WHEN:
+ - A partial update adds a remote OCR action without resubmitting triggers
+ THEN:
+ - The existing trigger is considered and the update succeeds
+ """
+ response = self.client.patch(
+ f"{self.ENDPOINT}{self.workflow.id}/",
+ json.dumps(
+ {
+ "actions": [
+ {
+ "type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ },
+ ],
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ self.assertEqual(
+ self.workflow.actions.get().type,
+ WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ )
+
+ def test_api_partial_update_cannot_remove_remote_ocr_trigger(self) -> None:
+ """
+ GIVEN:
+ - An existing workflow with a remote OCR action
+ - An existing consumption started trigger
+ WHEN:
+ - A partial update replaces the trigger without resubmitting actions
+ THEN:
+ - The existing action is considered and the update is rejected
+ """
+ self.action.type = WorkflowAction.WorkflowActionType.REMOTE_OCR
+ self.action.save()
+
+ response = self.client.patch(
+ f"{self.ENDPOINT}{self.workflow.id}/",
+ json.dumps(
+ {
+ "triggers": [
+ {
+ "type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ },
+ ],
+ },
+ ),
+ content_type="application/json",
+ )
+
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertEqual(self.workflow.triggers.get(), self.trigger)
+
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
"""
GIVEN:
diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py
index 574cecf4f..5d4ba6598 100644
--- a/src/documents/tests/test_workflows.py
+++ b/src/documents/tests/test_workflows.py
@@ -5409,3 +5409,82 @@ class TestDateWorkflowLocalization(
document = Document.objects.first()
assert document is not None
assert document.title == expected_title
+
+
+class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase):
+ def _make_workflow(self, trigger_type) -> None:
+ trigger = WorkflowTrigger.objects.create(type=trigger_type)
+ action = WorkflowAction.objects.create(
+ type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ )
+ w = Workflow.objects.create(name="Remote OCR", order=0)
+ w.triggers.add(trigger)
+ w.actions.add(action)
+ w.save()
+
+ def test_consumption_trigger_requests_remote_ocr(self) -> None:
+ """
+ GIVEN:
+ - A consumption workflow with a remote OCR action
+ WHEN:
+ - A matching document is consumed
+ THEN:
+ - The overrides ask for remote OCR, which is what the consumer
+ reads when choosing a parser
+ """
+ self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION)
+
+ test_file = shutil.copy(
+ self.SAMPLE_DIR / "simple.pdf",
+ self.dirs.scratch_dir / "simple.pdf",
+ )
+ overrides = DocumentMetadataOverrides()
+
+ run_workflows(
+ WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ ConsumableDocument(
+ source=DocumentSource.ConsumeFolder,
+ original_file=test_file,
+ ),
+ overrides=overrides,
+ )
+
+ self.assertTrue(overrides.remote_ocr)
+
+ def test_other_trigger_types_are_ignored(self) -> None:
+ """
+ GIVEN:
+ - A workflow with a remote OCR action that also has a
+ non-consumption trigger, which is a valid combination
+ WHEN:
+ - The non-consumption trigger fires
+ THEN:
+ - The action is skipped, since the document has already been
+ parsed by this point
+ """
+ trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
+ )
+ updated_trigger = WorkflowTrigger.objects.create(
+ type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ )
+ action = WorkflowAction.objects.create(
+ type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
+ )
+ w = Workflow.objects.create(name="Remote OCR", order=0)
+ w.triggers.add(trigger, updated_trigger)
+ w.actions.add(action)
+ w.save()
+
+ doc = Document.objects.create(
+ title="sample test",
+ original_filename="sample.pdf",
+ )
+
+ with self.assertLogs("paperless.handlers", level="DEBUG") as cm:
+ run_workflows(
+ WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
+ doc,
+ )
+
+ self.assertIn("only applies to consumption triggers", "".join(cm.output))