use std::sync::Arc;
use async_trait::async_trait;
use crate::Result;
use crate::core::config::{ExtractionConfig, OutputFormat};
use crate::core::diagnostics::push_warning;
use crate::extractors::qr::detect_qr_codes;
use crate::plugins::{Plugin, PostProcessor, ProcessingStage, register_post_processor};
use crate::types::internal::InternalDocument;
use crate::types::uri::classify_uri;
use crate::types::{ExtractedDocument, ExtractedUri};
const WARNING_SOURCE: &str = "qr-codes";
const URI_SCHEMES: [&str; 3] = ["http://", "https://", "mailto:"];
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone, Copy, Default)]
pub struct QrCodeProcessor;
impl Plugin for QrCodeProcessor {
fn name(&self) -> &str {
"qr-codes"
}
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl PostProcessor for QrCodeProcessor {
async fn process(&self, result: &mut ExtractedDocument, config: &ExtractionConfig) -> Result<()> {
if config.qr_codes != Some(true) {
return Ok(());
}
let Some(images) = result.images.as_mut() else {
return Ok(());
};
if images.is_empty() {
return Ok(());
}
tracing::info!(
target: "xberg::qr_codes",
images = images.len(),
"running rqrr QR detection across extracted images"
);
let mut payloads: Vec<String> = Vec::new();
let mut lossy_image_indices: Vec<usize> = Vec::new();
for (index, image) in images.iter_mut().enumerate() {
let codes = detect_qr_codes(image.data.as_ref(), Some(image.format.as_ref()));
for code in &codes {
if code.payload.contains(char::REPLACEMENT_CHARACTER) {
lossy_image_indices.push(index);
}
payloads.push(code.payload.clone());
}
image.qr_codes = Some(codes);
}
for index in lossy_image_indices {
push_warning(
&mut result.processing_warnings,
WARNING_SOURCE,
format!(
"A QR code in image {index} decoded to bytes that are not valid UTF-8; the \
undecodable bytes were replaced with U+FFFD and the payload is included as-is"
),
);
}
if payloads.is_empty() {
return Ok(());
}
append_payload_section(result, config, &payloads);
collect_payload_uris(result, &payloads);
Ok(())
}
fn processing_stage(&self) -> ProcessingStage {
ProcessingStage::Middle
}
fn should_process(&self, _result: &ExtractedDocument, config: &ExtractionConfig) -> bool {
config.qr_codes == Some(true)
}
fn priority(&self) -> i32 {
50
}
}
fn append_payload_section(result: &mut ExtractedDocument, config: &ExtractionConfig, payloads: &[String]) {
append_section(&mut result.content, &markdown_section(payloads));
let renders_as_text = match config.output_format {
OutputFormat::Plain | OutputFormat::Markdown | OutputFormat::Djot | OutputFormat::Html => true,
OutputFormat::Json | OutputFormat::DocTags | OutputFormat::Custom(_) => false,
};
if renders_as_text {
if let Some(formatted) = result.formatted_content.as_mut() {
let section = if config.output_format == OutputFormat::Html {
html_section(payloads)
} else {
markdown_section(payloads)
};
append_section(formatted, §ion);
}
return;
}
if result.formatted_content.is_some() {
push_warning(
&mut result.processing_warnings,
WARNING_SOURCE,
format!(
"{} decoded QR payload(s) could not be merged into the requested output format \
because it is produced verbatim by a renderer; the payloads are available on \
the per-image `qr_codes` field but are absent from the returned content, \
chunks and embeddings",
payloads.len()
),
);
}
}
fn collect_payload_uris(result: &mut ExtractedDocument, payloads: &[String]) {
if !payloads.iter().any(|payload| is_uri_payload(payload)) {
return;
}
let uris = result.uris.get_or_insert_with(Vec::new);
let mut dropped = 0usize;
for payload in payloads.iter().filter(|payload| is_uri_payload(payload)) {
let kind = classify_uri(payload);
if uris.iter().any(|uri| uri.url == *payload && uri.kind == kind) {
continue;
}
if uris.len() >= InternalDocument::MAX_URIS {
dropped += 1;
continue;
}
uris.push(ExtractedUri {
url: payload.clone(),
label: None,
page: None,
kind,
});
}
if dropped > 0 {
push_warning(
&mut result.processing_warnings,
"uris",
format!(
"{dropped} QR payload URI(s) were dropped at the per-document limit of {} and \
are missing from the result",
InternalDocument::MAX_URIS
),
);
}
}
fn is_uri_payload(payload: &str) -> bool {
URI_SCHEMES.iter().any(|scheme| {
payload.len() > scheme.len()
&& payload
.get(..scheme.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme))
})
}
fn markdown_section(payloads: &[String]) -> String {
let mut section = String::from("## QR Codes\n\n");
for payload in payloads {
section.push_str("- ");
section.push_str(payload);
section.push('\n');
}
section
}
fn html_section(payloads: &[String]) -> String {
let mut section = String::from("<h2>QR Codes</h2>\n<ul>\n");
for payload in payloads {
section.push_str("<li>");
section.push_str(&escape_html(payload));
section.push_str("</li>\n");
}
section.push_str("</ul>\n");
section
}
fn escape_html(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for character in text.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
_ => escaped.push(character),
}
}
escaped
}
fn append_section(target: &mut String, section: &str) {
if !target.is_empty() {
if !target.ends_with('\n') {
target.push('\n');
}
if !target.ends_with("\n\n") {
target.push('\n');
}
}
target.push_str(section);
}
#[cfg_attr(alef, alef(skip))]
pub fn register() -> Result<()> {
register_post_processor(Arc::new(QrCodeProcessor))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExtractedImage;
use bytes::Bytes;
use std::borrow::Cow;
fn empty_result_with_image() -> ExtractedDocument {
ExtractedDocument {
content: String::new(),
mime_type: Cow::Borrowed("text/plain"),
images: Some(vec![ExtractedImage {
data: Bytes::from_static(&[]),
format: Cow::Borrowed("png"),
..Default::default()
}]),
..Default::default()
}
}
#[test]
fn processor_metadata_is_correct() {
let p = QrCodeProcessor;
assert_eq!(p.name(), "qr-codes");
assert_eq!(p.processing_stage(), ProcessingStage::Middle);
}
#[test]
fn should_process_only_when_enabled() {
let p = QrCodeProcessor;
let result = empty_result_with_image();
assert!(!p.should_process(&result, &ExtractionConfig::default()));
let cfg = ExtractionConfig {
qr_codes: Some(false),
..Default::default()
};
assert!(!p.should_process(&result, &cfg));
let cfg = ExtractionConfig {
qr_codes: Some(true),
..Default::default()
};
assert!(p.should_process(&result, &cfg));
}
#[tokio::test]
async fn empty_image_bytes_yield_empty_vec() {
let p = QrCodeProcessor;
let mut result = empty_result_with_image();
let cfg = ExtractionConfig {
qr_codes: Some(true),
..Default::default()
};
p.process(&mut result, &cfg).await.unwrap();
let images = result.images.as_ref().expect("images should still be Some");
assert_eq!(images[0].qr_codes.as_deref(), Some(&[][..]));
}
#[tokio::test]
async fn disabled_config_leaves_field_none() {
let p = QrCodeProcessor;
let mut result = empty_result_with_image();
let cfg = ExtractionConfig::default();
p.process(&mut result, &cfg).await.unwrap();
let images = result.images.as_ref().expect("images should still be Some");
assert!(images[0].qr_codes.is_none());
}
}