Skip to main content

document_svg/document/
hwpx.rs

1//! Bounded Hancom HWPX (HWPML) package preview.
2//!
3//! HWPX is an OPC-like ZIP package containing HWPML section XML and optional
4//! BinData resources. This reader renders section paragraphs, simple tables,
5//! and package-local PNG/JPEG images. It never follows external references,
6//! executes macros, or interprets embedded controls.
7
8use std::collections::HashMap;
9use std::io::{Cursor, Read};
10use std::path::Path;
11
12use base64::Engine;
13use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
14use quick_xml::Reader;
15use quick_xml::events::Event;
16use zip::ZipArchive;
17
18use crate::convert::{ConvertOptions, PageConsumer, read_limited_file};
19use crate::document::html::{HtmlBlock, InlineHtmlImage, render_blocks_to_pages_with_warnings};
20use crate::error::{Error, Result};
21use crate::ooxml::{attribute, local_name, sniff_image_mime};
22use crate::table::{TableAlign, TableData};
23
24const MAX_HWPX_INPUT_BYTES: u64 = 128 * 1024 * 1024;
25const MAX_HWPX_ENTRIES: usize = 100_000;
26const MAX_HWPX_PART_BYTES: u64 = 64 * 1024 * 1024;
27const MAX_HWPX_EXPANDED_BYTES: u64 = 256 * 1024 * 1024;
28const MAX_HWPX_SECTIONS: usize = 10_000;
29const MAX_HWPX_XML_EVENTS: usize = 1_000_000;
30const MAX_HWPX_XML_DEPTH: usize = 256;
31const MAX_HWPX_TEXT_BYTES: usize = 64 * 1024 * 1024;
32const MAX_HWPX_TABLE_CELLS: usize = 200_000;
33const MAX_HWPX_IMAGE_BYTES: usize = 8 * 1024 * 1024;
34const MAX_HWPX_TOTAL_IMAGE_BYTES: usize = 32 * 1024 * 1024;
35const MAX_HWPX_TOTAL_URI_BYTES: usize = 48 * 1024 * 1024;
36const MAX_HWPX_IMAGE_PIXELS: u64 = 40_000_000;
37const MAX_HWPX_TOTAL_PIXELS: u64 = 100_000_000;
38
39#[derive(Default)]
40struct ImageBudget {
41    bytes: usize,
42    uri_bytes: usize,
43    pixels: u64,
44}
45
46#[derive(Default)]
47struct HwpxTable {
48    rows: Vec<Vec<String>>,
49    row: Vec<String>,
50    cell: String,
51    in_cell: bool,
52}
53
54#[cfg(not(target_arch = "wasm32"))]
55pub(crate) fn looks_like_hwpx_archive(path: &Path) -> bool {
56    let Ok(file) = std::fs::File::open(path) else {
57        return false;
58    };
59    let Ok(mut archive) = ZipArchive::new(file) else {
60        return false;
61    };
62    (0..archive.len()).take(MAX_HWPX_ENTRIES).any(|index| {
63        archive
64            .by_index(index)
65            .ok()
66            .map(|entry| {
67                entry.name().starts_with("Contents/section")
68                    && entry.name().to_ascii_lowercase().ends_with(".xml")
69            })
70            .unwrap_or(false)
71    })
72}
73
74pub(crate) fn convert(
75    path: &Path,
76    options: &ConvertOptions,
77    sink: &mut dyn PageConsumer,
78) -> Result<Vec<String>> {
79    let bytes = read_limited_file(
80        path,
81        options.max_input_bytes.min(MAX_HWPX_INPUT_BYTES),
82        "HWPX input",
83    )?;
84    let mut archive = ZipArchive::new(Cursor::new(bytes))
85        .map_err(|error| Error::InvalidInput(format!("invalid HWPX ZIP package: {error}")))?;
86    if archive.len() > MAX_HWPX_ENTRIES {
87        return Err(Error::LimitExceeded(format!(
88            "HWPX package contains more than {MAX_HWPX_ENTRIES} entries"
89        )));
90    }
91    let mut section_names = Vec::new();
92    let mut image_entries = Vec::new();
93    for index in 0..archive.len() {
94        let entry = archive.by_index(index)?;
95        let name = entry.name().to_owned();
96        validate_entry_name(&name)?;
97        if name.starts_with("Contents/section") && name.to_ascii_lowercase().ends_with(".xml") {
98            section_names.push(name.clone());
99        }
100        if name.to_ascii_lowercase().starts_with("bindata/") && !entry.is_dir() {
101            image_entries.push(name);
102        }
103    }
104    if section_names.is_empty() {
105        return Err(Error::InvalidInput(
106            "HWPX package contains no Contents/section*.xml parts".into(),
107        ));
108    }
109    if section_names.len() > MAX_HWPX_SECTIONS {
110        return Err(Error::LimitExceeded(format!(
111            "HWPX package contains more than {MAX_HWPX_SECTIONS} sections"
112        )));
113    }
114    section_names.sort_by_key(|name| natural_key(name));
115    image_entries.sort_by_key(|name| name.to_ascii_lowercase());
116    let (images, mut warnings) = load_images(&mut archive, &image_entries)?;
117    let mut expanded_bytes = 0u64;
118    let mut blocks = Vec::new();
119    for section_name in section_names {
120        let xml = read_zip_part(&mut archive, &section_name)?;
121        expanded_bytes = expanded_bytes.saturating_add(xml.len() as u64);
122        if expanded_bytes > MAX_HWPX_EXPANDED_BYTES {
123            return Err(Error::LimitExceeded(format!(
124                "HWPX expanded parts exceed {MAX_HWPX_EXPANDED_BYTES} bytes"
125            )));
126        }
127        let (section_blocks, section_warnings) =
128            parse_section(&xml, &images, options.max_xml_events)?;
129        blocks.extend(section_blocks);
130        warnings.extend(section_warnings);
131    }
132    if blocks.is_empty() {
133        return Err(Error::InvalidInput(
134            "HWPX sections contain no renderable text, table, or image content".into(),
135        ));
136    }
137    render_blocks_to_pages_with_warnings(&blocks, sink, options, &warnings)?;
138    Ok(dedup_warnings(warnings))
139}
140
141fn parse_section(
142    xml: &[u8],
143    images: &HashMap<String, InlineHtmlImage>,
144    max_events: usize,
145) -> Result<(Vec<HtmlBlock>, Vec<String>)> {
146    let mut reader = Reader::from_reader(Cursor::new(xml));
147    reader.config_mut().trim_text(false);
148    let mut buffer = Vec::new();
149    let mut stack = Vec::<Vec<u8>>::new();
150    let mut paragraph = None::<String>;
151    let mut table = None::<HwpxTable>;
152    let mut blocks = Vec::new();
153    let mut warnings = Vec::new();
154    let mut text_bytes = 0usize;
155    let mut events = 0usize;
156    loop {
157        events = events.saturating_add(1);
158        if events > max_events.min(MAX_HWPX_XML_EVENTS) {
159            return Err(Error::LimitExceeded(format!(
160                "HWPX section exceeds {} parser events",
161                max_events.min(MAX_HWPX_XML_EVENTS)
162            )));
163        }
164        match reader.read_event_into(&mut buffer)? {
165            Event::Start(element) => {
166                let qualified = element.name();
167                let name = local_name(qualified.as_ref()).to_vec();
168                if stack.len() >= MAX_HWPX_XML_DEPTH {
169                    return Err(Error::LimitExceeded(format!(
170                        "HWPX XML nesting exceeds {MAX_HWPX_XML_DEPTH}"
171                    )));
172                }
173                match name.as_slice() {
174                    b"p" if table.is_none() => paragraph = Some(String::new()),
175                    b"tbl" => table = Some(HwpxTable::default()),
176                    b"tr" => {
177                        if let Some(table) = table.as_mut() {
178                            table.row.clear();
179                        }
180                    }
181                    b"tc" => {
182                        if let Some(table) = table.as_mut() {
183                            table.cell.clear();
184                            table.in_cell = true;
185                        }
186                    }
187                    b"img" | b"image" => {
188                        append_image(
189                            &mut blocks,
190                            &mut warnings,
191                            images,
192                            attribute(&element, b"binaryItemIDRef"),
193                        );
194                    }
195                    b"ole" | b"ctrl" | b"script" => push_warning_once(
196                        &mut warnings,
197                        "HWPX controls, OLE objects, and scripts were omitted",
198                    ),
199                    _ => {}
200                }
201                stack.push(name);
202            }
203            Event::Empty(element) => {
204                let qualified = element.name();
205                let name = local_name(qualified.as_ref());
206                if name == b"img" || name == b"image" {
207                    append_image(
208                        &mut blocks,
209                        &mut warnings,
210                        images,
211                        attribute(&element, b"binaryItemIDRef"),
212                    );
213                }
214            }
215            Event::Text(text) => {
216                let value = text
217                    .decode()
218                    .map_err(|error| Error::InvalidInput(format!("invalid HWPX text: {error}")))?;
219                append_text(&value, &mut paragraph, &mut table, &mut text_bytes)?;
220            }
221            Event::CData(text) => {
222                let value = text
223                    .decode()
224                    .map_err(|error| Error::InvalidInput(format!("invalid HWPX CDATA: {error}")))?;
225                append_text(&value, &mut paragraph, &mut table, &mut text_bytes)?;
226            }
227            Event::End(element) => {
228                let qualified = element.name();
229                let name = local_name(qualified.as_ref());
230                match name {
231                    b"p" => {
232                        if let Some(text) = paragraph.take() {
233                            let text = clean_text(&text);
234                            if !text.is_empty() {
235                                blocks.push(HtmlBlock::Paragraph { text });
236                            }
237                        }
238                    }
239                    b"tc" => {
240                        if let Some(table) = table.as_mut()
241                            && table.in_cell
242                        {
243                            table.row.push(clean_text(&table.cell));
244                            table.cell.clear();
245                            table.in_cell = false;
246                        }
247                    }
248                    b"tr" => {
249                        if let Some(table) = table.as_mut()
250                            && !table.row.is_empty()
251                        {
252                            table.rows.push(std::mem::take(&mut table.row));
253                            if table.rows.iter().map(Vec::len).sum::<usize>() > MAX_HWPX_TABLE_CELLS
254                            {
255                                return Err(Error::LimitExceeded(format!(
256                                    "HWPX table exceeds {MAX_HWPX_TABLE_CELLS} cells"
257                                )));
258                            }
259                        }
260                    }
261                    b"tbl" => {
262                        if let Some(table) = table.take() {
263                            let table = finish_table(table);
264                            if !table.headers.is_empty() || !table.rows.is_empty() {
265                                blocks.push(HtmlBlock::Table(table));
266                            }
267                        }
268                    }
269                    _ => {}
270                }
271                stack.pop();
272            }
273            Event::DocType(_) => push_warning_once(
274                &mut warnings,
275                "HWPX document type declarations were ignored; external entities were not loaded",
276            ),
277            Event::Eof => break,
278            _ => {}
279        }
280        buffer.clear();
281    }
282    Ok((blocks, warnings))
283}
284
285fn load_images(
286    archive: &mut ZipArchive<Cursor<Vec<u8>>>,
287    entries: &[String],
288) -> Result<(HashMap<String, InlineHtmlImage>, Vec<String>)> {
289    let mut images = HashMap::new();
290    let mut warnings = Vec::new();
291    let mut budget = ImageBudget::default();
292    for entry_name in entries {
293        let bytes = read_zip_part(archive, entry_name)?;
294        if bytes.len() > MAX_HWPX_IMAGE_BYTES {
295            push_warning_once(
296                &mut warnings,
297                "HWPX images exceeding the per-image byte limit were omitted",
298            );
299            continue;
300        }
301        let Some(mime) =
302            sniff_image_mime(&bytes).filter(|mime| matches!(*mime, "image/png" | "image/jpeg"))
303        else {
304            push_warning_once(
305                &mut warnings,
306                "unsupported HWPX BinData images were omitted; only PNG/JPEG are embedded",
307            );
308            continue;
309        };
310        let Some((width, height)) = crate::document::mhtml::image_dimensions(&bytes, mime) else {
311            push_warning_once(
312                &mut warnings,
313                "invalid HWPX PNG/JPEG BinData images were omitted",
314            );
315            continue;
316        };
317        let pixels = u64::from(width).saturating_mul(u64::from(height));
318        if pixels == 0 || pixels > MAX_HWPX_IMAGE_PIXELS {
319            push_warning_once(
320                &mut warnings,
321                "HWPX images exceeding the pixel limit were omitted",
322            );
323            continue;
324        }
325        let uri = format!("data:{mime};base64,{}", BASE64_STANDARD.encode(&bytes));
326        let next_bytes = budget.bytes.saturating_add(bytes.len());
327        let next_uri = budget.uri_bytes.saturating_add(uri.len());
328        let next_pixels = budget.pixels.saturating_add(pixels);
329        if next_bytes > MAX_HWPX_TOTAL_IMAGE_BYTES
330            || next_uri > MAX_HWPX_TOTAL_URI_BYTES
331            || next_pixels > MAX_HWPX_TOTAL_PIXELS
332        {
333            push_warning_once(
334                &mut warnings,
335                "HWPX total image budget was exceeded; remaining images were omitted",
336            );
337            continue;
338        }
339        budget.bytes = next_bytes;
340        budget.uri_bytes = next_uri;
341        budget.pixels = next_pixels;
342        let key = entry_name
343            .rsplit('/')
344            .next()
345            .unwrap_or(entry_name)
346            .trim_end_matches(['.', ' '])
347            .to_ascii_lowercase();
348        let image = InlineHtmlImage {
349            href: uri,
350            pixel_width: width,
351            pixel_height: height,
352        };
353        images.insert(key.clone(), image.clone());
354        if let Some(stem) = key.rsplit_once('.').map(|(stem, _)| stem.to_owned()) {
355            images.insert(stem, image);
356        }
357    }
358    Ok((images, warnings))
359}
360
361fn append_image(
362    blocks: &mut Vec<HtmlBlock>,
363    warnings: &mut Vec<String>,
364    images: &HashMap<String, InlineHtmlImage>,
365    reference: Option<String>,
366) {
367    let Some(reference) = reference else {
368        push_warning_once(warnings, "HWPX image without binaryItemIDRef was omitted");
369        return;
370    };
371    let key = reference
372        .rsplit('/')
373        .next()
374        .unwrap_or(&reference)
375        .to_ascii_lowercase();
376    if let Some(image) = images.get(&key) {
377        blocks.push(HtmlBlock::Image {
378            href: image.href.clone(),
379            pixel_width: image.pixel_width,
380            pixel_height: image.pixel_height,
381            alt: "HWPX image".into(),
382        });
383    } else {
384        push_warning_once(
385            warnings,
386            "HWPX image BinData reference was missing or unsupported",
387        );
388    }
389}
390
391fn read_zip_part(archive: &mut ZipArchive<Cursor<Vec<u8>>>, name: &str) -> Result<Vec<u8>> {
392    let mut entry = archive.by_name(name)?;
393    if entry.is_dir() || entry.size() > MAX_HWPX_PART_BYTES {
394        return Err(Error::LimitExceeded(format!(
395            "HWPX part '{name}' exceeds {MAX_HWPX_PART_BYTES} bytes"
396        )));
397    }
398    let mut bytes = Vec::new();
399    Read::by_ref(&mut entry)
400        .take(MAX_HWPX_PART_BYTES.saturating_add(1))
401        .read_to_end(&mut bytes)?;
402    if bytes.len() as u64 > MAX_HWPX_PART_BYTES {
403        return Err(Error::LimitExceeded(format!(
404            "HWPX part '{name}' exceeds {MAX_HWPX_PART_BYTES} bytes"
405        )));
406    }
407    Ok(bytes)
408}
409
410fn validate_entry_name(name: &str) -> Result<()> {
411    if name.starts_with('/') || name.contains('\\') || name.split('/').any(|part| part == "..") {
412        return Err(Error::InvalidInput(format!(
413            "HWPX package contains an unsafe entry name: {name}"
414        )));
415    }
416    Ok(())
417}
418
419fn append_text(
420    value: &str,
421    paragraph: &mut Option<String>,
422    table: &mut Option<HwpxTable>,
423    text_bytes: &mut usize,
424) -> Result<()> {
425    *text_bytes = text_bytes.saturating_add(value.len());
426    if *text_bytes > MAX_HWPX_TEXT_BYTES {
427        return Err(Error::LimitExceeded(format!(
428            "HWPX text exceeds {MAX_HWPX_TEXT_BYTES} bytes"
429        )));
430    }
431    if let Some(table) = table.as_mut()
432        && table.in_cell
433    {
434        table.cell.push_str(value);
435    } else if let Some(paragraph) = paragraph.as_mut() {
436        paragraph.push_str(value);
437    }
438    Ok(())
439}
440
441fn finish_table(table: HwpxTable) -> TableData {
442    let mut rows = table.rows;
443    let headers = rows.first().cloned().unwrap_or_default();
444    if !rows.is_empty() {
445        rows.remove(0);
446    }
447    let columns = headers
448        .len()
449        .max(rows.iter().map(Vec::len).max().unwrap_or(0))
450        .max(1);
451    let mut headers = headers;
452    headers.resize(columns, String::new());
453    for row in &mut rows {
454        row.resize(columns, String::new());
455    }
456    TableData {
457        headers,
458        rows,
459        alignments: vec![TableAlign::Left; columns],
460        raw_source: String::new(),
461    }
462}
463
464fn natural_key(name: &str) -> (usize, String) {
465    let lower = name.to_ascii_lowercase();
466    let digits = lower
467        .strip_prefix("contents/section")
468        .and_then(|value| value.strip_suffix(".xml"))
469        .and_then(|value| value.parse::<usize>().ok())
470        .unwrap_or(usize::MAX);
471    (digits, lower)
472}
473
474fn clean_text(value: &str) -> String {
475    value.split_whitespace().collect::<Vec<_>>().join(" ")
476}
477
478fn push_warning_once(warnings: &mut Vec<String>, warning: &str) {
479    if !warnings.iter().any(|existing| existing == warning) {
480        warnings.push(warning.to_owned());
481    }
482}
483
484fn dedup_warnings(mut warnings: Vec<String>) -> Vec<String> {
485    warnings.sort();
486    warnings.dedup();
487    warnings
488}