use std::borrow::Cow;
use std::io::{Cursor, Read, Seek};
use ahash::AHashMap;
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::document_structure::{AnnotationKind, ContentLayer, TextAnnotation};
use crate::types::internal::InternalDocument;
use crate::types::internal_builder::InternalDocumentBuilder;
const HWPX_WARNING_SOURCE: &str = "hwpx";
const MAX_HWPX_MEMBER_SIZE: u64 = 100 * 1024 * 1024;
#[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"),
"image/svg+xml" => Cow::Borrowed("svg"),
"image/x-wmf" => Cow::Borrowed("wmf"),
"image/x-emf" => Cow::Borrowed("emf"),
_ => Cow::Borrowed("bin"),
}
}
fn collect_section_formulas<R: Read + Seek>(archive: &mut zip::ZipArchive<R>) -> AHashMap<usize, Vec<(usize, String)>> {
use crate::utils::xml_utils::EntityReader;
use quick_xml::events::Event;
let mut section_parts: Vec<(usize, String)> = archive
.file_names()
.filter_map(|name| section_index_of(name).map(|digits| (digits, name.to_string())))
.collect();
section_parts.sort();
let section_parts: Vec<(usize, String)> = section_parts
.into_iter()
.enumerate()
.map(|(position, (_, name))| (position, name))
.collect();
let mut per_section: AHashMap<usize, Vec<(usize, String)>> = AHashMap::new();
for (section_index, name) in section_parts {
let mut xml = String::new();
if archive
.by_name(&name)
.ok()
.and_then(|part| part.take(MAX_HWPX_MEMBER_SIZE).read_to_string(&mut xml).ok())
.is_none()
{
continue;
}
let mut formulas: Vec<(usize, String)> = Vec::new();
let mut reader = EntityReader::from_str(&xml);
let mut paragraph_ordinal = 0usize;
let mut paragraph_depth = 0usize;
let mut table_depth = 0usize;
let mut in_equation = false;
let mut in_script = false;
let mut script = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => match local_name(e.name().as_ref()) {
"tbl" => table_depth += 1,
"p" if table_depth == 0 => paragraph_depth += 1,
"equation" | "eqEdit" => in_equation = true,
"script" if in_equation => in_script = true,
_ => {}
},
Ok(Event::Text(t)) if in_script => {
script.push_str(&std::borrow::Cow::Borrowed(t.as_ref()));
}
Ok(Event::End(e)) => match local_name(e.name().as_ref()) {
"tbl" => table_depth = table_depth.saturating_sub(1),
"p" if table_depth == 0 => {
paragraph_depth = paragraph_depth.saturating_sub(1);
if paragraph_depth == 0 {
paragraph_ordinal += 1;
}
}
"script" => in_script = false,
"equation" | "eqEdit" => {
let latex = unhwp::equation::to_latex(std::mem::take(&mut script).trim());
if !latex.trim().is_empty() {
formulas.push((paragraph_ordinal, latex.trim().to_string()));
}
in_equation = false;
}
_ => {}
},
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
}
if !formulas.is_empty() {
per_section.insert(section_index, formulas);
}
}
per_section
}
fn section_index_of(name: &str) -> Option<usize> {
let file = name.rsplit('/').next()?;
let digits = file.strip_prefix("section")?.strip_suffix(".xml")?;
digits.parse().ok()
}
fn local_name(qname: &str) -> &str {
match qname.rsplit_once(':') {
Some((_, local)) => local,
None => qname,
}
}
fn build_hwpx_internal_document(
doc: unhwp::model::Document,
mime_type: &str,
section_formulas: &AHashMap<usize, Vec<(usize, String)>>,
) -> 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;
let mut footnote_counter: u32 = 0;
for section in &doc.sections {
let scanned = section_formulas.get(§ion.index);
let mut next_formula = 0usize;
let mut paragraph_ordinal = 0usize;
if let Some(header_paragraphs) = §ion.header {
push_header_footer_paragraphs(
&mut builder,
header_paragraphs,
ContentLayer::Header,
&mut footnote_counter,
);
}
if let Some(footer_paragraphs) = §ion.footer {
push_header_footer_paragraphs(
&mut builder,
footer_paragraphs,
ContentLayer::Footer,
&mut footnote_counter,
);
}
for block in §ion.content {
match block {
unhwp::model::Block::Paragraph(p) => {
let has_equation = p
.content
.iter()
.any(|c| matches!(c, unhwp::model::InlineContent::Equation(_)));
let (text, annotations) = build_paragraph_content(&mut builder, p, &mut footnote_counter);
let (trimmed, adjusted) = trim_text_and_annotations(&text, annotations);
if p.style.is_heading() && (p.has_text_content() || has_equation) {
if !trimmed.is_empty() {
let idx = builder.push_heading(p.style.heading_level, trimmed, None, None);
if !adjusted.is_empty() {
builder.set_annotations(idx, adjusted);
}
}
} else if (p.has_text_content() || has_equation) && !trimmed.is_empty() {
builder.push_paragraph(trimmed, adjusted, None, None);
}
for inline in &p.content {
if let unhwp::model::InlineContent::Image(img_ref) = inline {
if 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;
} else {
builder.add_warning(crate::core::diagnostics::warning(
HWPX_WARNING_SOURCE,
format!(
"Image reference '{}' has no corresponding entry in the document's \
resources; the image could not be extracted",
img_ref.id
),
));
}
}
}
}
unhwp::model::Block::Table(t) => {
if !t.rows.is_empty() {
let mut cells: Vec<Vec<String>> = Vec::with_capacity(t.rows.len());
for row in &t.rows {
let mut row_cells = Vec::with_capacity(row.cells.len());
for cell in &row.cells {
row_cells.push(cell_plain_text(&mut builder, cell, &mut footnote_counter));
}
cells.push(row_cells);
}
push_table(&mut builder, cells, t.has_header);
}
}
}
if matches!(block, unhwp::model::Block::Paragraph(_)) {
while let Some((ordinal, latex)) = scanned.and_then(|list| list.get(next_formula)) {
if *ordinal > paragraph_ordinal {
break;
}
builder.push_formula(latex, None, None);
next_formula += 1;
}
paragraph_ordinal += 1;
}
}
for (_, latex) in scanned.into_iter().flatten().skip(next_formula) {
builder.push_formula(latex, None, None);
}
}
builder.build()
}
fn push_header_footer_paragraphs(
builder: &mut InternalDocumentBuilder,
paragraphs: &[unhwp::model::Paragraph],
layer: ContentLayer,
footnote_counter: &mut u32,
) {
for p in paragraphs {
let (text, annotations) = build_paragraph_content(builder, p, footnote_counter);
let (trimmed, adjusted) = trim_text_and_annotations(&text, annotations);
if trimmed.is_empty() {
continue;
}
let idx = builder.push_paragraph(trimmed, adjusted, None, None);
builder.set_layer(idx, layer);
}
}
fn build_paragraph_content(
builder: &mut InternalDocumentBuilder,
p: &unhwp::model::Paragraph,
footnote_counter: &mut u32,
) -> (String, Vec<TextAnnotation>) {
let mut text = String::new();
let mut annotations = Vec::new();
let mut after_equation = false;
for item in &p.content {
match item {
unhwp::model::InlineContent::Text(run) => {
let value = if after_equation && text.ends_with(char::is_whitespace) {
run.text.trim_start()
} else {
run.text.as_str()
};
text.push_str(value);
after_equation = false;
}
unhwp::model::InlineContent::LineBreak => text.push('\n'),
unhwp::model::InlineContent::Link { text: link_text, url } => {
let start = text.len() as u32;
text.push_str(link_text);
let end = text.len() as u32;
if start < end {
annotations.push(TextAnnotation {
start,
end,
kind: AnnotationKind::Link {
url: url.clone(),
title: None,
},
});
}
}
unhwp::model::InlineContent::Equation(eq) => {
let latex = eq
.latex
.clone()
.unwrap_or_else(|| unhwp::equation::to_latex(&eq.script));
if !latex.trim().is_empty() {
after_equation = true;
}
}
unhwp::model::InlineContent::Footnote(note_text) => {
*footnote_counter += 1;
let key = format!("hwpx-fn{footnote_counter}");
text.push_str(&format!("[^{footnote_counter}]"));
if !note_text.trim().is_empty() {
let idx = builder.push_footnote_definition(note_text.trim(), &key, None);
builder.set_layer(idx, ContentLayer::Footnote);
}
}
unhwp::model::InlineContent::Image(_) => {
}
}
}
(text, annotations)
}
fn trim_text_and_annotations(text: &str, annotations: Vec<TextAnnotation>) -> (&str, Vec<TextAnnotation>) {
let trimmed = text.trim();
let trim_start = (text.len() - text.trim_start().len()) as u32;
let trimmed_len = trimmed.len() as u32;
let adjusted = annotations
.into_iter()
.filter_map(|mut annotation| {
if annotation.end <= trim_start {
return None;
}
annotation.start = annotation.start.saturating_sub(trim_start).min(trimmed_len);
annotation.end = annotation.end.saturating_sub(trim_start).min(trimmed_len);
if annotation.start >= annotation.end {
None
} else {
Some(annotation)
}
})
.collect();
(trimmed, adjusted)
}
fn cell_plain_text(
builder: &mut InternalDocumentBuilder,
cell: &unhwp::model::TableCell,
footnote_counter: &mut u32,
) -> String {
let mut lines = Vec::with_capacity(cell.content.len());
for p in &cell.content {
if p.content
.iter()
.any(|c| matches!(c, unhwp::model::InlineContent::Image(_)))
{
builder.add_warning(crate::core::diagnostics::warning(
HWPX_WARNING_SOURCE,
"A table cell contains an image; images inside table cells are not \
extracted and were omitted from the output",
));
}
let (text, _annotations) = build_paragraph_content(builder, p, footnote_counter);
lines.push(text.trim().to_string());
}
lines.join("\n")
}
fn push_table(builder: &mut InternalDocumentBuilder, cells: Vec<Vec<String>>, has_header: bool) -> u32 {
let markdown = crate::rendering::common::render_table_markdown(&cells);
let columns = if has_header { cells.first().cloned() } else { None };
let table = crate::types::Table {
cells,
markdown,
columns,
..Default::default()
};
builder.push_table(table, None, None)
}
#[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 section_formulas = collect_section_formulas(&mut archive);
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, §ion_formulas))
}
fn supported_mime_types(&self) -> &[&str] {
&["application/haansofthwpx", "application/hwp+zip"]
}
fn priority(&self) -> i32 {
50
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::internal::ElementKind;
use unhwp::model::{
Block, Document, Equation, InlineContent, Paragraph, Section, Table, TableCell, TableRow, TextRun,
};
fn hwpx_package(sections: &[(&str, &str)]) -> Vec<u8> {
let mut buffer = Vec::new();
{
let mut writer = zip::ZipWriter::new(Cursor::new(&mut buffer));
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
writer.start_file("mimetype", stored).unwrap();
std::io::Write::write_all(&mut writer, b"application/hwp+zip").unwrap();
for (name, xml) in sections {
writer
.start_file(*name, zip::write::SimpleFileOptions::default())
.unwrap();
std::io::Write::write_all(&mut writer, xml.as_bytes()).unwrap();
}
writer.finish().unwrap();
}
buffer
}
fn scan(package: &[u8]) -> AHashMap<usize, Vec<(usize, String)>> {
let mut archive = zip::ZipArchive::new(Cursor::new(package)).unwrap();
collect_section_formulas(&mut archive)
}
#[test]
fn test_a_section_formula_follows_its_paragraph() {
use unhwp::model::{Block, Document, Paragraph, Section, TextRun};
let mut doc = Document::new();
let mut section = Section::new(2);
for text in ["First.", "Second.", "Third."] {
let mut p = Paragraph::new();
p.push_text(TextRun::new(text));
section.content.push(Block::Paragraph(p));
}
doc.sections.push(section);
let scanned: AHashMap<usize, Vec<(usize, String)>> = [(2usize, vec![(2usize, "\\frac{a}{b}".to_string())])]
.into_iter()
.collect();
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &scanned);
let kinds: Vec<&ElementKind> = internal.elements.iter().map(|e| &e.kind).collect();
let formula_at = kinds
.iter()
.position(|k| matches!(k, ElementKind::Formula))
.expect("the equation reaches the document");
assert_eq!(
formula_at,
kinds.len() - 1,
"the equation follows the third paragraph, got {kinds:?}"
);
}
#[test]
fn test_scan_reads_an_equation_inside_a_run() {
let xml = r#"<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:p><hp:run><hp:t>Prose.</hp:t></hp:run></hp:p>
<hp:p><hp:run><hp:equation><hp:script>a OVER b</hp:script></hp:equation></hp:run></hp:p>
</hs:sec>"#;
let found = scan(&hwpx_package(&[("Contents/section0.xml", xml)]));
assert_eq!(
found.get(&0).map(Vec::as_slice),
Some(&[(1usize, "\\frac{a}{b}".to_string())][..])
);
}
#[test]
fn test_scan_resolves_entity_references_in_a_script() {
let xml = r#"<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:p><hp:run><hp:equation><hp:script>bmatrix { 1 & 2 # 3 & 4 }</hp:script></hp:equation></hp:run></hp:p>
</hs:sec>"#;
let found = scan(&hwpx_package(&[("Contents/section0.xml", xml)]));
let latex = &found.get(&0).expect("section 0 has an equation")[0].1;
let expected = unhwp::equation::to_latex("bmatrix { 1 & 2 # 3 & 4 }");
assert_eq!(
latex,
expected.trim(),
"the scan must resolve `&` before conversion"
);
assert_ne!(
latex,
unhwp::equation::to_latex("bmatrix { 1 2 # 3 4 }").trim(),
"a dropped reference must not produce the same LaTeX"
);
}
#[test]
fn test_scan_bounds_the_read_of_an_oversized_section_member() {
let before = r#"<hp:p><hp:run><hp:equation><hp:script>a OVER b</hp:script></hp:equation></hp:run></hp:p>"#;
let padding = "x".repeat(MAX_HWPX_MEMBER_SIZE as usize + 4096);
let after = r#"<hp:p><hp:run><hp:equation><hp:script>p OVER q</hp:script></hp:equation></hp:run></hp:p>"#;
let xml = format!(
"<hs:sec xmlns:hp=\"http://www.hancom.co.kr/hwpml/2011/paragraph\">{before}<!--{padding}-->{after}</hs:sec>"
);
let found = scan(&hwpx_package(&[("Contents/section0.xml", &xml)]));
let formulas = found.get(&0).expect("the equation before the cap is found");
assert_eq!(
formulas.as_slice(),
&[(0usize, "\\frac{a}{b}".to_string())][..],
"only the equation entirely within the first MAX_HWPX_MEMBER_SIZE bytes must be found; \
finding the second equation would mean the read was not actually bounded"
);
}
#[test]
fn test_scan_keys_each_section_by_its_own_index() {
let one = r#"<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:p><hp:run><hp:equation><hp:script>x OVER y</hp:script></hp:equation></hp:run></hp:p>
</hs:sec>"#;
let two = r#"<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:p><hp:run><hp:equation><hp:script>p OVER q</hp:script></hp:equation></hp:run></hp:p>
</hs:sec>"#;
let found = scan(&hwpx_package(&[
("Contents/section0.xml", one),
("Contents/section2.xml", two),
]));
assert_eq!(found.get(&0).map(|f| f[0].1.as_str()), Some("\\frac{x}{y}"));
assert_eq!(found.get(&1).map(|f| f[0].1.as_str()), Some("\\frac{p}{q}"));
assert!(found.get(&2).is_none(), "only two parts exist");
}
#[test]
fn test_scan_does_not_count_a_paragraph_inside_a_table() {
let xml = r#"<hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph">
<hp:p><hp:run><hp:tbl><hp:tr><hp:tc><hp:subList><hp:p><hp:run><hp:t>Cell.</hp:t></hp:run></hp:p></hp:subList></hp:tc></hp:tr></hp:tbl></hp:run></hp:p>
<hp:p><hp:run><hp:equation><hp:script>a OVER b</hp:script></hp:equation></hp:run></hp:p>
</hs:sec>"#;
let found = scan(&hwpx_package(&[("Contents/section0.xml", xml)]));
assert_eq!(
found.get(&0).map(|f| f[0].0),
Some(1),
"the equation is in the second paragraph"
);
}
#[test]
fn test_mime_to_format_maps_svg_wmf_emf() {
assert_eq!(mime_to_format("image/svg+xml"), Cow::Borrowed("svg"));
assert_eq!(mime_to_format("image/x-wmf"), Cow::Borrowed("wmf"));
assert_eq!(mime_to_format("image/x-emf"), Cow::Borrowed("emf"));
assert_eq!(mime_to_format("image/png"), Cow::Borrowed("png"));
assert_eq!(mime_to_format("application/octet-stream"), Cow::Borrowed("bin"));
}
#[test]
fn test_section_header_and_footer_are_extracted() {
let mut doc = Document::new();
let mut section = Section::new(0);
section.header = Some(vec![Paragraph::text("Confidential Draft")]);
section.footer = Some(vec![Paragraph::text("Page footer text")]);
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let header = internal
.elements
.iter()
.find(|e| e.layer == ContentLayer::Header)
.expect("header element must be present");
assert_eq!(header.text, "Confidential Draft");
let footer = internal
.elements
.iter()
.find(|e| e.layer == ContentLayer::Footer)
.expect("footer element must be present");
assert_eq!(footer.text, "Page footer text");
}
#[test]
fn test_footnote_produces_marker_and_definition() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.push_text(TextRun::new("See "));
p.content.push(InlineContent::Footnote("The note body.".to_string()));
p.push_text(TextRun::new(" done"));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let body = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::Paragraph)
.expect("body paragraph must be present");
assert_eq!(body.text, "See [^1] done");
let definition = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::FootnoteDefinition)
.expect("footnote definition element must be present");
assert_eq!(definition.text, "The note body.");
assert_eq!(definition.layer, ContentLayer::Footnote);
}
#[test]
fn test_link_produces_link_annotation_with_correct_offsets() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.push_text(TextRun::new("Go to "));
p.content.push(InlineContent::Link {
text: "our site".to_string(),
url: "https://example.com".to_string(),
});
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let elem = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::Paragraph)
.expect("paragraph must be present");
assert_eq!(elem.text, "Go to our site");
assert_eq!(elem.annotations.len(), 1);
let annotation = &elem.annotations[0];
assert_eq!(annotation.start, 6);
assert_eq!(annotation.end, 14);
assert_eq!(
annotation.kind,
AnnotationKind::Link {
url: "https://example.com".to_string(),
title: None,
}
);
}
#[test]
fn test_equation_becomes_a_formula_element() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.push_text(TextRun::new("Result: "));
p.content.push(InlineContent::Equation(Equation::new("FRAC{a}{b}")));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let section_formulas: AHashMap<usize, Vec<(usize, String)>> =
[(0usize, vec![(0usize, "\\frac{a}{b}".to_string())])]
.into_iter()
.collect();
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", §ion_formulas);
let formulas: Vec<&str> = internal
.elements
.iter()
.filter(|e| e.kind == ElementKind::Formula)
.map(|e| e.text.as_str())
.collect();
assert_eq!(formulas, vec!["\\frac{a}{b}"]);
let elem = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::Paragraph)
.expect("paragraph must be present");
assert_eq!(elem.text, "Result:");
}
#[test]
fn test_removing_an_equation_leaves_one_space() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.push_text(TextRun::new("The ratio is "));
p.content.push(InlineContent::Equation(Equation::new("x OVER y")));
p.push_text(TextRun::new(" per unit."));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let para = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::Paragraph)
.expect("paragraph must be present");
assert_eq!(para.text, "The ratio is per unit.");
}
#[test]
fn test_equation_only_paragraph_keeps_its_math() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.content.push(InlineContent::Equation(Equation::new("FRAC{a}{b}")));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let section_formulas: AHashMap<usize, Vec<(usize, String)>> =
[(0usize, vec![(0usize, "\\frac{a}{b}".to_string())])]
.into_iter()
.collect();
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", §ion_formulas);
let formulas: Vec<&str> = internal
.elements
.iter()
.filter(|e| e.kind == ElementKind::Formula)
.map(|e| e.text.as_str())
.collect();
assert_eq!(formulas, vec!["\\frac{a}{b}"], "the equation survives (#98)");
}
#[test]
fn test_table_header_row_populates_columns_and_cell_content() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut table = Table::new();
table.has_header = true;
let mut header_row = TableRow::new();
header_row.cells.push(TableCell::text("Name"));
header_row.cells.push(TableCell::text("Age"));
table.rows.push(header_row);
let mut data_row = TableRow::new();
data_row.cells.push(TableCell::text("Alice"));
data_row.cells.push(TableCell::text("30"));
table.rows.push(data_row);
section.content.push(Block::Table(table));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let table_elem = internal
.elements
.iter()
.find(|e| matches!(e.kind, ElementKind::Table { .. }))
.expect("table element must be present");
let ElementKind::Table { table_index } = table_elem.kind else {
unreachable!()
};
let extracted_table = &internal.tables[table_index as usize];
assert_eq!(
extracted_table.columns,
Some(vec!["Name".to_string(), "Age".to_string()])
);
assert_eq!(
extracted_table.cells,
vec![
vec!["Name".to_string(), "Age".to_string()],
vec!["Alice".to_string(), "30".to_string()],
]
);
}
#[test]
fn test_table_without_header_leaves_columns_unset() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut table = Table::new();
table.has_header = false;
let mut row = TableRow::new();
row.cells.push(TableCell::text("A"));
table.rows.push(row);
section.content.push(Block::Table(table));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let table_elem = internal
.elements
.iter()
.find(|e| matches!(e.kind, ElementKind::Table { .. }))
.expect("table element must be present");
let ElementKind::Table { table_index } = table_elem.kind else {
unreachable!()
};
assert_eq!(internal.tables[table_index as usize].columns, None);
}
#[test]
fn test_table_cell_footnote_is_extracted_not_dropped() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut table = Table::new();
let mut row = TableRow::new();
let mut cell = TableCell::new();
let mut cell_para = Paragraph::new();
cell_para.content.push(InlineContent::Footnote("cell note".to_string()));
cell_para.push_text(TextRun::new("cell body"));
cell.content.push(cell_para);
row.cells.push(cell);
table.rows.push(row);
section.content.push(Block::Table(table));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let definition = internal
.elements
.iter()
.find(|e| e.kind == ElementKind::FootnoteDefinition)
.expect("footnote inside a table cell must still produce a definition");
assert_eq!(definition.text, "cell note");
}
fn hwpx_warnings(doc: &InternalDocument) -> Vec<String> {
doc.processing_warnings
.iter()
.filter(|w| w.source == HWPX_WARNING_SOURCE)
.map(|w| w.message.to_string())
.collect()
}
#[test]
fn should_warn_when_image_resource_id_is_missing_from_resources() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.content
.push(InlineContent::Image(unhwp::model::ImageRef::new("bin0")));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let warnings = hwpx_warnings(&internal);
assert_eq!(warnings.len(), 1, "expected exactly one hwpx warning, got {warnings:?}");
assert!(
warnings[0].contains("bin0") && warnings[0].contains("could not be extracted"),
"warning must name the unresolved image id, got {warnings:?}"
);
assert!(
internal.images.is_empty(),
"an unresolved image must not produce an image element"
);
}
#[test]
fn should_not_warn_when_image_resource_resolves() {
let mut doc = Document::new();
doc.resources.insert(
"bin0".to_string(),
unhwp::model::Resource::new(unhwp::model::ResourceType::Image, vec![0x89, 0x50, 0x4E, 0x47]),
);
let mut section = Section::new(0);
let mut p = Paragraph::new();
p.content
.push(InlineContent::Image(unhwp::model::ImageRef::new("bin0")));
section.content.push(Block::Paragraph(p));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
assert!(
hwpx_warnings(&internal).is_empty(),
"a resolvable image must not warn, got {:?}",
hwpx_warnings(&internal)
);
}
#[test]
fn should_warn_when_table_cell_contains_an_image() {
let mut doc = Document::new();
doc.resources.insert(
"bin0".to_string(),
unhwp::model::Resource::new(unhwp::model::ResourceType::Image, vec![0x89, 0x50, 0x4E, 0x47]),
);
let mut section = Section::new(0);
let mut table = Table::new();
let mut row = TableRow::new();
let mut cell = TableCell::new();
let mut cell_para = Paragraph::new();
cell_para
.content
.push(InlineContent::Image(unhwp::model::ImageRef::new("bin0")));
cell.content.push(cell_para);
row.cells.push(cell);
table.rows.push(row);
section.content.push(Block::Table(table));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
let warnings = hwpx_warnings(&internal);
assert_eq!(warnings.len(), 1, "expected exactly one hwpx warning, got {warnings:?}");
assert!(
warnings[0].contains("table cell") && warnings[0].contains("not extracted"),
"warning must describe the dropped table-cell image, got {warnings:?}"
);
assert!(
internal.images.is_empty(),
"an image inside a table cell must not produce an image element"
);
}
#[test]
fn should_not_warn_for_table_with_only_text_cells() {
let mut doc = Document::new();
let mut section = Section::new(0);
let mut table = Table::new();
let mut row = TableRow::new();
row.cells.push(TableCell::text("Alice"));
row.cells.push(TableCell::text("30"));
table.rows.push(row);
section.content.push(Block::Table(table));
doc.sections.push(section);
let internal = build_hwpx_internal_document(doc, "application/haansofthwpx", &AHashMap::new());
assert!(
hwpx_warnings(&internal).is_empty(),
"a table with only text cells must not warn, got {:?}",
hwpx_warnings(&internal)
);
}
#[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", "application/hwp+zip"]
);
}
#[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:?}"
);
}
}