1use 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, §ion_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}