diff --git a/CHANGELOG.md b/CHANGELOG.md index 456b287..f4a5a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ The workspace `Cargo.toml` `[workspace.package] version` is the single source fo --- +## [Unreleased] + +**Added** + +- DOCX export (GJ-1): `convert --to docx` writes OOXML from the IR (`hwp-convert::docx`) — + paragraphs with Heading styles, run properties (font, size, color, shade, letter-spacing, + super/subscript), para alignment/spacing/indents, tables with gridSpan/vMerge and nesting, + embedded images, hyperlinks, numbering lists, footnotes/endnotes, and page setup from + SectionDef. Equations fall back to script text. DOCX input stays open (L-tier). + +--- + ## [0.6.0] **Added** diff --git a/crates/hwp-cli/src/cli.rs b/crates/hwp-cli/src/cli.rs index 709b174..d74bc21 100644 --- a/crates/hwp-cli/src/cli.rs +++ b/crates/hwp-cli/src/cli.rs @@ -477,6 +477,7 @@ pub enum ConvertFormat { Odt, Txt, Csv, + Docx, } /// Official-document preset (`hwp new --preset`). diff --git a/crates/hwp-cli/src/commands/convert.rs b/crates/hwp-cli/src/commands/convert.rs index 7a91d74..2ed2487 100644 --- a/crates/hwp-cli/src/commands/convert.rs +++ b/crates/hwp-cli/src/commands/convert.rs @@ -247,6 +247,7 @@ fn target_extension(target: ConvertFormat) -> &'static str { ConvertFormat::Odt => "odt", ConvertFormat::Txt => "txt", ConvertFormat::Csv => "csv", + ConvertFormat::Docx => "docx", } } @@ -313,6 +314,10 @@ pub fn execute( std::fs::write(staged, hwp_convert::to_csv(&doc))?; Ok(Vec::new()) } + ConvertFormat::Docx => { + std::fs::write(staged, hwp_convert::to_docx(&doc)?)?; + Ok(Vec::new()) + } ConvertFormat::Odt => { std::fs::write(staged, hwp_convert::to_odt(&doc)?)?; Ok(Vec::new()) @@ -455,6 +460,7 @@ fn infer_format(output: &Path) -> anyhow::Result { Some("json") => Ok(ConvertFormat::Json), Some("txt") => Ok(ConvertFormat::Txt), Some("csv") => Ok(ConvertFormat::Csv), + Some("docx") => Ok(ConvertFormat::Docx), Some("hwpx") => Ok(ConvertFormat::Hwpx), Some("hwp") => Ok(ConvertFormat::Hwp), other => { diff --git a/crates/hwp-cli/tests/cli.rs b/crates/hwp-cli/tests/cli.rs index 9b84899..5d55ed7 100644 --- a/crates/hwp-cli/tests/cli.rs +++ b/crates/hwp-cli/tests/cli.rs @@ -1869,7 +1869,7 @@ fn edit_replace_and_seal_applies_both_edits() { } } -// ── S-tier 배치: 편집 프리미티브·배치·파이프·grep·csv/txt (GK-3/4/6/8, GM-1/2/5, GJ-5/6) ── +// ── S-tier batch: edit primitives, batch, pipes, grep, csv/txt (GK-3/4/6/8, GM-1/2/5, GJ-5/6) ── fn make_doc(name: &str, md: &str) -> PathBuf { let md_path = tmp(name); @@ -2056,12 +2056,73 @@ fn grep_match_and_no_match_exit_codes() { String::from_utf8_lossy(&r.stderr) ); assert!(String::from_utf8_lossy(&r.stdout).contains("사과 바나나")); - // 표 셀도 검색된다. + // Table cells are searched too. let r = hwp().arg("grep").arg("오렌지").arg(&src).output().unwrap(); assert!(r.status.success()); assert!(String::from_utf8_lossy(&r.stdout).contains("오렌지")); - // 일치 없음 → 종료 코드 1. + // No match → exit code 1. let r = hwp().arg("grep").arg("포도").arg(&src).output().unwrap(); assert_eq!(r.status.code(), Some(1), "미일치는 grep 관례 1"); let _ = std::fs::remove_file(&src); } + +#[test] +fn convert_docx_structure_and_textutil() { + // hwp→docx — the OPC parts and body must survive (GJ-1). + let src = fixture("samples/report-tables.hwpx"); + if !src.exists() { + eprintln!("스킵: 샘플 없음"); + return; + } + let out = tmp("s_tier_docx.docx"); + let r = hwp() + .arg("convert") + .arg(&src) + .arg("-o") + .arg(&out) + .output() + .unwrap(); + assert!( + r.status.success(), + "docx 변환: {}", + String::from_utf8_lossy(&r.stderr) + ); + // OPC structure check. + let bytes = std::fs::read(&out).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + for part in [ + "[Content_Types].xml", + "_rels/.rels", + "word/document.xml", + "word/styles.xml", + "word/_rels/document.xml.rels", + ] { + assert!(zip.by_name(part).is_ok(), "파트 없음: {part}"); + } + let mut document = String::new(); + zip.by_name("word/document.xml") + .unwrap() + .read_to_string(&mut document) + .unwrap(); + assert!(document.contains(""), "표 방출"); + assert!( + document.contains("gridSpan") || document.contains("vMerge"), + "병합 셀 span 방출" + ); + // Text round-trip smoke via textutil (macOS) — skipped on CI (ubuntu). + if Path::new("/usr/bin/textutil").exists() { + let t = Command::new("/usr/bin/textutil") + .args(["-convert", "txt", "-stdout"]) + .arg(&out) + .output() + .unwrap(); + assert!(t.status.success(), "textutil이 docx를 열지 못함"); + let text = String::from_utf8_lossy(&t.stdout); + assert!( + text.contains("전문가"), + "textutil 텍스트: {}", + &text[..text.len().min(200)] + ); + } + let _ = std::fs::remove_file(&out); +} diff --git a/crates/hwp-convert/src/docx.rs b/crates/hwp-convert/src/docx.rs new file mode 100644 index 0000000..b367c36 --- /dev/null +++ b/crates/hwp-convert/src/docx.rs @@ -0,0 +1,1155 @@ +//! IR → DOCX (OOXML) one-way export (GJ-1). +//! +//! Mapping (content fidelity, no page-layout reproduction): +//! - "개요 N" style paragraphs → HeadingN paragraph styles +//! - CharShape → run properties (b/i/u/strike/vertAlign, sz, color, shd, rFonts, spacing) +//! - ParaShape → pPr (jc, line spacing, ind, spacing before/after, numPr) +//! - Tables → `w:tbl` with gridSpan (colspan) and vMerge (rowspan); nested tables kept +//! - Pictures → `word/media/*` + inline drawings (extent in EMU) +//! - Hyperlinks → external rels + `w:hyperlink` +//! - Footnotes/endnotes → footnotes.xml/endnotes.xml + references +//! - Equations → script text as-is (v1 fallback; OMML mapping is out of scope) + +use std::io::Write as _; + +use hwp_model::{CharShape, Control, Document, HwpChar, NumFmt, Paragraph, ctrl_char}; + +/// Serializes the entire document to DOCX as a ZIP (OPC) package. +pub fn to_docx(doc: &Document) -> std::io::Result> { + let mut b = Builder { + doc, + body: String::new(), + images: Vec::new(), + link_rels: Vec::new(), + footnotes: Vec::new(), + endnotes: Vec::new(), + foot_n: 0, + end_n: 0, + }; + for (i, section) in doc.sections.iter().enumerate() { + if i > 0 { + // Section boundary — closes the previous section's sectPr in a paragraph pPr. + b.body.push_str(""); + b.body + .push_str(§_pr_xml(section_page(&doc.sections[i - 1]))); + b.body.push_str(""); + } + for para in §ion.paragraphs { + b.paragraph(para); + } + } + // The body-end sectPr holds the last section's settings. + if let Some(last) = doc.sections.last() { + b.body.push_str(§_pr_xml(section_page(last))); + } + + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + let map_err = |e: zip::result::ZipError| std::io::Error::other(e); + + let mut entries: Vec<(String, Vec)> = vec![ + ( + "[Content_Types].xml".into(), + content_types_xml(&b).into_bytes(), + ), + ("_rels/.rels".into(), ROOT_RELS.into()), + ( + "word/document.xml".into(), + document_xml(&b.body).into_bytes(), + ), + ("word/styles.xml".into(), styles_xml(doc).into_bytes()), + ( + "word/_rels/document.xml.rels".into(), + b.doc_rels_xml().into_bytes(), + ), + ("docProps/core.xml".into(), core_xml(doc).into_bytes()), + ]; + if b.uses_numbering(doc) { + entries.push(("word/numbering.xml".into(), numbering_xml(doc).into_bytes())); + } + if !b.footnotes.is_empty() { + entries.push(("word/footnotes.xml".into(), b.notes_xml(false).into_bytes())); + } + if !b.endnotes.is_empty() { + entries.push(("word/endnotes.xml".into(), b.notes_xml(true).into_bytes())); + } + for (i, img) in b.images.iter().enumerate() { + entries.push(( + format!("word/media/image{}.{}", i + 1, img.ext), + img.data.clone(), + )); + } + for (name, data) in entries { + zip.start_file(name, deflated).map_err(map_err)?; + zip.write_all(&data)?; + } + let cursor = zip.finish().map_err(map_err)?; + Ok(cursor.into_inner()) +} + +struct ImageItem { + data: Vec, + ext: &'static str, + cx_emu: i64, + cy_emu: i64, +} + +/// One external hyperlink rel (id, URL). +struct LinkRel { + id: String, + url: String, +} + +struct Builder<'d> { + doc: &'d Document, + body: String, + images: Vec, + link_rels: Vec, + /// (id, body XML) — notes that go into footnotes.xml/endnotes.xml. + footnotes: Vec<(u32, String)>, + endnotes: Vec<(u32, String)>, + foot_n: u32, + end_n: u32, +} + +impl Builder<'_> { + fn uses_numbering(&self, doc: &Document) -> bool { + !doc.header.numbering_levels.is_empty() || !doc.header.bullet_chars.is_empty() + } + + fn paragraph(&mut self, para: &Paragraph) { + let mut inline = String::new(); + let mut blocks = String::new(); + self.inline(para, &mut inline, &mut blocks); + if inline.is_empty() && blocks.is_empty() { + return; + } + if !inline.is_empty() { + self.body.push_str(""); + self.body.push_str(&self.ppr(para)); + self.body.push_str(&inline); + self.body.push_str(""); + } + self.body.push_str(&blocks); + } + + /// Paragraph properties (style, numbering, alignment, spacing, indent). + fn ppr(&self, para: &Paragraph) -> String { + let mut ppr = String::from(""); + // "개요 N" style → HeadingN. + if let Some(level) = self + .doc + .header + .styles + .get(para.style.0 as usize) + .and_then(|s| s.name.strip_prefix("개요 ")) + .and_then(|n| n.trim().parse::().ok()) + .filter(|n| (1..=6).contains(n)) + { + ppr.push_str(&format!("")); + } + if let Some(ps) = self.doc.header.para_shapes.get(para.para_shape.0 as usize) { + // Numbered/bullet list. + let (ht, level) = (ps.head_type(), ps.head_level()); + if ht == 2 || ht == 3 { + // Numbering definitions use numId = index+1; bullets point at BULLET_NUM_BASE+index. + let num_id = if ht == 3 { + BULLET_NUM_BASE + u32::from(ps.numbering_id) + } else { + u32::from(ps.numbering_id) + 1 + }; + ppr.push_str(&format!( + "", + level.saturating_sub(1) + )); + } + // Alignment. + let jc = match ps.alignment() { + 1 => "left", + 2 => "right", + 3 => "center", + 4 | 5 => "distribute", + _ => "both", + }; + ppr.push_str(&format!("")); + // Line spacing — PERCENT uses lineRule=auto (240=single); length kinds use exact/atLeast. + match ps.line_spacing_type { + 1 => ppr.push_str(&format!( + "", + ps.line_spacing / 10 + )), + 3 => ppr.push_str(&format!( + "", + ps.line_spacing / 10 + )), + _ if ps.line_spacing > 0 && ps.line_spacing != 160 => ppr.push_str(&format!( + "", + ps.line_spacing * 240 / 100 + )), + _ => {} + } + // Paragraph spacing/indent — the IR uses hwp5 2× units, so twips is value/10. + let mut ind = String::new(); + if ps.margin_left != 0 { + ind.push_str(&format!(" w:left=\"{}\"", ps.margin_left / 10)); + } + if ps.margin_right != 0 { + ind.push_str(&format!(" w:right=\"{}\"", ps.margin_right / 10)); + } + if ps.indent > 0 { + ind.push_str(&format!(" w:firstLine=\"{}\"", ps.indent / 10)); + } else if ps.indent < 0 { + ind.push_str(&format!(" w:hanging=\"{}\"", -ps.indent / 10)); + } + if !ind.is_empty() { + ppr.push_str(&format!("")); + } + if ps.spacing_top != 0 || ps.spacing_bottom != 0 { + ppr.push_str(&format!( + "", + (ps.spacing_top / 10).max(0), + (ps.spacing_bottom / 10).max(0) + )); + } + } + ppr.push_str(""); + ppr + } + + /// Inline content of a paragraph. Blocks such as tables are split off into `blocks`. + fn inline(&mut self, para: &Paragraph, out: &mut String, blocks: &mut String) { + let mut wchar_pos = 0u32; + let mut current: Option = None; // active char shape id + let mut link_open: Option = None; // active hyperlink rel id + let mut text_buf = String::new(); // consecutive Text — flushed as + macro_rules! flush_text { + () => { + if !text_buf.is_empty() { + out.push_str(""); + out.push_str(&text_buf); + out.push_str(""); + text_buf.clear(); + } + }; + } + for ch in ¶.chars { + if let HwpChar::Text(_) = ch { + let want = shape_id_at(self.doc, para, wchar_pos); + if want != current { + flush_text!(); + close_run(out, &mut current); + open_run(out, self.doc, want, &mut current); + } + } + match ch { + HwpChar::Text(c) => push_escaped(&mut text_buf, *c), + HwpChar::CharCtrl(code) => { + flush_text!(); + match *code { + ctrl_char::LINE_BREAK => out.push_str(""), + ctrl_char::HYPHEN => out.push('-'), + ctrl_char::NB_SPACE | ctrl_char::FW_SPACE => out.push(' '), + _ => {} + } + } + HwpChar::InlineCtrl { code, .. } => { + flush_text!(); + if *code == ctrl_char::FIELD_END { + if let Some(rel) = link_open.take() { + close_run(out, &mut current); + out.push_str(""); + let _ = rel; + } + } else if *code == ctrl_char::TAB { + out.push_str(""); + } + } + HwpChar::ExtCtrl { + code, ctrl_index, .. + } => { + flush_text!(); + if let Some(idx) = ctrl_index + && let Some(control) = para.controls.get(*idx as usize) + { + if *code == ctrl_char::FIELD_START + && let Some(url) = crate::field::hyperlink_url(control) + { + close_run(out, &mut current); + let rel_id = format!("rIdLink{}", self.link_rels.len() + 1); + self.link_rels.push(LinkRel { + id: rel_id.clone(), + url, + }); + out.push_str(&format!( + "" + )); + link_open = Some(rel_id); + current = None; // open a fresh run for the link + } else { + close_run(out, &mut current); + self.control(control, *code, out, blocks); + } + } + } + } + wchar_pos += ch.wchar_width(); + } + flush_text!(); + close_run(out, &mut current); + if link_open.is_some() { + out.push_str(""); + } + } + + fn control(&mut self, control: &Control, code: u16, out: &mut String, blocks: &mut String) { + match control { + Control::SectionDef(_) => {} + Control::Picture(pic) => { + if let Some(data) = self.doc.resolve_bin(&pic.bin_ref) { + let idx = self.images.len(); + let (ext, _) = crate::image::image_kind(data); + self.images.push(ImageItem { + data: data.to_vec(), + ext, + cx_emu: i64::from(pic.width.0) * 127, + cy_emu: i64::from(pic.height.0) * 127, + }); + let rel = format!("rIdImg{}", idx + 1); + let (cx, cy) = (self.images[idx].cx_emu, self.images[idx].cy_emu); + out.push_str(&format!( + "\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ", + idx + 100, + idx + 1, + )); + } + } + Control::Table(table) => self.table(table, blocks), + Control::Generic(g) => { + // Footnote/endnote → footnotes.xml/endnotes.xml + reference run. + if code == ctrl_char::FOOTNOTE_ENDNOTE && matches!(&g.ctrl_id, b"fn " | b"en ") { + let mut note_body = String::new(); + for list in &g.paragraph_lists { + for p in &list.paragraphs { + let mut inl = String::new(); + let mut blk = String::new(); + self.inline(p, &mut inl, &mut blk); + let inl = inl.trim(); + if !inl.is_empty() { + note_body.push_str(&format!( + "{inl}" + )); + } + } + } + let endnote = g.ctrl_id == *b"en "; + let id = if endnote { + self.end_n += 1; + self.end_n + 1 // 0/1 are reserved for separators + } else { + self.foot_n += 1; + self.foot_n + 1 + }; + if endnote { + self.endnotes.push((id, note_body)); + out.push_str(&format!( + "" + )); + } else { + self.footnotes.push((id, note_body)); + out.push_str(&format!( + "" + )); + } + return; + } + // Equation → the script source as a run (v1 fallback). + if let Some(eq) = &g.equation { + out.push_str(""); + for c in eq.script.chars() { + push_escaped(out, c); + } + out.push_str(""); + return; + } + if code == ctrl_char::HEADER_FOOTER || code == ctrl_char::HIDDEN_COMMENT { + return; + } + for list in &g.paragraph_lists { + for p in &list.paragraphs { + self.paragraph_in_block(p, out, blocks); + } + } + } + } + } + + /// Paragraphs inside text boxes etc. — draws a paragraph in a block context. + fn paragraph_in_block(&mut self, para: &Paragraph, out: &mut String, blocks: &mut String) { + let mut inl = String::new(); + self.inline(para, &mut inl, blocks); + let inl = inl.trim(); + if !inl.is_empty() { + if !out.is_empty() && !out.ends_with([' ', '>']) { + out.push(' '); + } + out.push_str(inl); + } + } + + /// Table — gridSpan (colspan) and vMerge (rowspan). Nested tables inside cells are preserved. + fn table(&mut self, table: &hwp_model::Table, blocks: &mut String) { + let rows = table.rows.max(1) as usize; + let cols = table.cols.max(1) as usize; + // Covered-slot tracking: slots covered horizontally (colspan) emit no cell; only slots + // covered vertically (rowspan) emit a vMerge cell. Value = (vertically covered?, origin's col_span). + let mut covered: Vec>> = vec![vec![None; cols]; rows]; + let col_twips = crate::from_markdown::BODY_WIDTH / cols as i32 / 5; + blocks.push_str( + "\ + \ + \ + \ + \ + \ + \ + ", + ); + for _ in 0..cols { + blocks.push_str(&format!("")); + } + blocks.push_str(""); + for r in 0..rows { + blocks.push_str(""); + for c in 0..cols { + if let Some((from_above, origin_col_span)) = covered[r][c] { + if !from_above { + continue; // slots covered horizontally (colspan) emit no cell. + } + // Vertically covered (rowspan) — carries the origin's gridSpan as-is. + let span = if origin_col_span > 1 { + format!("") + } else { + String::new() + }; + blocks.push_str(&format!( + "{span}" + )); + continue; + } + let Some(cell) = table + .cells + .iter() + .find(|cell| cell.row as usize == r && cell.col as usize == c) + else { + blocks.push_str(&format!( + "" + )); + continue; + }; + for dr in 0..cell.row_span.max(1) as usize { + for dc in 0..cell.col_span.max(1) as usize { + if dr == 0 && dc == 0 { + continue; // the origin itself + } + if let Some(slot) = + covered.get_mut(r + dr).and_then(|row| row.get_mut(c + dc)) + { + // Slots to the right in the same row are horizontally covered; rows below are vertically covered. + *slot = Some((dr > 0, cell.col_span)); + } + } + } + let mut tcpr = format!(""); + if cell.col_span > 1 { + tcpr.push_str(&format!("", cell.col_span)); + } + if cell.row_span > 1 { + tcpr.push_str(""); + } + blocks.push_str(&format!("{tcpr}")); + let mut wrote_p = false; + for p in &cell.paragraphs { + let mut inl = String::new(); + let mut blk = String::new(); + self.inline(p, &mut inl, &mut blk); + if !inl.trim().is_empty() { + blocks.push_str(""); + blocks.push_str(&self.ppr(p)); + blocks.push_str(&inl); + blocks.push_str(""); + wrote_p = true; + } + blocks.push_str(&blk); + if !blk.is_empty() { + wrote_p = true; + } + } + if !wrote_p { + blocks.push_str(""); + } + blocks.push_str(""); + } + blocks.push_str(""); + } + blocks.push_str(""); + } + + /// document.xml.rels — style/numbering/note/image/hyperlink relationships. + fn doc_rels_xml(&self) -> String { + let mut rels = String::from( + "\ + ", + ); + rels.push_str( + "", + ); + if self.uses_numbering(self.doc) { + rels.push_str( + "", + ); + } + if !self.footnotes.is_empty() { + rels.push_str( + "", + ); + } + if !self.endnotes.is_empty() { + rels.push_str( + "", + ); + } + for (i, _) in self.images.iter().enumerate() { + rels.push_str(&format!( + "", + i + 1, + i + 1, + self.images[i].ext + )); + } + for link in &self.link_rels { + rels.push_str(&format!( + "", + link.id, + escape(&link.url) + )); + } + rels.push_str(""); + rels + } + + /// footnotes.xml/endnotes.xml — ids 0/1 are reserved for separators. + fn notes_xml(&self, endnote: bool) -> String { + let (root, item, reference) = if endnote { + ("endnotes", "endnote", "endnoteRef") + } else { + ("footnotes", "footnote", "footnoteRef") + }; + let notes = if endnote { + &self.endnotes + } else { + &self.footnotes + }; + let mut out = format!( + "\ + \ + \ + " + ); + for (id, body) in notes { + out.push_str(&format!( + "\ + {body}" + )); + } + out.push_str(&format!("")); + out + } +} + +/// numId base for numbering definitions — bullet definitions are appended after the numbering definitions. +const BULLET_NUM_BASE: u32 = 128; + +fn shape_id_at(doc: &Document, para: &Paragraph, pos: u32) -> Option { + let id = para + .char_shape_runs + .iter() + .rev() + .find(|(start, _)| *start <= pos) + .map(|(_, id)| *id)?; + (doc.header.char_shapes.get(id.0 as usize).is_some()).then_some(id.0 as usize) +} + +/// Opens a run — writes the rPr of the current shape. `current` tracks the active shape id. +fn open_run(out: &mut String, doc: &Document, want: Option, current: &mut Option) { + let rpr = want + .and_then(|id| doc.header.char_shapes.get(id)) + .map(|s| run_props(doc, s)) + .unwrap_or_default(); + out.push_str(""); + out.push_str(&rpr); + *current = want; +} + +fn close_run(out: &mut String, current: &mut Option) { + if current.is_some() { + out.push_str(""); + *current = None; + } +} + +/// CharShape → w:rPr. +fn run_props(doc: &Document, s: &CharShape) -> String { + let mut r = String::from(""); + if let Some(face) = doc.header.fonts[0].get(s.face_ids[0] as usize) { + r.push_str(&format!( + "", + escape(&face.name) + )); + } + if s.is_bold() { + r.push_str(""); + } + if s.is_italic() { + r.push_str(""); + } + if s.has_underline() { + r.push_str(""); + } + if s.has_strike() { + r.push_str(""); + } + if s.is_superscript() { + r.push_str(""); + } + if s.is_subscript() { + r.push_str(""); + } + // Size: base_size (1/100pt) × rel_sizes (%) → half-points (10pt → 20). + let hp = (i64::from(s.base_size) * i64::from(s.rel_sizes[0]) / 5000).max(2); + r.push_str(&format!("")); + // Letter spacing % → twips of pt at the current size. + if s.spacings[0] != 0 { + let twips = + i64::from(s.spacings[0]) * i64::from(s.base_size) * i64::from(s.rel_sizes[0]) / 50000; + if twips != 0 { + r.push_str(&format!("")); + } + } + if s.text_color != 0 { + r.push_str(&format!( + "", + colorref_hex(s.text_color) + )); + } + if s.has_shade() { + r.push_str(&format!( + "", + colorref_hex(s.shade_color) + )); + } + r.push_str(""); + r +} + +/// COLORREF(0x00BBGGRR) → RRGGBB (docx uses 6 digits without #). +fn colorref_hex(v: u32) -> String { + format!( + "{:02X}{:02X}{:02X}", + v & 0xFF, + (v >> 8) & 0xFF, + (v >> 16) & 0xFF + ) +} + +fn push_escaped(out: &mut String, c: char) { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + _ => out.push(c), + } +} + +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } + out +} + +const NS_DECL: &str = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" \ +xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\" \ +xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" \ +xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" \ +xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\""; + +fn document_xml(body: &str) -> String { + format!( + "\ + {body}" + ) +} + +/// Finds the section's PageDef (based on the first SectionDef). +fn section_page(section: &hwp_model::Section) -> Option<&hwp_model::PageDef> { + section + .paragraphs + .iter() + .flat_map(|p| &p.controls) + .find_map(|c| match c { + Control::SectionDef(sd) => sd.page.as_ref(), + _ => None, + }) +} + +/// PageDef → sectPr (twips). +fn sect_pr_xml(page: Option<&hwp_model::PageDef>) -> String { + let Some(page) = page else { + return "".into(); + }; + // PageDef is HWPUNIT (not 2×) — twips is ×0.2. + let tw = |v: i32| (i64::from(v) / 5).max(0); + let orient = if page.attr & 1 != 0 { + " w:orient=\"landscape\"" + } else { + "" + }; + format!( + "\ + ", + tw(page.width.0), + tw(page.height.0), + tw(page.margin_top.0), + tw(page.margin_right.0), + tw(page.margin_bottom.0), + tw(page.margin_left.0), + tw(page.margin_header.0), + tw(page.margin_footer.0), + tw(page.gutter.0), + ) +} + +fn styles_xml(doc: &Document) -> String { + let body_font = doc.header.fonts[0] + .first() + .map(|f| f.name.clone()) + .unwrap_or_else(|| "함초롬바탕".to_string()); + let mut out = format!( + "\ + \ + \ + \ + \ + ", + escape(&body_font) + ); + let sizes = [36, 30, 26, 24, 22, 22]; // half-points (18/15/13/12/11/11pt) + for (i, sz) in sizes.iter().enumerate() { + let n = i + 1; + out.push_str(&format!( + "\ + \ + \ + " + )); + } + out.push_str( + "\ + \ + \ + \ + ", + ); + out.push_str(""); + out +} + +/// numbering.xml — numbering definitions (0-based index) + bullet definitions (BULLET_NUM_BASE~). +fn numbering_xml(doc: &Document) -> String { + let mut out = format!( + "\ + " + ); + for (i, levels) in doc.header.numbering_levels.iter().enumerate() { + out.push_str(&format!("")); + for (ilvl, level) in levels.iter().enumerate() { + let (fmt, text) = num_fmt(level); + out.push_str(&format!( + "\ + ", + level.start + )); + } + out.push_str(""); + } + for (i, ch) in doc.header.bullet_chars.iter().enumerate() { + let id = BULLET_NUM_BASE as usize + i; + out.push_str(&format!( + "\ + \ + \ + " + )); + } + for (i, _) in doc.header.numbering_levels.iter().enumerate() { + out.push_str(&format!( + "", + i + 1 + )); + } + for (i, _) in doc.header.bullet_chars.iter().enumerate() { + let id = BULLET_NUM_BASE as usize + i; + out.push_str(&format!( + "" + )); + } + out.push_str(""); + out +} + +/// NumLevel → OOXML numFmt + lvlText (`^N` → `%N`). +fn num_fmt(level: &hwp_model::NumLevel) -> (&'static str, String) { + let fmt = match level.fmt { + NumFmt::Digit => "decimal", + NumFmt::HangulSyllable => "koreanCounting", + NumFmt::HangulJamo => "koreanDigital", + NumFmt::CircledDigit => "decimalEnclosedCircle", + NumFmt::LatinUpper => "upperLetter", + NumFmt::LatinLower => "lowerLetter", + NumFmt::RomanUpper => "upperRoman", + NumFmt::RomanLower => "lowerRoman", + }; + let mut text = if level.template.is_empty() { + "%1.".to_string() + } else { + level.template.clone() + }; + // `^N` placeholders to OOXML `%N` — replaces ^1..^7 of the template in order. + for n in 1..=7u8 { + text = text.replace(&format!("^{n}"), &format!("%{n}")); + } + (fmt, escape(&text)) +} + +fn content_types_xml(b: &Builder) -> String { + let mut out = String::from( + "\ + \ + \ + \ + \ + \ + \ + \ + \ + \ + \ + ", + ); + if b.uses_numbering(b.doc) { + out.push_str( + "", + ); + } + if !b.footnotes.is_empty() { + out.push_str( + "", + ); + } + if !b.endnotes.is_empty() { + out.push_str( + "", + ); + } + out.push_str(""); + out +} + +const ROOT_RELS: &str = "\ +\ +\ +\ +"; + +fn core_xml(doc: &Document) -> String { + let m = &doc.metadata; + let mut out = String::from( + "\ + ", + ); + if let Some(t) = m.title.as_deref().filter(|s| !s.is_empty()) { + out.push_str(&format!("{}", escape(t))); + } + if let Some(a) = m.author.as_deref().filter(|s| !s.is_empty()) { + out.push_str(&format!("{}", escape(a))); + } + if let Some(s) = m.subject.as_deref().filter(|s| !s.is_empty()) { + out.push_str(&format!("{}", escape(s))); + } + if let Some(k) = m.keywords.as_deref().filter(|s| !s.is_empty()) { + out.push_str(&format!("{}", escape(k))); + } + out.push_str(""); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read as _; + + fn unzip(bytes: &[u8], name: &str) -> Option { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?; + let mut f = zip.by_name(name).ok()?; + let mut s = String::new(); + f.read_to_string(&mut s).ok()?; + Some(s) + } + + fn all_parts_well_formed(bytes: &[u8]) -> bool { + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).unwrap(); + if !f.name().ends_with(".xml") && !f.name().ends_with(".rels") { + continue; + } + let mut s = String::new(); + if f.read_to_string(&mut s).is_err() { + return false; + } + let mut reader = quick_xml::Reader::from_str(&s); + loop { + match reader.read_event() { + Ok(quick_xml::events::Event::Eof) => break, + Err(_) => return false, + _ => {} + } + } + } + true + } + + #[test] + fn document_structure_and_body() { + let doc = crate::from_markdown::from_markdown( + "# 제목\n\n본문 **굵게** 문단입니다.\n\n| 가 | 나 |\n|---|---|\n| 1 | 2 |\n", + ); + let bytes = to_docx(&doc).unwrap(); + assert!(all_parts_well_formed(&bytes), "모든 XML well-formed"); + let document = unzip(&bytes, "word/document.xml").unwrap(); + assert!( + document.contains(""), + "제목: {document}" + ); + assert!(document.contains(""), "굵게: {document}"); + assert!(document.contains("본문"), "본문: {document}"); + assert!(document.contains(""), "표: {document}"); + // Regression: 10pt body text must be half-points 20 (100× bug). + assert!( + document.contains(""), + "본문 크기: {document}" + ); + assert!(!document.contains("w:val=\"2000\""), "크기 100배 방지"); + assert!( + unzip(&bytes, "word/styles.xml") + .unwrap() + .contains("Heading1") + ); + assert!(unzip(&bytes, "[Content_Types].xml").is_some()); + assert!(unzip(&bytes, "_rels/.rels").is_some()); + assert!(unzip(&bytes, "word/_rels/document.xml.rels").is_some()); + } + + #[test] + fn merged_cell_span_and_vmerge() { + let mut doc = crate::from_markdown::from_markdown("표\n"); + { + let para = &mut doc.sections[0].paragraphs[0]; + // Manually constructed merged cells — GFM cannot create merges. + use hwp_model::{BorderFillId, Cell, HwpUnit, Table}; + let cell = |row, col, cs, rs, text: &str| Cell { + list_attr: 0, + col, + row, + col_span: cs, + row_span: rs, + width: HwpUnit(0), + height: HwpUnit(0), + margins: [0; 4], + border_fill: BorderFillId(0), + header_tail: vec![], + paragraphs: vec![Paragraph { + chars: text.chars().map(HwpChar::Text).collect(), + char_shape_runs: vec![(0, hwp_model::CharShapeId(0))], + ..Paragraph::default() + }], + }; + let table = Table { + common_data: vec![], + placement: None, + attr: 0, + rows: 2, + cols: 3, + cell_spacing: 0, + inner_margins: [0; 4], + row_cell_counts: vec![2, 2], + border_fill: BorderFillId(0), + table_tail: vec![], + cells: vec![ + cell(0, 0, 2, 1, "가로"), + cell(0, 2, 1, 2, "세로"), + cell(1, 0, 1, 1, "a"), + cell(1, 1, 1, 1, "b"), + ], + extras: vec![], + }; + let idx = para.controls.len() as u32; + para.controls.push(Control::Table(table)); + para.chars.push(HwpChar::ExtCtrl { + code: ctrl_char::OBJECT, + ctrl_id: *b"tbl ", + payload: vec![], + ctrl_index: Some(idx), + }); + } + let bytes = to_docx(&doc).unwrap(); + let document = unzip(&bytes, "word/document.xml").unwrap(); + assert!( + document.contains(""), + "colspan: {document}" + ); + assert!( + document.contains(""), + "rowspan: {document}" + ); + assert!(document.contains(""), "덮인 칸: {document}"); + // Regression: slots covered horizontally (colspan) emit no cell — the first row has only 2 origin cells. + let row1 = document.split("").next().unwrap(); + assert_eq!( + row1.matches("").count(), + 2, + "가로 덮임 미방출: {row1}" + ); + } + + #[test] + fn footnote_hyperlink_and_list() { + let doc = crate::from_markdown::from_markdown( + "본문[^1] [링크](https://example.com)\n\n1. 첫째\n2. 둘째\n\n[^1]: 각주 내용\n", + ); + let bytes = to_docx(&doc).unwrap(); + let document = unzip(&bytes, "word/document.xml").unwrap(); + assert!( + document.contains(""), + "각주 참조" + ); + assert!( + document.contains(""), "목록: {document}"); + let footnotes = unzip(&bytes, "word/footnotes.xml").unwrap(); + assert!(footnotes.contains("각주 내용"), "각주 본문: {footnotes}"); + assert!(footnotes.contains("w:type=\"separator\""), "separator"); + let rels = unzip(&bytes, "word/_rels/document.xml.rels").unwrap(); + assert!( + rels.contains("Target=\"https://example.com\" TargetMode=\"External\""), + "rels: {rels}" + ); + assert!( + unzip(&bytes, "word/numbering.xml") + .unwrap() + .contains("decimal") + ); + // Regression: the bullet numId must match the definition in numbering.xml (128-based). + let doc_b = crate::from_markdown::from_markdown("- 항목 하나\n"); + let bytes_b = to_docx(&doc_b).unwrap(); + let doc_b_xml = unzip(&bytes_b, "word/document.xml").unwrap(); + assert!( + doc_b_xml.contains(""), + "불릿 numId: {doc_b_xml}" + ); + assert!( + unzip(&bytes_b, "word/numbering.xml") + .unwrap() + .contains(""), + "불릿 정의 존재" + ); + // Regression: the equation script goes inside a run. + let mut doc_e = crate::from_markdown::from_markdown("수식: 여기\n"); + { + use hwp_model::{Equation, GenericControl}; + let para = &mut doc_e.sections[0].paragraphs[0]; + let idx = para.controls.len() as u32; + para.controls.push(Control::Generic(GenericControl { + ctrl_id: *b"eqed", + data: vec![], + paragraph_lists: vec![], + extras: vec![], + raw_children: vec![], + gso_shapes: vec![], + equation: Some(Equation { + script: "x^2".to_string(), + ..Equation::default() + }), + column_def: None, + })); + para.chars.push(HwpChar::ExtCtrl { + code: ctrl_char::OBJECT, + ctrl_id: *b"eqed", + payload: vec![], + ctrl_index: Some(idx), + }); + } + let bytes_e = to_docx(&doc_e).unwrap(); + let doc_e_xml = unzip(&bytes_e, "word/document.xml").unwrap(); + assert!( + doc_e_xml.contains("x^2"), + "수식 run: {doc_e_xml}" + ); + } + + #[test] + fn image_embed_and_extent() { + let mut doc = crate::from_markdown::from_markdown("그림: 여기\n"); + let mut png = b"\x89PNG\r\n\x1a\n".to_vec(); + png.extend([0, 0, 0, 13]); + png.extend(b"IHDR"); + png.extend(96u32.to_be_bytes()); + png.extend(96u32.to_be_bytes()); + png.extend([0u8; 8]); + let p = std::env::temp_dir().join(format!("docx_img_{}.png", std::process::id())); + std::fs::write(&p, &png).unwrap(); + crate::image::insert_image(&mut doc, "여기", &p, crate::image::ImageSize::Natural).unwrap(); + let bytes = to_docx(&doc).unwrap(); + let document = unzip(&bytes, "word/document.xml").unwrap(); + assert!(document.contains(""), "드로잉: {document}"); + assert!( + document.contains(""), + "blip: {document}" + ); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + assert!(zip.by_name("word/media/image1.png").is_ok(), "media 엔트리"); + let _ = std::fs::remove_file(&p); + } +} diff --git a/crates/hwp-convert/src/lib.rs b/crates/hwp-convert/src/lib.rs index 57d0a5c..03eef95 100644 --- a/crates/hwp-convert/src/lib.rs +++ b/crates/hwp-convert/src/lib.rs @@ -3,6 +3,7 @@ pub mod base64; pub mod bookmark; pub mod csv; +pub mod docx; pub mod edit; pub mod field; pub mod format; @@ -23,6 +24,7 @@ pub use bookmark::{ BookmarkInfo, bookmark_name, create_bookmark, list_bookmarks, make_bokm_ctrl_data, }; pub use csv::to_csv; +pub use docx::to_docx; pub use edit::{ ObjectKind, add_col, add_rows, add_table, add_table_column, apply_meta, delete_object, delete_table_column, delete_table_row, merge_cells, replace_text, set_cell, split_cell, diff --git a/docs/design/12-feature-gaps.ko.md b/docs/design/12-feature-gaps.ko.md index dcf468f..f1d9d57 100644 --- a/docs/design/12-feature-gaps.ko.md +++ b/docs/design/12-feature-gaps.ko.md @@ -439,7 +439,7 @@ IR→텍스트 포맷 출력에서 잃는 것들. `hwp-convert/src/{markdown,htm | ID | 현상 | 근거 | 현 동작 | 난이도 | |---|---|---|---|---| -| GJ-1 | **DOCX 입출력 부재** — 가장 흔한 상호운용 요구. MS가 공식 배치 변환기(HwpConverter+BATCHHWPCONV)를 배포할 정도의 수요인데 OSS HWP→DOCX는 무주공산 | 코드 흔적 전무(grep), `hwp-cli/src/main.rs` ConvertFormat에 없음 | 미구현 | M~L | +| GJ-1 | **DOCX 입출력 부재** — 가장 흔한 상호운용 요구. MS가 공식 배치 변환기(HwpConverter+BATCHHWPCONV)를 배포할 정도의 수요인데 OSS HWP→DOCX는 무주공산 | `hwp-convert/src/docx.rs` | ✅ **출력 해소(2026-08-01)** — `convert --to docx` (`hwp-convert::docx` — 문단/스타일, gridSpan/vMerge·중첩 표, 그림, 하이퍼링크, 번호, 각주/미주, 수식은 스크립트 폴리백). 입력은 미해소(L급 완전 왕복) | M(출력) / L(입력) | | GJ-2 | **HWPML(.hml) 입출력 부재** — 한컴 공식 스펙(HWPML rev1.2 Part II)·KS 표준 존재, kordoc 구현 선례 | grep 무일치. hwpml은 네임스페이스 URI로만 등장 | 미구현 | M | | GJ-3 | **HWP 3.x 레거시 침묵 거부** — `V3.00` 시그니처 감지 없이 generic "시그니처 불일치" 에러. 공식 스펙(3.0 rev1.2 Part I) 존재, rhwp·kordoc·LibreOffice hwpfilter 선례 | `hwp-cli/src/format.rs:22-38`(CFB/ZIP만) | 침묵 거부 | 감지=S / 파싱=M~L | | GJ-4 | **RTF 입출력 부재** | grep 무일치 | 미구현 | M | @@ -497,7 +497,7 @@ validate·mcp·dump) 기준 부재 목록. 수요 근거는 [08](08-external-res | | **난이도 S**(자료구조만) | **난이도 M**(정답지 필요) | **난이도 L**(실기 반복) | |---|---|---|---| -| **가치 高**(빈출) | GC-4·GC-5(탭·구역속성), GC-8·GC-9(내어쓰기·문단배경) — ✅해소(2026-07-15): ~~GE-α1~α5·α7, GH-1·GH-2, GL-1, GA-5, GE-β4~~ / ✅해소(2026-07-18, md): ~~GH-3·GH-4·GH-5·GH-6, GH-8~~ | GG-3·GG-4(양쪽정렬·자간), GF-2(찾아보기·겹침), **GA-2★**(배포용 읽기 — 공식 스펙 공개), **GJ-1**(DOCX 출력 — 수요 최상·무주공산), **GK-1**(셀 병합), **GK-2**(열 삭제 — 추가는 07-19 해소) — ✅GC-2·GC-3은 07-19 해소(J1 실기 대기) | GG-1·GG-2(글상자 드롭·오버플로) | +| **가치 高**(빈출) | GC-4·GC-5(탭·구역속성), GC-8·GC-9(내어쓰기·문단배경) — ✅해소(2026-07-15): ~~GE-α1~α5·α7, GH-1·GH-2, GL-1, GA-5, GE-β4~~ / ✅해소(2026-07-18, md): ~~GH-3·GH-4·GH-5·GH-6, GH-8~~ | GG-3·GG-4(양쪽정렬·자간), GF-2(찾아보기·겹침), **GA-2★**(배포용 읽기 — 공식 스펙 공개), ~~GJ-1 출력~~(DOCX 내보내기 2026-08-01 해소), **GK-1**(셀 병합), **GK-2**(열 삭제 — 추가는 07-19 해소) — ✅GC-2·GC-3은 07-19 해소(J1 실기 대기) | GG-1·GG-2(글상자 드롭·오버플로) | | **가치 中** | GC-6(글상자 다단), GE-2~GE-6(그림 드롭·단·번호 합성), GF-1(%unk), **GB-12**(참고문헌), **GE-β1·β2·β5**(미리보기·스크립트·설정), **GG-5·GG-6·GG-8~GG-11·GG-16·GG-17·GG-20**(렌더 국소), **GH-3·GH-4·GH-5**(html/odt 각주 마커·병합셀·셀 블록 — md는 2026-07-18 해소), ~~GJ-5·GJ-6~~(csv·txt, 08-01 해소) — ✅GI 계열 전체·GE-7은 07-19 해소, ~~GK-3·GK-4·GK-6·GK-8~~, ~~GM-1·GM-2·GM-5~~, **GM-6**(이미 충족 정정)·GM-7(날인, 07-16 해소) — ✅ 2026-08-01 배치 해소 | GB-4~GB-7·GB-10(글맵시·양식·묶음·메모·바탕쪽), GC-1(세로쓰기), GD-1~GD-3(수식 — rhwp 선례), GE-α6(그러데이션), GF-3(필드 생성), **GB-1 hwpx 차트 생성★**(chartSpace — kordoc 선례), **GJ-2·GJ-3**(hml·HWP3.x — 공식 스펙 공개), **GG-7·GG-12~GG-15·GG-18·GG-19**(렌더 픽셀 대조), **GE-β3·β6**(DocOptions·임베디드 폰트), **GH-7**(ODT 레이아웃), **GK-5·GK-7**(머리말 편집·스타일), **GM-3·GM-4·GM-8**(병합·분할·비교) | GB-2·GB-3(OLE·동영상), **GJ-1 완전 왕복**(docx 들여오기 포함 시) | | **가치 低**(드묾) | GA-3·GA-4(거부 메시지), **GI-5**(embed-bin), **GL-2·GL-3**(추출 세분) | **GJ-4**(rtf) | GA-1(암호화), GB-8·GB-9·GB-11(변경추적 등), **GJ-7**(역방향 입력), **GJ-8**(HWPX 배포용) | @@ -564,5 +564,5 @@ validate·mcp·dump) 기준 부재 목록. 수요 근거는 [08](08-external-res GA-5 버전 게이트, GE-β4 요약정보)은 **2026-07-15에 일괄 해소**됐다(§0.5). 다음 진입점은 **GC-8·GC-9**(내어쓰기·문단배경, S)와 **GE-β5·GM-7**(설정 pass-through· 도장 날인, S)이고, 고가치·고난도의 정공법은 **GC-2·GC-3**(공문서 빈출 쪽테두리·각주모양)과 -**GA-2**(배포용 읽기 — 공식 스펙 공개로 재평가된 M), 상품 관점의 최대 수요는 **GJ-1**(DOCX 출력 -— OSS 무주공산)이다. +**GA-2**(배포용 읽기 — 공식 스펙 공개로 재평가된 M)이 남았다. 과거 최대 수요였던 **GJ-1**(DOCX 출력)은 +2026-08-01 해소(내보내기 전용, 입력은 L급 미해소) — 다음 대형 항목은 GA-2와 GM-3/4/8 계열이다. diff --git a/docs/design/12-feature-gaps.md b/docs/design/12-feature-gaps.md index 4b1758d..a8f5538 100644 --- a/docs/design/12-feature-gaps.md +++ b/docs/design/12-feature-gaps.md @@ -502,7 +502,7 @@ in [08](08-external-research.md). | ID | Symptom | Evidence | Current behavior | Difficulty | |---|---|---|---|---| -| GJ-1 | **No DOCX input or output**: the most common interoperability request. Demand is high enough that Microsoft ships an official batch converter (HwpConverter plus BATCHHWPCONV), yet OSS HWP → DOCX is open territory | no trace in the code (grep), absent from ConvertFormat in `hwp-cli/src/main.rs` | unimplemented | M to L | +| GJ-1 | **No DOCX input or output**: the most common interoperability request. Demand is high enough that Microsoft ships an official batch converter (HwpConverter plus BATCHHWPCONV), yet OSS HWP → DOCX is open territory | `hwp-convert/src/docx.rs` | ✅ **output resolved (2026-08-01)**: `convert --to docx` (`hwp-convert::docx` — paragraphs/styles, tables with gridSpan/vMerge + nesting, images, hyperlinks, numbering, footnotes/endnotes, equations as script fallback). Input stays open (L-tier full round-trip) | M (output) / L (input) | | GJ-2 | **No HWPML (.hml) input or output**: Hancom's official specification (HWPML rev1.2 Part II) and a KS standard exist, with kordoc as an implementation precedent | grep finds nothing; hwpml appears only as a namespace URI | unimplemented | M | | GJ-3 | **HWP 3.x legacy silently refused**: no `V3.00` signature detection, giving a generic "signature mismatch" error. The official specification (3.0 rev1.2 Part I) exists, with rhwp, kordoc and LibreOffice hwpfilter as precedents | `hwp-cli/src/format.rs:22-38` (CFB and ZIP only) | silently refused | detection S / parsing M to L | | GJ-4 | **No RTF input or output** | grep finds nothing | unimplemented | M | @@ -563,7 +563,7 @@ comparison in [08](08-external-research.md). | | **Difficulty S** (data structures only) | **Difficulty M** (needs ground truth) | **Difficulty L** (repeated Hancom testing) | |---|---|---|---| -| **High value** (frequent) | GC-4 and GC-5 (tabs, section properties), GC-8 and GC-9 (hanging indent, paragraph background). ✅ resolved 2026-07-15: ~~GE-α1 to α5 and α7, GH-1 and GH-2, GL-1, GA-5, GE-β4~~ / ✅ resolved 2026-07-18 (markdown): ~~GH-3, GH-4, GH-5, GH-6, GH-8~~ | GG-3 and GG-4 (justify, letter spacing), GF-2 (index marks, overlap), **GA-2 ★** (reading distribution documents; the specification is public), **GJ-1** (DOCX output; highest demand, open territory), **GK-1** (cell merge), **GK-2** (column deletion; addition resolved on 07-19). ✅ GC-2 and GC-3 resolved on 07-19 (J1 awaiting Hancom) | GG-1 and GG-2 (text box drop, overflow) | +| **High value** (frequent) | GC-4 and GC-5 (tabs, section properties), GC-8 and GC-9 (hanging indent, paragraph background). ✅ resolved 2026-07-15: ~~GE-α1 to α5 and α7, GH-1 and GH-2, GL-1, GA-5, GE-β4~~ / ✅ resolved 2026-07-18 (markdown): ~~GH-3, GH-4, GH-5, GH-6, GH-8~~ | GG-3 and GG-4 (justify, letter spacing), GF-2 (index marks, overlap), **GA-2 ★** (reading distribution documents; the specification is public), ~~GJ-1 output~~ (DOCX export resolved 2026-08-01), **GK-1** (cell merge), **GK-2** (column deletion; addition resolved on 07-19). ✅ GC-2 and GC-3 resolved on 07-19 (J1 awaiting Hancom) | GG-1 and GG-2 (text box drop, overflow) | | **Medium value** | GC-6 (multi-column text boxes), GE-2 to GE-6 (picture drop, columns, number synthesis), GF-1 (%unk), **GB-12** (bibliography), **GE-β1, β2, β5** (preview, scripts, settings), **GG-5, GG-6, GG-8 to GG-11, GG-16, GG-17, GG-20** (local rendering), **GH-3, GH-4, GH-5** (html/odt footnote markers, merged cells, in-cell blocks; markdown resolved 2026-07-18), ~~GJ-5 and GJ-6~~ (csv, txt, resolved 08-01). ✅ the whole GI series and GE-7 resolved on 07-19. ~~GK-3, GK-4, GK-6, GK-8~~, ~~GM-1, GM-2, GM-5~~, **GM-6** (corrected to already-covered), GM-7 (sealing, resolved 07-16) — ✅ resolved in the 2026-08-01 batch | GB-4 to GB-7 and GB-10 (word art, forms, grouping, memos, master pages), GC-1 (vertical writing), GD-1 to GD-3 (equations; rhwp precedent), GE-α6 (gradients), GF-3 (field creation), **GB-1 hwpx chart generation ★** (chartSpace; kordoc precedent), **GJ-2 and GJ-3** (hml, HWP 3.x; specifications public), **GG-7, GG-12 to GG-15, GG-18, GG-19** (render pixel comparison), **GE-β3 and β6** (DocOptions, embedded fonts), **GH-7** (ODT layout), **GK-5 and GK-7** (header editing, styles), **GM-3, GM-4, GM-8** (merge, split, compare) | GB-2 and GB-3 (OLE, video), **GJ-1 full round-trip** (if DOCX import is included) | | **Low value** (rare) | GA-3 and GA-4 (refusal messages), **GI-5** (embed-bin), **GL-2 and GL-3** (extraction granularity) | **GJ-4** (rtf) | GA-1 (encryption), GB-8, GB-9, GB-11 (change tracking and so on), **GJ-7** (reverse input), **GJ-8** (HWPX distribution) | @@ -644,5 +644,6 @@ were **all resolved on 2026-07-15** (§0.5). The next entry points are **GC-8 an indent and paragraph background, S) and **GE-β5 and GM-7** (settings pass-through and sealing, S). The high-value, high-difficulty frontal approach is **GC-2 and GC-3** (page borders and footnote shape, frequent in official documents) plus **GA-2** (reading distribution documents, re-rated M now -that the specification is public), and the largest demand from a product perspective is **GJ-1** -(DOCX output, open territory in OSS). +that the specification is public). The former largest demand, **GJ-1** DOCX output, was resolved +on 2026-08-01 (export only; input remains L-tier) — next large items are GA-2 and the GM-3/4/8 +family. diff --git a/docs/manual/cli-reference.ko.md b/docs/manual/cli-reference.ko.md index 9a5dec0..1d829eb 100644 --- a/docs/manual/cli-reference.ko.md +++ b/docs/manual/cli-reference.ko.md @@ -78,7 +78,7 @@ | `` | | | 입력 HWP/HWPX 파일들 ("-"는 stdin; 여러 입력은 --out-dir 필요) (반복 가능) | | `-o, --output` | `` | | 출력 파일 경로 ("-"는 텍스트 포맷(md/json/html/txt/csv)에 한해 stdout; 단일 입력에서 필수) | | `--out-dir` | `` | | 여러 입력의 출력 디렉터리 (파일명은 "<스템>.<확장자>", --to 필요) | -| `--to` | `hwp` \| `hwpx` \| `md` \| `json` \| `html` \| `pdf` \| `odt` \| `txt` \| `csv` | | 출력 포맷 (생략 시 확장자에서 추론) | +| `--to` | `hwp` \| `hwpx` \| `md` \| `json` \| `html` \| `pdf` \| `odt` \| `txt` \| `csv` \| `docx` | | 출력 포맷 (생략 시 확장자에서 추론) | | `--strict` | | | 변환 중 보존 불가능한(opaque) 데이터 발견 시 실패 처리 | | `--preserve-layout` | | | 줄 배치 캐시 보존 (무수정 왕복 전용 — 한글은 내용과 어긋난 줄 배치를 변조로 판정하므로 기본은 제거) | | `--embed-bin` | | | JSON 출력 시 첨부 바이너리(이미지)를 base64로 임베드 (자급식 JSON) | diff --git a/docs/manual/cli-reference.md b/docs/manual/cli-reference.md index e7c6ad2..3467d18 100644 --- a/docs/manual/cli-reference.md +++ b/docs/manual/cli-reference.md @@ -78,7 +78,7 @@ Convert between formats | `` | | | Input HWP/HWPX files ("-" reads stdin; multiple inputs require --out-dir) (repeatable) | | `-o, --output` | `` | | Output file path ("-" writes stdout for text formats: md/json/html/txt/csv; required with a single input) | | `--out-dir` | `` | | Output directory for multiple inputs (file names are ".", requires --to) | -| `--to` | `hwp` \| `hwpx` \| `md` \| `json` \| `html` \| `pdf` \| `odt` \| `txt` \| `csv` | | Output format (inferred from the extension when omitted) | +| `--to` | `hwp` \| `hwpx` \| `md` \| `json` \| `html` \| `pdf` \| `odt` \| `txt` \| `csv` \| `docx` | | Output format (inferred from the extension when omitted) | | `--strict` | | | Fail when data that cannot be preserved (opaque) is found during conversion | | `--preserve-layout` | | | Preserve the line layout cache (unmodified round-trips only; Hancom treats a layout inconsistent with the content as tampering, so it is dropped by default) | | `--embed-bin` | | | Embed attached binaries (images) as base64 in JSON output (self-contained JSON) |