Skip to content

Creating Plugins

Extend Xberg with custom extractors, post-processors, OCR backends, and validators registered globally for use across all extraction calls.

Type Purpose Use case
DocumentExtractor Extract content from file formats New format support, override built-in extractors
PostProcessor Transform extraction results Metadata enrichment, content filtering, text normalization
OcrBackend Perform OCR on images Cloud OCR services, custom OCR engines
Validator Validate extraction quality Minimum content length, quality score thresholds
EmbeddingBackend Generate embedding vectors Custom embedding models, RAG pipelines
RerankerBackend Score query/document pairs Cross-encoder reranking of retrieved chunks
TokenizerBackend Count tokens for chunk boundaries Model-specific tokenizers, token-aware chunk sizing
Renderer Convert results to output formats Custom Markdown, HTML, Djot, or plain-text renderers

All plugins must be thread-safe (Send + Sync in Rust, thread-safe in Python) and implement initialize() / shutdown() lifecycle methods.

Rust
use xberg::plugins::{DocumentExtractor, Plugin};
use xberg::{Result, ExtractedDocument, ExtractionConfig, Metadata};
use async_trait::async_trait;
use std::path::Path;
struct CustomJsonExtractor;
impl Plugin for CustomJsonExtractor {
fn name(&self) -> &str { "custom-json-extractor" }
fn version(&self) -> String { "1.0.0".to_string() }
fn initialize(&self) -> Result<()> { Ok(()) }
fn shutdown(&self) -> Result<()> { Ok(()) }
}
#[async_trait]
impl DocumentExtractor for CustomJsonExtractor {
async fn extract(
&self,
content: &[u8],
_mime_type: &str,
_config: &ExtractionConfig,
) -> Result<ExtractedDocument> {
let json: serde_json::Value = serde_json::from_slice(content)?;
let text = extract_text_from_json(&json);
Ok(ExtractedDocument {
content: text,
mime_type: "application/json".to_string(),
metadata: Metadata::default(),
tables: vec![],
detected_languages: None,
chunks: None,
images: None,
})
}
fn supported_mime_types(&self) -> &[&str] {
&["application/json", "text/json"]
}
fn priority(&self) -> i32 { 50 }
}
fn extract_text_from_json(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("{}\n", s),
serde_json::Value::Array(arr) => arr.iter().map(extract_text_from_json).collect(),
serde_json::Value::Object(obj) => obj.values().map(extract_text_from_json).collect(),
_ => String::new(),
}
}
Python
from xberg import register_document_extractor
class CustomExtractor:
def name(self) -> str:
return "custom"
def version(self) -> str:
return "1.0.0"
extractor = CustomExtractor()
register_document_extractor(extractor)
print("Extractor registered")

When multiple extractors support the same MIME type, the highest priority wins:

Range Level
0–25 Fallback / low-quality
26–49 Alternative
50 Default (built-in)
51–75 Enhanced / premium
76–100 Specialized / high-priority

Processors execute in three stages:

  • Early — Foundational: language detection, quality scoring, text normalization
  • Middle — Transformation: keyword extraction, token reduction, summarization
  • Late — Final: custom metadata, analytics, output formatting
Rust
use xberg::plugins::{Plugin, PostProcessor, ProcessingStage};
use xberg::{Result, ExtractedDocument, ExtractionConfig};
use async_trait::async_trait;
struct WordCountProcessor;
impl Plugin for WordCountProcessor {
fn name(&self) -> &str { "word-count" }
fn version(&self) -> String { "1.0.0".to_string() }
fn initialize(&self) -> Result<()> { Ok(()) }
fn shutdown(&self) -> Result<()> { Ok(()) }
}
#[async_trait]
impl PostProcessor for WordCountProcessor {
async fn process(
&self,
result: &mut ExtractedDocument,
_config: &ExtractionConfig
) -> Result<()> {
let word_count = result.content.split_whitespace().count();
result.processing_warnings.push(ProcessingWarning {
source: "word-count".to_string(),
message: format!("Processed with word count: {}", word_count)
});
Ok(())
}
fn processing_stage(&self) -> ProcessingStage {
ProcessingStage::Early
}
fn should_process(
&self,
result: &ExtractedDocument,
_config: &ExtractionConfig
) -> bool {
!result.content.is_empty()
}
}
Python
from xberg import ExtractedDocument, register_post_processor
class PdfOnlyProcessor:
def name(self) -> str:
return "pdf-only-processor"
def version(self) -> str:
return "1.0.0"
def process(self, result: ExtractedDocument) -> ExtractedDocument:
return result
def should_process(self, result: ExtractedDocument) -> bool:
return result.mime_type == "application/pdf"
processor: PdfOnlyProcessor = PdfOnlyProcessor()
register_post_processor(processor)
Rust
use xberg::plugins::{Plugin, OcrBackend, OcrBackendType};
use xberg::{Result, ExtractedDocument, OcrConfig, Metadata};
use async_trait::async_trait;
use std::path::Path;
struct CloudOcrBackend {
api_key: String,
supported_langs: Vec<String>,
}
impl Plugin for CloudOcrBackend {
fn name(&self) -> &str { "cloud-ocr" }
fn version(&self) -> String { "1.0.0".to_string() }
fn initialize(&self) -> Result<()> { Ok(()) }
fn shutdown(&self) -> Result<()> { Ok(()) }
}
#[async_trait]
impl OcrBackend for CloudOcrBackend {
async fn process_image(
&self,
image_bytes: &[u8],
config: &OcrConfig,
) -> Result<ExtractedDocument> {
let text = self.call_cloud_api(image_bytes, &config.language).await?;
Ok(ExtractedDocument {
content: text,
mime_type: "text/plain".to_string(),
metadata: Metadata::default(),
tables: vec![],
detected_languages: None,
chunks: None,
images: None,
})
}
fn supports_language(&self, lang: &str) -> bool {
self.supported_langs.iter().any(|l| l == lang)
}
fn backend_type(&self) -> OcrBackendType {
OcrBackendType::Custom
}
fn supported_languages(&self) -> Vec<String> {
self.supported_langs.clone()
}
}
impl CloudOcrBackend {
async fn call_cloud_api(
&self,
image: &[u8],
language: &str
) -> Result<String> {
Ok("Extracted text".to_string())
}
}

Register the backend and set its name in OcrConfig:

Python
from xberg import register_ocr_backend, unregister_ocr_backend
backend = CloudOcrBackend(api_key="your-api-key")
register_ocr_backend(backend)
from xberg import extract, ExtractionConfig, OcrConfig
config = ExtractionConfig(ocr=OcrConfig(backend="cloud-ocr", language="eng"))
result = extract("scanned.pdf", config=config)
unregister_ocr_backend("cloud-ocr")
Rust
use xberg::plugins::{Plugin, Validator};
use xberg::{Result, ExtractedDocument, ExtractionConfig, XbergError};
use async_trait::async_trait;
struct MinLengthValidator {
min_length: usize,
}
impl Plugin for MinLengthValidator {
fn name(&self) -> &str { "min-length-validator" }
fn version(&self) -> String { "1.0.0".to_string() }
fn initialize(&self) -> Result<()> { Ok(()) }
fn shutdown(&self) -> Result<()> { Ok(()) }
}
#[async_trait]
impl Validator for MinLengthValidator {
async fn validate(
&self,
result: &ExtractedDocument,
_config: &ExtractionConfig,
) -> Result<()> {
if result.content.len() < self.min_length {
return Err(XbergError::validation(format!(
"Content too short: {} < {} characters",
result.content.len(),
self.min_length
)));
}
Ok(())
}
fn priority(&self) -> i32 {
100
}
}
Rust
#[async_trait]
impl Validator for QualityValidator {
async fn validate(
&self,
result: &ExtractedDocument,
_config: &ExtractionConfig,
) -> Result<()> {
let score = result.metadata
.additional
.get("quality_score")
.and_then(|v| v.as_f64())
.unwrap_or(0.0);
if score < 0.5 {
return Err(XbergError::validation(format!(
"Quality score too low: {:.2} < 0.50",
score
)));
}
Ok(())
}
}
Python
from xberg import (
list_document_extractors,
list_post_processors,
list_ocr_backends,
list_validators,
)
extractors: list[str] = list_document_extractors()
processors: list[str] = list_post_processors()
ocr_backends: list[str] = list_ocr_backends()
validators: list[str] = list_validators()
print(f"Extractors: {extractors}")
print(f"Processors: {processors}")
print(f"OCR backends: {ocr_backends}")
print(f"Validators: {validators}")
Python
from xberg import (
unregister_document_extractor,
unregister_post_processor,
unregister_ocr_backend,
unregister_validator,
)
names: list[str] = [
"custom-json-extractor",
"word_count",
"cloud-ocr",
"min_length_validator",
]
unregister_document_extractor(names[0])
unregister_post_processor(names[1])
unregister_ocr_backend(names[2])
unregister_validator(names[3])
Python
from xberg import (
clear_document_extractors,
clear_post_processors,
clear_ocr_backends,
clear_validators,
)
clear_post_processors()
clear_validators()
clear_ocr_backends()
clear_document_extractors()
print("All plugins cleared")
Rust
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicUsize, Ordering};
use xberg::XbergError;
struct StatefulPlugin {
call_count: AtomicUsize,
cache: Mutex<HashMap<String, String>>,
}
impl Plugin for StatefulPlugin {
fn name(&self) -> &str { "stateful-plugin" }
fn version(&self) -> String { "1.0.0".to_string() }
fn initialize(&self) -> Result<()> {
self.call_count.store(0, Ordering::Release);
Ok(())
}
fn shutdown(&self) -> Result<()> {
let count = self.call_count.load(Ordering::Acquire);
println!("Plugin called {} times", count);
Ok(())
}
}
#[async_trait]
impl PostProcessor for StatefulPlugin {
async fn process(
&self,
result: &mut ExtractedDocument,
_config: &ExtractionConfig
) -> Result<()> {
self.call_count.fetch_add(1, Ordering::AcqRel);
let mut cache = self.cache.lock()
.map_err(|_| XbergError::plugin("Cache lock poisoned"))?;
cache.insert("last_mime".to_string(), result.mime_type.clone());
Ok(())
}
fn processing_stage(&self) -> ProcessingStage {
ProcessingStage::Middle
}
}

Naming: Use kebab-case (my-custom-plugin), lowercase only, no spaces or special characters.

Python
import logging
logger = logging.getLogger(__name__)
class MyPlugin:
def name(self) -> str:
return "my-plugin"
def version(self) -> str:
return "1.0.0"
def initialize(self) -> None:
logger.info(f"Initializing plugin: {self.name()}")
def shutdown(self) -> None:
logger.info(f"Shutting down plugin: {self.name()}")
def extract(
self, content: bytes, mime_type: str, config: dict
) -> dict:
logger.info(f"Extracting {mime_type} ({len(content)} bytes)")
result: dict = {"content": "", "mime_type": mime_type}
if not result["content"]:
logger.warning("Extraction resulted in empty content")
return result
Python
import pytest
from xberg import ExtractedDocument
def test_custom_extractor() -> None:
extractor = CustomJsonExtractor()
json_data: bytes = b'{"message": "Hello, world!"}'
config: dict = {}
result: ExtractedDocument = extractor.extract(
json_data, "application/json", config
)
assert "Hello, world!" in result.content
assert result.mime_type == "application/json"
Python
from xberg import register_post_processor, ExtractedDocument
import logging
logger = logging.getLogger(__name__)
class PdfMetadataExtractor:
def __init__(self):
self.processed_count: int = 0
def name(self) -> str:
return "pdf_metadata_extractor"
def version(self) -> str:
return "1.0.0"
def description(self) -> str:
return "Extracts and enriches PDF metadata"
def processing_stage(self) -> str:
return "early"
def should_process(self, result: ExtractedDocument) -> bool:
return result.mime_type == "application/pdf"
def process(self, result: ExtractedDocument) -> ExtractedDocument:
self.processed_count += 1
result.metadata["pdf_processed"] = True
return result
def initialize(self) -> None:
logger.info("PDF metadata extractor initialized")
def shutdown(self) -> None:
logger.info(f"Processed {self.processed_count} PDFs")
processor: PdfMetadataExtractor = PdfMetadataExtractor()
register_post_processor(processor)