Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
1 change: 1 addition & 0 deletions crates/hwp-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,7 @@ pub enum ConvertFormat {
Odt,
Txt,
Csv,
Docx,
}

/// Official-document preset (`hwp new --preset`).
Expand Down
6 changes: 6 additions & 0 deletions crates/hwp-cli/src/commands/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ fn target_extension(target: ConvertFormat) -> &'static str {
ConvertFormat::Odt => "odt",
ConvertFormat::Txt => "txt",
ConvertFormat::Csv => "csv",
ConvertFormat::Docx => "docx",
}
}

Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -455,6 +460,7 @@ fn infer_format(output: &Path) -> anyhow::Result<ConvertFormat> {
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 => {
Expand Down
67 changes: 64 additions & 3 deletions crates/hwp-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 matchexit 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("<w:tbl>"), "표 방출");
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);
}
Loading
Loading