use std::borrow::Cow;
use std::io::Cursor;
use async_trait::async_trait;
use bytes::Bytes;
use crate::Result;
use crate::core::config::ExtractionConfig;
use crate::extractors::security::ZipBombValidator;
use crate::plugins::{InternalDocumentExtractor, Plugin};
use crate::types::ExtractedImage;
use crate::types::internal::InternalDocument;
use crate::types::internal_builder::InternalDocumentBuilder;
#[cfg_attr(alef, alef(skip))]
pub struct HwpxExtractor;
impl HwpxExtractor {
pub(crate) fn new() -> Self {
Self
}
}
impl Default for HwpxExtractor {
fn default() -> Self {
Self::new()
}
}
impl Plugin for HwpxExtractor {
fn name(&self) -> &str {
"hwpx-extractor"
}
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn initialize(&self) -> Result<()> {
Ok(())
}
fn shutdown(&self) -> Result<()> {
Ok(())
}
fn description(&self) -> &str {
"Hangul Word Processor XML (.hwpx) text extraction"
}
fn author(&self) -> &str {
"Xberg Team"
}
}
fn mime_to_format(mime: &str) -> Cow<'static, str> {
match mime {
"image/png" => Cow::Borrowed("png"),
"image/jpeg" | "image/jpg" => Cow::Borrowed("jpeg"),
"image/gif" => Cow::Borrowed("gif"),
"image/bmp" => Cow::Borrowed("bmp"),
"image/webp" => Cow::Borrowed("webp"),
_ => Cow::Borrowed("bin"),
}
}
fn build_hwpx_internal_document(doc: unhwp::model::Document, mime_type: &str) -> InternalDocument {
let mut builder = InternalDocumentBuilder::new("hwpx");
builder.set_mime_type(Cow::Owned(mime_type.to_string()));
let mut metadata = crate::types::metadata::Metadata::default();
if let Some(title) = &doc.metadata.title {
metadata.title = Some(title.clone());
}
if let Some(author) = &doc.metadata.author {
metadata.authors = Some(vec![author.clone()]);
}
if let Some(subject) = &doc.metadata.subject {
metadata.subject = Some(subject.clone());
}
if !doc.metadata.keywords.is_empty() {
metadata.keywords = Some(doc.metadata.keywords.clone());
}
if let Some(created) = &doc.metadata.created {
metadata.created_at = Some(created.clone());
}
if let Some(modified) = &doc.metadata.modified {
metadata.modified_at = Some(modified.clone());
}
if let Some(creator_app) = &doc.metadata.creator_app {
metadata.additional.insert(
Cow::Borrowed("creator_app"),
serde_json::Value::String(creator_app.clone()),
);
}
if let Some(version) = &doc.metadata.format_version {
metadata.document_version = Some(version.clone());
}
if !metadata.is_empty() {
builder.set_metadata(metadata);
}
let mut image_index: usize = 0;
for section in &doc.sections {
for block in §ion.content {
match block {
unhwp::model::Block::Paragraph(p) => {
if p.style.is_heading() && p.has_text_content() {
let text = p.plain_text();
let trimmed = text.trim();
if !trimmed.is_empty() {
builder.push_heading(p.style.heading_level, trimmed, None, None);
}
} else if p.has_text_content() {
let text = p.plain_text();
let trimmed = text.trim();
if !trimmed.is_empty() {
builder.push_paragraph(trimmed, vec![], None, None);
}
}
for inline in &p.content {
if let unhwp::model::InlineContent::Image(img_ref) = inline
&& let Some(resource) = doc.resources.get(&img_ref.id)
{
let image = ExtractedImage {
data: Bytes::from(resource.data.clone()),
format: mime_to_format(resource.mime_type.as_deref().unwrap_or("")),
image_index: image_index as u32,
page_number: None,
width: img_ref.width,
height: img_ref.height,
colorspace: None,
bits_per_component: None,
is_mask: false,
description: img_ref.alt_text.clone(),
ocr_result: None,
bounding_box: None,
source_path: None,
image_kind: None,
kind_confidence: None,
cluster_id: None,
caption: None,
qr_codes: None,
data_base64: None,
};
builder.push_image(img_ref.alt_text.as_deref(), image, None, None);
image_index += 1;
}
}
}
unhwp::model::Block::Table(t) => {
if !t.rows.is_empty() {
let cells: Vec<Vec<String>> = t
.rows
.iter()
.map(|row| row.cells.iter().map(|cell| cell.plain_text()).collect())
.collect();
builder.push_table_from_cells(&cells, None, None);
}
}
}
}
}
builder.build()
}
#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
impl InternalDocumentExtractor for HwpxExtractor {
async fn extract_content(
&self,
content: &[u8],
mime_type: &str,
config: &ExtractionConfig,
) -> Result<InternalDocument> {
let limits = config.security_limits.clone().unwrap_or_default();
if content.len() as u64 > limits.max_archive_size as u64 {
return Err(crate::XbergError::validation(format!(
"HWPX file exceeds size limit ({} > {} bytes)",
content.len(),
limits.max_archive_size
)));
}
let cursor = Cursor::new(content);
let mut archive =
zip::ZipArchive::new(cursor).map_err(|e| crate::XbergError::parsing(format!("invalid HWPX zip: {e}")))?;
ZipBombValidator::new(limits)
.validate(&mut archive)
.map_err(|e| crate::XbergError::validation(e.to_string()))?;
let doc = unhwp::parse_bytes(content)
.map_err(|e| crate::XbergError::parsing(format!("Failed to parse HWPX: {e}")))?;
Ok(build_hwpx_internal_document(doc, mime_type))
}
fn supported_mime_types(&self) -> &[&str] {
&["application/haansofthwpx"]
}
fn priority(&self) -> i32 {
50
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hwpx_extractor_plugin_interface() {
let extractor = HwpxExtractor::new();
assert_eq!(extractor.name(), "hwpx-extractor");
assert_eq!(extractor.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(extractor.priority(), 50);
assert_eq!(extractor.supported_mime_types(), &["application/haansofthwpx"]);
}
#[test]
fn test_hwpx_extractor_initialize_shutdown() {
let extractor = HwpxExtractor::new();
assert!(extractor.initialize().is_ok());
assert!(extractor.shutdown().is_ok());
}
#[tokio::test]
async fn test_hwpx_extract_real_document() {
let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../../test_documents/hwpx/simple.hwpx");
let content = std::fs::read(path).expect("test_documents/hwpx/simple.hwpx must exist");
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&content, "application/haansofthwpx", &ExtractionConfig::default())
.await
.expect("extraction of simple.hwpx must succeed");
let text = result.content();
assert!(
text.contains("Hello from HWPX document"),
"expected body text not found; got: {text}"
);
}
#[tokio::test]
async fn test_hwpx_extract_corrupted_returns_err() {
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(b"not a zip", "application/haansofthwpx", &ExtractionConfig::default())
.await;
assert!(result.is_err(), "corrupted input must return Err, not panic");
}
fn make_zip_with_ratio(uncompressed_len: usize) -> Vec<u8> {
use std::io::Write as _;
let mut buf = std::io::Cursor::new(Vec::new());
let mut zw = zip::ZipWriter::new(&mut buf);
let opts = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
zw.start_file("content.hml", opts).unwrap();
zw.write_all(&vec![0u8; uncompressed_len]).unwrap();
zw.finish().unwrap();
buf.into_inner()
}
fn make_zip_with_n_files(n: usize) -> Vec<u8> {
use std::io::Write as _;
let mut buf = std::io::Cursor::new(Vec::new());
let mut zw = zip::ZipWriter::new(&mut buf);
let opts = zip::write::FileOptions::<()>::default().compression_method(zip::CompressionMethod::Stored);
for i in 0..n {
zw.start_file(format!("f{i}.bin"), opts).unwrap();
zw.write_all(b"x").unwrap();
}
zw.finish().unwrap();
buf.into_inner()
}
#[tokio::test]
async fn test_hwpx_rejects_zip_bomb_default_limits() {
let zip_bytes = make_zip_with_ratio(256 * 1024);
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&zip_bytes, "application/haansofthwpx", &ExtractionConfig::default())
.await;
assert!(result.is_err(), "default limits must block a >100:1 zip bomb");
let err = result.unwrap_err().to_string();
assert!(
err.contains("ZIP bomb") || err.contains("ratio") || err.contains("validation"),
"error should mention bomb/ratio/validation, got: {err}"
);
}
#[tokio::test]
async fn test_hwpx_rejects_zip_bomb() {
use crate::extractors::security::SecurityLimits;
let zip_bytes = make_zip_with_ratio(8 * 1024);
let config = ExtractionConfig {
security_limits: Some(SecurityLimits {
max_compression_ratio: 1,
..SecurityLimits::default()
}),
..ExtractionConfig::default()
};
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&zip_bytes, "application/haansofthwpx", &config)
.await;
assert!(result.is_err(), "zip bomb must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("ZIP bomb") || err.contains("ratio") || err.contains("validation"),
"error should mention bomb/ratio/validation, got: {err}"
);
}
#[tokio::test]
async fn test_hwpx_rejects_oversized_file() {
use crate::extractors::security::SecurityLimits;
let limits = SecurityLimits {
max_archive_size: 10,
..SecurityLimits::default()
};
let config = ExtractionConfig {
security_limits: Some(limits),
..ExtractionConfig::default()
};
let oversized = vec![0u8; 11];
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&oversized, "application/haansofthwpx", &config)
.await;
assert!(result.is_err(), "oversized file must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("size limit") || err.contains("validation"),
"error should mention size limit, got: {err}"
);
}
#[tokio::test]
async fn test_hwpx_rejects_too_many_files() {
use crate::extractors::security::SecurityLimits;
let zip_bytes = make_zip_with_n_files(3);
let config = ExtractionConfig {
security_limits: Some(SecurityLimits {
max_files_in_archive: 2,
..SecurityLimits::default()
}),
..ExtractionConfig::default()
};
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&zip_bytes, "application/haansofthwpx", &config)
.await;
assert!(result.is_err(), "archive exceeding file-count limit must be rejected");
let err = result.unwrap_err().to_string();
assert!(
err.contains("files") || err.contains("count") || err.contains("validation"),
"error should mention file count, got: {err}"
);
}
#[tokio::test]
async fn test_hwpx_valid_zip_passes_security_check() {
use crate::extractors::security::SecurityLimits;
let zip_bytes = make_zip_with_ratio(1024);
let config = ExtractionConfig {
security_limits: Some(SecurityLimits {
max_compression_ratio: 10_000,
max_archive_size: 10 * 1024 * 1024,
max_files_in_archive: 1_000,
..SecurityLimits::default()
}),
..ExtractionConfig::default()
};
let extractor = HwpxExtractor::new();
let result = extractor
.extract_content(&zip_bytes, "application/haansofthwpx", &config)
.await;
let is_parse_err = match &result {
Err(e) => {
let msg = e.to_string();
!msg.contains("ZIP bomb")
&& !msg.contains("ratio")
&& !msg.contains("size limit")
&& !msg.contains("too many files")
}
Ok(_) => true,
};
assert!(
is_parse_err,
"security validator must not reject a safe ZIP; got: {result:?}"
);
}
}