use super::equation;
use super::error::{HwpError, Result};
use super::model::{CharShape, HwpTable, ParaText, Paragraph, Section, fill_next_equation_placeholder};
use super::reader::{StreamReader, decompress_stream};
const HWP_SIGNATURE: &[u8] = b"HWP Document File";
#[cfg_attr(alef, alef(skip))]
#[derive(Debug, Clone)]
pub struct FileHeader {
pub flags: u32,
}
impl FileHeader {
pub(crate) fn parse(data: Vec<u8>) -> Result<Self> {
if data.len() < 256 {
return Err(HwpError::InvalidFormat(
"FileHeader must be at least 256 bytes".to_string(),
));
}
if &data[..17] != HWP_SIGNATURE {
return Err(HwpError::InvalidFormat("Invalid HWP signature".to_string()));
}
let flags = u32::from_le_bytes([data[36], data[37], data[38], data[39]]);
Ok(Self { flags })
}
pub(crate) fn is_compressed(&self) -> bool {
(self.flags & 0x01) != 0
}
pub(crate) fn is_encrypted(&self) -> bool {
(self.flags & 0x02) != 0
}
}
#[cfg_attr(alef, alef(skip))]
#[derive(Debug)]
pub struct Record {
pub tag_id: u16,
pub level: u16,
pub data: Vec<u8>,
}
impl Record {
pub(crate) fn parse(reader: &mut StreamReader) -> Result<Self> {
if reader.remaining() < 4 {
return Err(HwpError::ParseError("Not enough data for record header".to_string()));
}
let header = reader.read_u32()?;
let tag_id = (header & 0x3FF) as u16;
let level = ((header >> 10) & 0x3FF) as u16;
let mut size = header >> 20;
if size == 0xFFF {
size = reader.read_u32()?;
}
let data_size = size as usize;
if data_size > reader.remaining() {
return Err(HwpError::ParseError(format!(
"Record size {data_size} exceeds remaining data {}",
reader.remaining()
)));
}
let data = reader.read_bytes(data_size)?;
Ok(Self { tag_id, level, data })
}
pub(crate) fn data_reader(&self) -> StreamReader {
StreamReader::new(self.data.clone())
}
}
const HWPTAG_BEGIN: u16 = 0x010;
const TAG_PARA_HEADER: u16 = HWPTAG_BEGIN + 50;
const TAG_PARA_TEXT: u16 = HWPTAG_BEGIN + 51;
const TAG_LIST_HEADER: u16 = HWPTAG_BEGIN + 56;
const TAG_TABLE: u16 = HWPTAG_BEGIN + 61;
const TAG_EQEDIT: u16 = HWPTAG_BEGIN + 72;
const MAX_TABLE_DIMENSION: usize = 1_000;
const MAX_TABLE_CELLS: usize = 100_000;
const TAG_PARA_SHAPE: u16 = HWPTAG_BEGIN + 66;
const TAG_CHAR_SHAPE: u16 = HWPTAG_BEGIN + 67;
const TAG_CHAR_SHAPE_INFO: u16 = HWPTAG_BEGIN + 30;
pub(crate) fn parse_doc_info(data: Vec<u8>) -> Result<Vec<CharShape>> {
let mut reader = StreamReader::new(data);
let mut char_shapes = Vec::new();
while reader.remaining() >= 4 {
let record = match Record::parse(&mut reader) {
Ok(r) => r,
Err(_) => break,
};
if record.tag_id == TAG_CHAR_SHAPE_INFO && record.data.len() >= 4 {
let font_attr = u32::from_le_bytes([record.data[0], record.data[1], record.data[2], record.data[3]]);
char_shapes.push(CharShape {
bold: (font_attr & 0x01) != 0,
italic: (font_attr & 0x02) != 0,
underline: (font_attr & 0x04) != 0,
});
}
}
Ok(char_shapes)
}
fn parse_eqedit_script(data: &[u8]) -> Option<String> {
if data.len() < 6 {
return None;
}
let char_count = u16::from_le_bytes([data[4], data[5]]) as usize;
let script_bytes = data.get(6..6 + char_count * 2)?;
let units: Vec<u16> = script_bytes
.chunks_exact(2)
.map(|b| u16::from_le_bytes([b[0], b[1]]))
.collect();
Some(String::from_utf16_lossy(&units).trim().to_string())
}
pub(crate) fn parse_body_text(
data: Vec<u8>,
is_compressed: bool,
stream_name: &str,
warnings: &mut Vec<String>,
) -> Result<Vec<Section>> {
let data = if is_compressed { decompress_stream(&data)? } else { data };
let mut reader = StreamReader::new(data);
let mut records: Vec<Record> = Vec::new();
loop {
if reader.remaining() < 4 {
break;
}
match Record::parse(&mut reader) {
Ok(record) => records.push(record),
Err(e) => {
warnings.push(format!(
"HWP body-text parsing in '{stream_name}' stopped after {} record(s); \
{} remaining byte(s) were not parsed and their content is missing: {e}",
records.len(),
reader.remaining()
));
break;
}
}
}
let (paragraphs, tables) = parse_records_into_paragraphs_and_tables(&records, warnings, stream_name);
Ok(vec![Section { paragraphs, tables }])
}
fn parse_records_into_paragraphs_and_tables(
records: &[Record],
warnings: &mut Vec<String>,
stream_name: &str,
) -> (Vec<Paragraph>, Vec<HwpTable>) {
let mut paragraphs: Vec<Paragraph> = Vec::new();
let mut tables: Vec<HwpTable> = Vec::new();
let mut current_paragraph: Option<Paragraph> = None;
let mut idx = 0;
while idx < records.len() {
let record = &records[idx];
match record.tag_id {
TAG_PARA_HEADER => {
if let Some(para) = current_paragraph.take() {
paragraphs.push(para);
}
current_paragraph = Some(Paragraph::default());
}
TAG_PARA_TEXT => {
if let Some(ref mut para) = current_paragraph
&& let Ok(text) = ParaText::from_record(record)
{
para.text = Some(text);
}
}
TAG_PARA_SHAPE => {
if let Some(ref mut para) = current_paragraph
&& record.data.len() > 18
{
para.outline_level = record.data[18];
}
}
TAG_CHAR_SHAPE => {
if let Some(ref mut para) = current_paragraph {
let mut reader = record.data_reader();
while reader.remaining() >= 6 {
let pos = reader.read_u32().unwrap_or(0);
let shape_idx = reader.read_u16().unwrap_or(0);
para.char_shape_runs.push((pos, shape_idx));
}
}
}
TAG_EQEDIT => {
if let Some(script) = parse_eqedit_script(&record.data) {
let latex = equation::to_latex(&script);
let replacement = format!("${latex}$");
match &mut current_paragraph {
Some(para) => {
para.equations.push(latex.clone());
let filled = para
.text
.as_mut()
.is_some_and(|text| fill_next_equation_placeholder(&mut text.content, &replacement));
if !filled {
let existing = para.text.take().map(|t| t.content).unwrap_or_default();
warnings.push(format!(
"HWP equation in '{stream_name}' had no reserved inline slot; \
its LaTeX rendering was appended to the paragraph instead of placed inline"
));
para.text = Some(super::model::ParaText {
content: format!("{existing}{replacement}"),
});
}
}
None => {
current_paragraph = Some(Paragraph {
text: Some(super::model::ParaText { content: replacement }),
..Paragraph::default()
});
}
}
}
}
TAG_TABLE => {
let table_level = record.level;
let table_end = find_block_end(records, idx, table_level);
if let Some(table) = parse_table_at(&records[idx..table_end]) {
tables.push(table);
}
idx = table_end;
continue;
}
_ => {}
}
idx += 1;
}
if let Some(para) = current_paragraph {
paragraphs.push(para);
}
(paragraphs, tables)
}
fn find_block_end(records: &[Record], start_idx: usize, base_level: u16) -> usize {
for (i, record) in records.iter().enumerate().skip(start_idx + 1) {
if record.level < base_level {
return i;
}
}
records.len()
}
fn find_cell_end(records: &[Record], start_idx: usize, cell_level: u16) -> usize {
for (i, record) in records.iter().enumerate().skip(start_idx + 1) {
if record.level < cell_level {
return i;
}
if record.level == cell_level && record.tag_id == TAG_LIST_HEADER {
return i;
}
}
records.len()
}
fn parse_table_at(records: &[Record]) -> Option<HwpTable> {
let table_record = records.first()?;
if table_record.tag_id != TAG_TABLE || table_record.data.len() < 8 {
return None;
}
let row_count = u16::from_le_bytes([table_record.data[4], table_record.data[5]]) as usize;
let col_count = u16::from_le_bytes([table_record.data[6], table_record.data[7]]) as usize;
if row_count == 0 || col_count == 0 {
return None;
}
if row_count > MAX_TABLE_DIMENSION
|| col_count > MAX_TABLE_DIMENSION
|| row_count.saturating_mul(col_count) > MAX_TABLE_CELLS
{
return None;
}
let mut grid: Vec<Vec<String>> = vec![vec![String::new(); col_count]; row_count];
let mut i = 1;
while i < records.len() {
let record = &records[i];
if record.tag_id == TAG_LIST_HEADER {
let cell_level = record.level;
let cell_end = find_cell_end(records, i, cell_level);
if let Some((row, col, text)) = parse_cell_at(&records[i..cell_end])
&& row < row_count
&& col < col_count
{
grid[row][col] = text;
}
i = cell_end;
} else {
i += 1;
}
}
Some(HwpTable { rows: grid })
}
fn parse_cell_at(records: &[Record]) -> Option<(usize, usize, String)> {
let list_header = records.first()?;
if list_header.data.len() < 16 {
return None;
}
let col = u16::from_le_bytes([list_header.data[8], list_header.data[9]]) as usize;
let row = u16::from_le_bytes([list_header.data[10], list_header.data[11]]) as usize;
let mut lines: Vec<String> = Vec::new();
let mut current: Option<Paragraph> = None;
for record in records.iter().skip(1) {
match record.tag_id {
TAG_PARA_HEADER => {
if let Some(para) = current.take()
&& let Some(text) = para.text
{
lines.push(text.content);
}
current = Some(Paragraph::default());
}
TAG_PARA_TEXT => {
if let Some(ref mut para) = current
&& let Ok(text) = ParaText::from_record(record)
{
para.text = Some(text);
}
}
_ => {}
}
}
if let Some(para) = current
&& let Some(text) = para.text
{
lines.push(text.content);
}
Some((row, col, lines.join("\n")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hwp_extract_converted_output() {
let path =
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../test_documents/hwp/converted_output.hwp");
if !path.exists() {
println!("Skipping: test document not found at {}", path.display());
return;
}
let bytes = std::fs::read(&path).expect("read file");
let _doc = crate::extraction::hwp::extract_hwp_document(&bytes).expect("HWP extraction should succeed");
}
#[test]
fn test_hwp_tag_constants() {
assert_eq!(super::TAG_PARA_HEADER, 0x42);
assert_eq!(super::TAG_PARA_TEXT, 0x43);
assert_eq!(super::TAG_LIST_HEADER, 0x48);
assert_eq!(super::TAG_TABLE, 0x4D);
assert_eq!(super::TAG_EQEDIT, 0x58);
}
fn utf16le(s: &str) -> Vec<u8> {
s.encode_utf16().flat_map(|u| u.to_le_bytes()).collect()
}
fn record_header(tag_id: u16, level: u16, size: u32) -> Vec<u8> {
let packed = (tag_id as u32 & 0x3FF) | ((level as u32 & 0x3FF) << 10) | ((size.min(0xFFE)) << 20);
packed.to_le_bytes().to_vec()
}
fn make_record(tag_id: u16, level: u16, data: &[u8]) -> Vec<u8> {
let mut out = record_header(tag_id, level, data.len() as u32);
out.extend_from_slice(data);
out
}
#[test]
fn should_stop_and_warn_on_malformed_record_instead_of_silently_truncating() {
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend_from_slice(&utf16le("first paragraph"));
let bad_header = record_header(TAG_PARA_TEXT, 1, 5000);
stream.extend_from_slice(&bad_header);
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).expect("parse must not error");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("Section0"));
assert!(warnings[0].contains("stopped after"));
assert_eq!(sections[0].paragraphs.len(), 1);
}
#[test]
fn should_extract_paragraph_header_and_text_at_verified_tag_ids() {
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("Hello, HWP")));
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).unwrap();
assert!(warnings.is_empty());
assert_eq!(sections.len(), 1);
assert_eq!(sections[0].paragraphs.len(), 1);
assert_eq!(
sections[0].paragraphs[0].text.as_ref().map(|t| t.content.as_str()),
Some("Hello, HWP")
);
}
#[test]
fn should_fill_inline_equation_slot_with_latex() {
let mut para_text_data = utf16le("Result: ");
para_text_data.extend_from_slice(&0x000Bu16.to_le_bytes());
para_text_data.extend_from_slice(b"deqe");
para_text_data.extend_from_slice(&[0u8; 10]);
para_text_data.extend_from_slice(&utf16le(" done"));
let mut eqedit_data = 0u32.to_le_bytes().to_vec();
let script = "a OVER b";
let units: Vec<u16> = script.encode_utf16().collect();
eqedit_data.extend_from_slice(&(units.len() as u16).to_le_bytes());
for u in units {
eqedit_data.extend_from_slice(&u.to_le_bytes());
}
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend(make_record(TAG_PARA_TEXT, 1, ¶_text_data));
stream.extend(make_record(TAG_EQEDIT, 1, &eqedit_data));
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).unwrap();
assert!(warnings.is_empty());
let text = sections[0].paragraphs[0].text.as_ref().unwrap();
assert_eq!(text.content, "Result: $\\frac{a}{b}$ done");
}
#[test]
fn should_extract_table_rows_and_cell_text_without_swallowing_trailing_paragraph() {
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("Table:")));
let mut table_data = vec![b' ', b'l', b'b', b't']; table_data.extend_from_slice(&2u16.to_le_bytes()); table_data.extend_from_slice(&2u16.to_le_bytes()); stream.extend(make_record(TAG_TABLE, 1, &table_data));
for (row, col, text) in [(0u16, 0u16, "Name"), (0, 1, "Age"), (1, 0, "Alice"), (1, 1, "30")] {
let mut list_header = vec![0u8; 16];
list_header[8..10].copy_from_slice(&col.to_le_bytes());
list_header[10..12].copy_from_slice(&row.to_le_bytes());
list_header[12..14].copy_from_slice(&1u16.to_le_bytes());
list_header[14..16].copy_from_slice(&1u16.to_le_bytes());
stream.extend(make_record(TAG_LIST_HEADER, 1, &list_header));
stream.extend(make_record(TAG_PARA_HEADER, 2, &[0u8; 24]));
stream.extend(make_record(TAG_PARA_TEXT, 3, &utf16le(text)));
}
stream.extend(make_record(TAG_PARA_HEADER, 0, &[0u8; 24]));
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("After table")));
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).unwrap();
assert!(warnings.is_empty());
assert_eq!(sections[0].tables.len(), 1);
assert_eq!(
sections[0].tables[0].rows,
vec![
vec!["Name".to_string(), "Age".to_string()],
vec!["Alice".to_string(), "30".to_string()],
]
);
let paragraph_texts: Vec<&str> = sections[0]
.paragraphs
.iter()
.map(|p| p.text.as_ref().map(|t| t.content.as_str()).unwrap_or(""))
.collect();
assert_eq!(paragraph_texts, vec!["Table:", "After table"]);
}
fn table_record_data(row_count: u16, col_count: u16) -> Vec<u8> {
let mut data = vec![b' ', b'l', b'b', b't']; data.extend_from_slice(&row_count.to_le_bytes());
data.extend_from_slice(&col_count.to_le_bytes());
data
}
#[test]
fn should_reject_table_with_maximal_row_and_column_counts_without_allocating() {
let table_data = table_record_data(0xFFFF, 0xFFFF);
let records = vec![Record {
tag_id: TAG_TABLE,
level: 1,
data: table_data,
}];
let table = parse_table_at(&records);
assert!(
table.is_none(),
"a table claiming {}x{} cells must be rejected, not allocated",
0xFFFFu16,
0xFFFFu16
);
}
#[test]
fn should_reject_table_at_maximal_dimensions_via_full_body_text_pipeline() {
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("Before table")));
stream.extend(make_record(TAG_TABLE, 1, &table_record_data(0xFFFF, 0xFFFF)));
stream.extend(make_record(TAG_PARA_HEADER, 0, &[0u8; 24]));
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("After table")));
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).expect("parse must not error");
assert!(sections[0].tables.is_empty(), "the oversized table must be dropped");
let paragraph_texts: Vec<&str> = sections[0]
.paragraphs
.iter()
.map(|p| p.text.as_ref().map(|t| t.content.as_str()).unwrap_or(""))
.collect();
assert_eq!(paragraph_texts, vec!["Before table", "After table"]);
}
#[test]
fn should_reject_table_just_over_the_total_cell_cap_even_under_the_per_dimension_cap() {
let row_count = 1_000u16;
let col_count = 1_000u16;
assert!((row_count as usize) <= MAX_TABLE_DIMENSION);
assert!((col_count as usize) <= MAX_TABLE_DIMENSION);
assert!((row_count as usize) * (col_count as usize) > MAX_TABLE_CELLS);
let records = vec![Record {
tag_id: TAG_TABLE,
level: 1,
data: table_record_data(row_count, col_count),
}];
assert!(parse_table_at(&records).is_none());
}
#[test]
fn should_accept_table_exactly_at_the_total_cell_cap() {
let row_count = 316u16;
let col_count = 316u16;
assert!((row_count as usize) * (col_count as usize) <= MAX_TABLE_CELLS);
let records = vec![Record {
tag_id: TAG_TABLE,
level: 1,
data: table_record_data(row_count, col_count),
}];
let table = parse_table_at(&records).expect("a table at the cap must still parse");
assert_eq!(table.rows.len(), row_count as usize);
assert_eq!(table.rows[0].len(), col_count as usize);
}
#[test]
fn should_still_parse_an_ordinary_small_table_to_the_same_grid_as_before() {
let mut stream = make_record(TAG_PARA_HEADER, 0, &[0u8; 24]);
stream.extend(make_record(TAG_PARA_TEXT, 1, &utf16le("Table:")));
stream.extend(make_record(TAG_TABLE, 1, &table_record_data(3, 4)));
let mut expected: Vec<Vec<String>> = vec![vec![String::new(); 4]; 3];
for (row, col, text) in [
(0u16, 0u16, "R0C0"),
(0, 1, "R0C1"),
(0, 2, "R0C2"),
(0, 3, "R0C3"),
(1, 0, "R1C0"),
(2, 3, "R2C3"),
] {
let mut list_header = vec![0u8; 16];
list_header[8..10].copy_from_slice(&col.to_le_bytes());
list_header[10..12].copy_from_slice(&row.to_le_bytes());
list_header[12..14].copy_from_slice(&1u16.to_le_bytes());
list_header[14..16].copy_from_slice(&1u16.to_le_bytes());
stream.extend(make_record(TAG_LIST_HEADER, 1, &list_header));
stream.extend(make_record(TAG_PARA_HEADER, 2, &[0u8; 24]));
stream.extend(make_record(TAG_PARA_TEXT, 3, &utf16le(text)));
expected[row as usize][col as usize] = text.to_string();
}
let mut warnings = Vec::new();
let sections = parse_body_text(stream, false, "Section0", &mut warnings).unwrap();
assert!(warnings.is_empty());
assert_eq!(sections[0].tables.len(), 1);
assert_eq!(sections[0].tables[0].rows, expected);
}
}