diff --git a/crates/hwp-convert/src/markdown.rs b/crates/hwp-convert/src/markdown.rs index 9d35448..98f56fa 100644 --- a/crates/hwp-convert/src/markdown.rs +++ b/crates/hwp-convert/src/markdown.rs @@ -1707,6 +1707,7 @@ mod tests { inline, x: 0, y: 0, + ..Equation::default() }), column_def: None, }) @@ -2486,6 +2487,7 @@ mod tests { inline, x: 0, y: 0, + ..Equation::default() }), column_def: None, }) diff --git a/crates/hwp-model/src/control.rs b/crates/hwp-model/src/control.rs index c036a51..bca3d27 100644 --- a/crates/hwp-model/src/control.rs +++ b/crates/hwp-model/src/control.rs @@ -266,7 +266,7 @@ pub struct ColumnDef { } /// 수식 개체 — 렌더러가 상자+스크립트 텍스트로 근사한다. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Equation { /// HWP 수식 스크립트 원문. pub script: String, @@ -278,6 +278,17 @@ pub struct Equation { /// 떠 있는 경우 페이지 절대 오프셋(HWPUNIT). pub x: i32, pub y: i32, + /// hwpx ``의 시작 태그 속성 원문(`id` 제외 — writer가 재부여). + /// 글자색·기준선·baseUnit·수식 글꼴·zOrder·본문배치 등 IR이 의미 모델링하지 않는 + /// 속성을 왕복에서 보존하기 위한 pass-through다([`SectionDef::secpr_raw_children`] + /// 와 같은 규약). 비어 있으면(hwp5 출신·합성) writer가 표준값을 방출한다. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw_attrs: Option, + /// 개체 공통 자식(`hp:sz`·`hp:pos`·`hp:outMargin`·caption 등)의 원문 XML을 등장 + /// 순서대로 보존한다(`hp:script`는 제외 — script 필드가 정본). 비어 있으면 writer가 + /// width/height/inline/x/y와 hwp5 gso 공통 헤더로 재구성한다. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub raw_props: Vec, } /// 도형 종류 (hwpx 그리기 개체). diff --git a/crates/hwp-render/tests/render.rs b/crates/hwp-render/tests/render.rs index 58fb604..9f608f3 100644 --- a/crates/hwp-render/tests/render.rs +++ b/crates/hwp-render/tests/render.rs @@ -265,6 +265,7 @@ fn 수식_조판_렌더() { inline: false, x: 8000, y: 6000 + i as i32 * 5000, + ..Equation::default() }), column_def: None, })); diff --git a/crates/hwp5/src/body_text.rs b/crates/hwp5/src/body_text.rs index 50272d6..2dd7ce9 100644 --- a/crates/hwp5/src/body_text.rs +++ b/crates/hwp5/src/body_text.rs @@ -591,6 +591,9 @@ fn parse_eqed(data: &[u8], children: &[RecordNode]) -> Option { inline: attr & 1 == 1, // bit0 = 글자처럼 취급 x: rd(8), // hoff y: rd(4), // voff + // hwpx 원문 pass-through는 hwpx 출신 전용 — hwp5 출신은 배치를 gso 공통 헤더 + // (`GenericControl::data`)에서 그대로 읽을 수 있어 hwpx writer가 재구성한다. + ..Equation::default() }) } diff --git a/crates/hwpx/src/read/section.rs b/crates/hwpx/src/read/section.rs index 7ac9d04..33005d9 100644 --- a/crates/hwpx/src/read/section.rs +++ b/crates/hwpx/src/read/section.rs @@ -286,19 +286,7 @@ fn parse_text( } // 엔티티 참조(& &#x...;)는 별도 이벤트로 온다 Event::GeneralRef(r) => { - let resolved = r - .resolve_char_ref() - .ok() - .flatten() - .or_else(|| match &r[..] { - b"amp" => Some('&'), - b"lt" => Some('<'), - b"gt" => Some('>'), - b"quot" => Some('"'), - b"apos" => Some('\''), - _ => None, - }); - if let Some(c) = resolved { + if let Some(c) = resolve_entity(&r) { push_text_char(para, wchar_pos, c); } } @@ -1242,24 +1230,32 @@ fn parse_equation( let mut script = attr(start, "script").unwrap_or_default(); let (mut width, mut height, mut x, mut y) = (0i32, 0i32, 0i32, 0i32); let mut inline = true; + // 의미 모델링하지 않는 속성(글자색·baseUnit·수식 글꼴·zOrder·본문배치…)은 원문으로 + // 보존해 왕복에서 되쓴다. id 는 writer 가 문서 전역으로 다시 매기므로 제외한다. + let raw_attrs = raw_attrs_except(start, b"id"); + let mut raw_props = Vec::new(); if !empty { loop { let ev = next_event(reader)?; match &ev { Event::Start(e) | Event::Empty(e) => { let is_start = matches!(ev, Event::Start(_)); + let is_empty_el = matches!(ev, Event::Empty(_)); match e.local_name().as_ref() { b"script" if is_start => script = read_element_text(reader, b"script")?, b"sz" => { width = attr_i32(e, "width").unwrap_or(width); height = attr_i32(e, "height").unwrap_or(height); + raw_props.push(capture_element(reader, e, is_empty_el)?); } b"pos" => { inline = attr(e, "treatAsChar").as_deref() == Some("1"); x = attr_offset_i32(e, "horzOffset").unwrap_or(0); y = attr_offset_i32(e, "vertOffset").unwrap_or(0); + raw_props.push(capture_element(reader, e, is_empty_el)?); } - _ => {} + // outMargin·caption·shapeComment 등 나머지 공통 자식도 원문 보존. + _ => raw_props.push(capture_element(reader, e, is_empty_el)?), } } Event::End(e) if e.local_name().as_ref() == b"equation" => break, @@ -1275,9 +1271,46 @@ fn parse_equation( inline, x, y, + raw_attrs, + raw_props, }) } +/// 시작 태그의 속성을 `name="value"` 원문으로 되돌린다(`skip` 이름 하나는 제외). +/// 값은 파일에 있던 이스케이프 형태 그대로 다시 쓰므로(재이스케이프 없음) 왕복이 닫힌다. +fn raw_attrs_except(start: &BytesStart<'_>, skip: &[u8]) -> Option { + let mut out = String::new(); + for a in start.attributes().flatten() { + if a.key.local_name().as_ref() == skip { + continue; + } + let key = String::from_utf8_lossy(a.key.as_ref()).into_owned(); + let val = String::from_utf8_lossy(&a.value).into_owned(); + if !out.is_empty() { + out.push(' '); + } + out.push_str(&format!("{key}=\"{val}\"")); + } + (!out.is_empty()).then_some(out) +} + +/// 엔티티 참조(`&` `≤`)를 문자로 되돌린다. quick-xml은 참조를 텍스트에 +/// 합치지 않고 `Event::GeneralRef`로 따로 주므로, 텍스트를 모으는 쪽마다 이 해석이 +/// 필요하다. DTD 없이는 외부 엔티티를 풀 수 없어 미리 정의된 5종 + 숫자 참조만 푼다. +fn resolve_entity(r: &quick_xml::events::BytesRef<'_>) -> Option { + r.resolve_char_ref() + .ok() + .flatten() + .or_else(|| match &r[..] { + b"amp" => Some('&'), + b"lt" => Some('<'), + b"gt" => Some('>'), + b"quot" => Some('"'), + b"apos" => Some('\''), + _ => None, + }) +} + /// 주어진 요소가 닫힐 때까지 텍스트를 모은다. fn read_element_text(reader: &mut XmlReader<'_>, end: &[u8]) -> Result { let mut out = String::new(); @@ -1290,6 +1323,22 @@ fn read_element_text(reader: &mut XmlReader<'_>, end: &[u8]) -> Result { })?; out.push_str(&s); } + // 수식 스크립트는 `x < y`처럼 XML 특수문자를 담는다 — 참조를 풀지 않으면 + // 그 글자가 통째로 사라진다(writer의 esc()와 짝을 이루는 역변환). + Event::GeneralRef(r) => { + if let Some(c) = resolve_entity(&r) { + out.push(c); + } + } + // CDATA 구획(``)은 Text가 아닌 별도 이벤트다. 내용은 이미 + // 문자 그대로라 참조 해석 없이 붙인다. + Event::CData(t) => { + let s = t.xml10_content().map_err(|e| HwpxError::Xml { + entry: "section".to_string(), + message: e.to_string(), + })?; + out.push_str(&s); + } Event::End(e) if e.local_name().as_ref() == end => break, Event::Eof => break, _ => {} diff --git a/crates/hwpx/src/read/xml.rs b/crates/hwpx/src/read/xml.rs index 8dbe6dc..387ded8 100644 --- a/crates/hwpx/src/read/xml.rs +++ b/crates/hwpx/src/read/xml.rs @@ -3,11 +3,20 @@ use quick_xml::events::BytesStart; /// 로컬 이름(네임스페이스 접두사 제거) 기준 속성 조회. +/// 속성값을 **엔티티 해석까지 마친** 문자열로 돌려준다. quick-xml은 속성값을 파일에 있던 +/// 이스케이프 형태 그대로 주므로, 풀지 않으면 `name="A&B"`가 IR에 `A&B`로 올라가고 +/// writer의 `esc()`가 다시 감싸 `A&amp;B`로 이중 이스케이프된다(책갈피·필드 이름, +/// 수식 script 속성에서 실제로 발생). 해석 실패(잘못된 참조)면 원문을 그대로 둔다. pub fn attr(e: &BytesStart<'_>, name: &str) -> Option { e.attributes().flatten().find_map(|a| { let key = a.key.local_name(); if key.as_ref() == name.as_bytes() { - Some(String::from_utf8_lossy(&a.value).into_owned()) + let raw = String::from_utf8_lossy(&a.value).into_owned(); + Some( + quick_xml::escape::unescape(&raw) + .map(|s| s.into_owned()) + .unwrap_or(raw), + ) } else { None } @@ -56,4 +65,17 @@ mod tests { assert_eq!(parse_color("#000000"), 0); assert_eq!(parse_color("none"), 0xFFFF_FFFF); } + + /// 속성값 엔티티는 읽는 즉시 풀린다 — 안 풀면 writer의 esc()가 다시 감싸 + /// `A&B` → `A&amp;B`로 왕복마다 이중 이스케이프가 쌓인다. + #[test] + fn 속성값_엔티티_해석() { + let el = quick_xml::events::BytesStart::from_content( + r#"hp:bookmark name="A&B <중> 민" bad="A&nosuch;B""#, + 12, + ); + assert_eq!(attr(&el, "name").as_deref(), Some("A&B <중> 민")); // 민 = U+BBFC + // 해석 불가한 참조는 원문 유지(정보 손실 없이 그대로 되쓴다). + assert_eq!(attr(&el, "bad").as_deref(), Some("A&nosuch;B")); + } } diff --git a/crates/hwpx/src/write/section.rs b/crates/hwpx/src/write/section.rs index a274b70..1e4b985 100644 --- a/crates/hwpx/src/write/section.rs +++ b/crates/hwpx/src/write/section.rs @@ -8,8 +8,8 @@ use std::collections::BTreeMap; use std::fmt::Write as _; use hwp_model::{ - BinRef, Cell, Control, Document, GenericControl, HwpChar, PageDef, Paragraph, Picture, Section, - SectionDef, ShapeKind, Table, + BinRef, Cell, Control, Document, Equation, GenericControl, HwpChar, PageDef, Paragraph, + Picture, Section, SectionDef, ShapeKind, Table, }; use crate::write::templates::{color_attr, esc}; @@ -102,8 +102,10 @@ const SHAPE_RUN_LIMIT: usize = 12; /// 방출된 XML 조각에서 최상위 그리기 도형 요소 수를 센다. ` usize { - const OPENS: [&str; 8] = [ + const OPENS: [&str; 9] = [ " usize { " { + // 수식 — hp:ctrl이 아닌 run 직속 개체(리더 parse_equation의 역). + // hwpx 출신(hp:script)·hwp5 출신(EQEDIT 스크립트, parse_eqed) 모두 + // IR Equation을 채우므로 같은 경로로 방출한다. + open_run!(cur_shape); + flush_text(out, &mut text_buf, &mut pending_tabs); + shape_break!(); + let eq = g.equation.as_ref().expect("is_some 가드"); + let before = out.len(); + write_equation(out, g, eq, ids); + run_shapes += count_shape_tags(&out[before..]); + } Control::Generic(g) => { warnings.push(format!( "DROP: hwpx 쓰기 미지원 컨트롤 드롭: {:?}", @@ -979,6 +994,72 @@ fn write_foot_end_note( let _ = write!(out, ""); } +/// 수식 → ``(run 직속). 자식 순서는 개체 공통 규약(sz → pos → outMargin, +/// 그 뒤 개체 전용)을 따른다 — 한컴 공식 모델 `AbstractShapeObjectType`과 동일. +/// +/// 소스별 충실도 3단: +/// 1. hwpx 출신 — 시작 태그 속성(`raw_attrs`)과 공통 자식(`raw_props`)을 원문 그대로 +/// 되쓴다. 글자색·baseUnit·수식 글꼴·zOrder·배치기준이 왕복에서 살아남는다. +/// 2. hwp5 출신 — 공통 자식은 gso 공통 헤더(`g.data`)로 재구성한다. 배치기준(PAGE/PARA)· +/// 정렬·z-order가 헤더에 있으므로 `gso_pos_xml`을 그대로 쓴다(그림·표와 같은 경로). +/// 3. 합성(md/JSON 출신) — 아래 표준값. `lineMode`는 한컴 모델 열거값 LINE|CHAR 중 +/// 기본값 CHAR다(`enumdef.h` g_EquationLineList). +/// +/// ⚠ 3의 수식 전용 상수(version·baseLine·baseUnit·font)는 **정답지 미확보 상태의 표준 +/// 추정값**이다. 실기에서 수식이 깨져 보이면 정품 저장본 속성으로 교체할 것 +/// (12-feature-gaps.md GE-14). +fn write_equation(out: &mut String, g: &GenericControl, eq: &Equation, ids: &mut IdSeq) { + let id = ids.next(); + match &eq.raw_attrs { + Some(raw) => { + let _ = write!(out, r##""##); + } + None => { + // 인라인(글자처럼 취급)은 본문 흐름을 따르고, 부유는 겹침 허용 — gso_pos_xml과 + // 같은 실측 규칙(부유에 flowWithText=1을 주면 한글이 개체를 배치하지 못한다). + let wrap = if eq.inline { + "SQUARE" + } else { + "IN_FRONT_OF_TEXT" + }; + let z = parse_gso_header(&g.data).map_or(0, |h| h.5.max(0)); + let _ = write!( + out, + r##""## + ); + } + } + if eq.raw_props.is_empty() { + // hwp5 출신이면 gso 공통 헤더에서 배치를 복원하고(그림·표와 동일 경로), 없으면 + // IR의 inline/오프셋으로 합성한다. + let pos_xml = match parse_gso_header(&g.data) { + Some((attr, voff, hoff, _, _, _)) => gso_pos_xml(attr, voff, hoff), + None => { + let (treat, flow, overlap) = if eq.inline { (1, 1, 0) } else { (0, 0, 1) }; + format!( + r##""##, + eq.y, eq.x, + ) + } + }; + let _ = write!( + out, + r##"{pos_xml}"##, + eq.width.max(0), + eq.height.max(0), + ); + } else { + for p in &eq.raw_props { + out.push_str(p); + } + } + let _ = write!( + out, + "{}", + esc(&eq.script) + ); +} + /// hwp5 gso 공통 개체 헤더(20B+): attr(u32)@0, 세로 오프셋@4, 가로 오프셋@8, 폭@12, 높이@16, /// **z-order@20**. hwp5 `parse_picture_gso`/hwp-render `parse_gso_box`와 동일 레이아웃(역의존 /// 불가라 로컬 복제). z-order는 도형 겹침 순서 — 이를 `zOrder="0"`로 뭉개면 한글이 다중 도형을 diff --git a/crates/hwpx/src/write/templates.rs b/crates/hwpx/src/write/templates.rs index 109dfc2..a48342e 100644 --- a/crates/hwpx/src/write/templates.rs +++ b/crates/hwpx/src/write/templates.rs @@ -119,6 +119,9 @@ pub fn esc(s: &str) -> String { // 해도 무효이고, raw로 방출하면 한글이 파일을 거부한다. 탭/개행은 상위 // (flush_text)에서 이미 · 요소로 변환돼 여기 오지 않는다. c if (c as u32) < 0x20 && c != '\t' && c != '\n' && c != '\r' => {} + // 비문자(U+FFFE·U+FFFF)도 XML 1.0 허용 범위 밖이다. 이걸 흘리면 well-formed + // 하지 않은 패키지가 나와 파서·한글이 파일 전체를 거부한다(C0와 같은 처리). + '\u{FFFE}' | '\u{FFFF}' => {} _ => out.push(c), } } @@ -219,6 +222,16 @@ mod tests { assert_eq!(parsed.author.as_deref(), Some("hwp-cli")); } + /// XML 1.0 허용 범위 밖 문자는 이스케이프가 아니라 제거다 — 흘리면 패키지가 + /// well-formed하지 않아 파서·한글이 파일 전체를 거부한다(수식 스크립트에서 유입 가능). + #[test] + fn esc_금지문자_제거() { + assert_eq!(esc("a"##, + r##""##, + r##""##, + r##""##, + r##"x < y"##, + r##""##, + ); + let (section, w) = hwpx::read::section::parse_section(src).unwrap(); + assert!(w.is_empty(), "{w:?}"); + let mut doc = hwp_convert::from_markdown("본문"); + doc.sections = vec![section]; + + let out = tmp("equation_attrs.hwpx"); + assert!( + !hwpx::write_document(&doc, &out) + .unwrap() + .iter() + .any(|w| w.contains("DROP")) + ); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(&out).unwrap())).unwrap(); + let mut xml = String::new(); + zip.by_name("Contents/section0.xml") + .unwrap() + .read_to_string(&mut xml) + .unwrap(); + for keep in [ + r#"zOrder="7""#, + r#"textWrap="BEHIND_TEXT""#, + r#"lock="1""#, + r#"baseLine="70""#, + r##"textColor="#FF0000""##, + r#"baseUnit="2200""#, + r#"lineMode="LINE""#, + r#"font="맑은 고딕""#, + r#"vertRelTo="PAGE""#, + r#"horzOffset="2000""#, + r#" g.equation.clone(), + _ => None, + }) + .expect("수식 왕복"); + assert_eq!(eq.script, "x < y"); + + // 합성 경로(원문 없음)는 스키마 유효한 lineMode 열거값을 써야 한다. + let mut synth = hwp_convert::from_markdown("본문\n\n둘째"); + attach_gso( + &mut synth.sections[0].paragraphs[1], + eqed_control(hwp_model::Equation { + script: "x".to_string(), + ..Default::default() + }), + ); + let out2 = tmp("equation_synth.hwpx"); + hwpx::write_document(&synth, &out2).unwrap(); + let mut zip2 = + zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(&out2).unwrap())).unwrap(); + let mut xml2 = String::new(); + zip2.by_name("Contents/section0.xml") + .unwrap() + .read_to_string(&mut xml2) + .unwrap(); + assert!(xml2.contains(r#"lineMode="CHAR""#), "합성 lineMode: {xml2}"); + assert!( + !xml2.contains(r#"lineMode="0""#), + "스키마 밖 lineMode: {xml2}" + ); +} + +/// 적대적 점검(codex) 반영 ③: hwp5 출신 부유 수식의 배치(PAGE 기준·오프셋·z-order)가 +/// gso 공통 헤더에서 복원된다. 이전엔 PARA/PARA·zOrder 0 상수라 페이지 기준으로 놓인 +/// 수식이 문단 기준으로 옮겨갔다(그림 GE-9와 같은 결함). +#[test] +fn 수식_hwp5출신_배치_복원() { + // gso 공통 헤더 24B: attr(bit0=인라인 off, bits3-4=vertRel PAGE, bits8-9=horzRel PAGE) + // + voff + hoff + width + height + zorder. + let mut data = Vec::new(); + let attr: u32 = (1 << 3) | (1 << 8); // vertRelTo=PAGE(1), horzRelTo=PAGE(1), 부유 + data.extend(attr.to_le_bytes()); + data.extend(1000i32.to_le_bytes()); // voff + data.extend(2000i32.to_le_bytes()); // hoff + data.extend(4000i32.to_le_bytes()); // width + data.extend(1200i32.to_le_bytes()); // height + data.extend(7i32.to_le_bytes()); // z-order + + let mut doc = hwp_convert::from_markdown("본문\n\n둘째"); + let mut g = eqed_control(hwp_model::Equation { + script: "x over y".to_string(), + width: 4000, + height: 1200, + inline: false, + x: 2000, + y: 1000, + ..Default::default() + }); + g.data = data; + attach_gso(&mut doc.sections[0].paragraphs[1], g); + + let out = tmp("equation_hwp5_pos.hwpx"); + hwpx::write_document(&doc, &out).unwrap(); + let mut zip = zip::ZipArchive::new(std::io::Cursor::new(std::fs::read(&out).unwrap())).unwrap(); + let mut xml = String::new(); + zip.by_name("Contents/section0.xml") + .unwrap() + .read_to_string(&mut xml) + .unwrap(); + let eq = &xml[xml.find(" hwp_model::GenericControl { + hwp_model::GenericControl { + ctrl_id: *b"eqed", + data: Vec::new(), + paragraph_lists: Vec::new(), + extras: Vec::new(), + raw_children: Vec::new(), + gso_shapes: Vec::new(), + equation: Some(equation), + column_def: None, + } +} + +/// GE-14: 수식이 ``으로 방출되고 스크립트·크기·배치가 왕복에서 살아남는다. +/// 이전엔 writer arm이 없어 통째로 DROP — 각주 든 문서를 편집만 해도 수식이 사라졌다. +/// XML 특수문자(`<`·`&`) 케이스는 writer `esc()` ↔ reader 엔티티 해석의 짝을 고정한다. +#[test] +fn 수식_hwpx_왕복() { + use hwp_model::{Control, Equation, GenericControl}; + + let equations = [ + Equation { + script: "a over b = c_1 ^2".to_string(), + width: 4000, + height: 1200, + inline: true, + x: 0, + y: 0, + ..Equation::default() + }, + // 특수문자: esc()가 <·&로 쓰고 reader가 되돌리지 못하면 글자가 사라진다. + Equation { + script: "x < y & y > z".to_string(), + width: 2000, + height: 900, + inline: false, + x: 3000, + y: 5000, + ..Equation::default() + }, + ]; + let mut doc = hwp_convert::from_markdown("본문\n\n둘째"); + for eq in equations.iter().cloned() { + attach_gso( + &mut doc.sections[0].paragraphs[1], + GenericControl { + ctrl_id: *b"eqed", + data: Vec::new(), + paragraph_lists: Vec::new(), + extras: Vec::new(), + raw_children: Vec::new(), + gso_shapes: Vec::new(), + equation: Some(eq), + column_def: None, + }, + ); + } + + let out = tmp("equation.hwpx"); + let warnings = hwpx::write_document(&doc, &out).unwrap(); + assert!(!warnings.iter().any(|w| w.contains("DROP")), "{warnings:?}"); + + let reread = hwpx::read_document(&out).unwrap(); + assert!( + !reread.warnings.iter().any(|w| w.contains("DROP")), + "{:?}", + reread.warnings + ); + let got: Vec<_> = reread.document.sections[0] + .paragraphs + .iter() + .flat_map(|p| &p.controls) + .filter_map(|c| match c { + Control::Generic(g) => g.equation.as_ref(), + _ => None, + }) + .collect(); + assert_eq!(got.len(), 2, "수식 2개 왕복"); + for (got, want) in got.iter().zip(&equations) { + assert_eq!(got.script, want.script, "스크립트 원문"); + assert_eq!((got.width, got.height), (want.width, want.height), "크기"); + assert_eq!(got.inline, want.inline, "글자처럼 취급"); + } + // 부유 수식의 오프셋은 에 실려야 한다(0으로 뭉개면 좌상단에 몰린다). + assert_eq!((got[1].x, got[1].y), (3000, 5000), "부유 오프셋"); +} + /// hwp5-출신 글상자(gso + 문단)가 hwpx `+` 왕복을 통과한다 — /// 이전엔 통째로 드롭돼 안의 텍스트가 소실됐다. #[test] diff --git a/docs/design/12-feature-gaps.md b/docs/design/12-feature-gaps.md index f328eff..8251d7e 100644 --- a/docs/design/12-feature-gaps.md +++ b/docs/design/12-feature-gaps.md @@ -254,6 +254,26 @@ hwpx/렌더로 내보내는 것"이다 → 정답지로 레코드 레이아웃 | GE-12 | **문서 시작번호 hwpx read 미파싱** — write는 ``을 방출하지만 read에 파싱이 없어(전체 grep 0건) hwpx 소스의 페이지/각주/미주/그림/표/수식 시작번호가 IR에 안 올라옴 | write `hwpx/src/write/header.rs:53-59` ↔ read 부재 | §4.2.1 / `hh:beginNum` | 드롭(read) | 왕복(hwpx→hwpx)·합성(hwpx→hwp5) | S(GE-3 계열 동일 패턴) | | GE-13 | **스타일 종류(PARA/CHAR) hwpx 양방향 무시** — read는 `type` 속성을 안 읽고(attr 항상 0) write는 `type="PARA"` 고정 → **글자 스타일이 항상 문단 스타일로 오기록**, hwpx→hwpx 왕복에서도 매번 유실 | read `hwpx/src/read/header.rs:589-597` ↔ write `hwpx/src/write/header.rs:527-538` | §4.2.11 표 48 / `hh:style@type` | 근사(PARA 고정) | 왕복(hwpx→hwpx)·합성 | S | +| GE-14 | **수식(eqed) hwpx 쓰기 드롭** — read는 ``을(hwp5는 EQEDIT 스크립트를) IR `Equation`으로 올리지만 writer arm이 없어 generic fallback으로 빠짐 → hwpx를 편집·변환만 해도 수식이 통째로 사라짐 | write `hwpx/src/write/section.rs`(`write_equation`) ↔ read `hwpx/src/read/section.rs`(`parse_equation`), hwp5 `body_text.rs`(`parse_eqed`) | §4.3.9.3 / `hp:equation` | ✅ **해소(2026-07-27)** — run 직속 `` + `hp:sz`/`hp:pos`/`hp:script` 방출(`수식_hwpx_왕복` 락). ⚠ 수식 전용 속성(version·baseLine·baseUnit·font)은 **정답지 미확보 표준 추정값** — 한글 실기 확정 대기 | 왕복(hwpx→hwpx)·합성(hwp5→hwpx) | S | +| GE-15 | **hp:script 엔티티·CDATA 유실(read)** — `read_element_text`가 `Event::GeneralRef`/`CData`를 무시해 `x < y` 같은 수식 스크립트의 특수문자가 읽기에서 사라짐(`hp:t` 파서에는 있던 해석이 이 경로만 누락) | `hwpx/src/read/section.rs`(`resolve_entity` 공용 헬퍼 — `parse_text`와 공유) | XML 1.0 §4.6 미리 정의 엔티티 | ✅ **해소(2026-07-27)** — 참조 5종+숫자 참조 해석, CDATA 구획 수집. writer `esc()`와 짝을 이루는 역변환 | 왕복(hwpx→hwpx)·추출 | S | + +| GE-16 | **속성값 엔티티 이중 이스케이프(hwpx read)** — `attr()`이 quick-xml raw 값을 그대로 올려(`A&B` → IR `A&amp;B`가 아니라 `A&B` 원문), writer `esc()`가 다시 감싸 왕복마다 `&`가 늘어남. 책갈피·필드·스타일 이름, 수식 `script` 속성 전부 해당 | `hwpx/src/read/xml.rs`(`attr` — unescape 적용) | XML 1.0 §4.6 | ✅ **해소(2026-07-27)** — 속성값을 읽는 즉시 엔티티 해석(해석 불가 참조는 원문 유지). 적대적 점검(codex) 발견 | 왕복(hwpx→hwpx)·합성 | S | +| GE-17 | **XML 비문자(U+FFFE·U+FFFF) 방출** — `esc()`가 C0 제어문자만 걸러 비문자를 그대로 흘림 → well-formed하지 않은 패키지가 되어 파서·한글이 **파일 전체를 거부** | `hwpx/src/write/templates.rs`(`esc`) | XML 1.0 §2.2 Char 범위 | ✅ **해소(2026-07-27)** — C0와 동일하게 제거(`esc_금지문자_제거`). 적대적 점검(codex) 발견 | 모든 hwpx 쓰기 | S | + +> **2026-07-27 (GE-14~GE-17)**: 외부 기여 PR #7의 진단(각주/미주·수식 writer arm 부재)에서 +> 각주/미주는 `e433462`(실측판)로 선행 해소됐고, 남은 수식 방출 arm + 엔티티 해석만 별도 +> 구현. **적대적 점검(codex, 한컴 공식 OWPML 모델 소스 대조)** 으로 4건을 추가 수정했다: +> ① `lineMode="0"` → 열거값 `CHAR`(`enumdef.h` `g_EquationLineList` = LINE|CHAR, 기본 CHAR), +> ② hwpx 출신 수식의 비기본 속성·공통 자식 원문 pass-through(`Equation::raw_attrs`·`raw_props` +> — 이전엔 zOrder·textWrap·baseUnit·글자색·수식 글꼴·PAGE 기준 배치가 전부 기본값으로 재작성), +> ③ hwp5 출신 부유 수식 배치를 gso 공통 헤더에서 복원(`gso_pos_xml` 재사용 — 그림 GE-9와 동형), +> ④ GE-16·GE-17. 수식을 run당 개체 한도 카운트에도 포함시켰다(과소 계상 = 개체 유실). +> +> **잔여**: `` 합성 경로의 수식 전용 상수(version·baseLine·baseUnit·font)는 +> 여전히 **정답지 미확보 표준 추정값**이다(코퍼스에 수식 든 hwpx 부재). 한글 실기(`L1_수식.hwpx`, +> `docs/실기검증-체크리스트.md` §L)에서 수식 표시가 깨지면 정품 저장본 속성으로 교체할 것. +> read가 `hp:script`를 `trim()`하는 점(앞뒤 공백 비보존)도 미해결이나 조판 영향은 없다. + > **2026-07-19 (GK 배치)**: GK-1·GK-2 구현 — 정품 병합 표 1,816개 전수 실측으로 저장 구조 > 5규칙 확정(만장일치) 후 프리미티브 4종+CLI 4종+불변식 게이트. 정품 병합 표 회귀·왕복·세트 > 30/0(K1~K3 신설). 전체 테스트 322. **✅K 시리즈 실기 확정(2026-07-19)** — 병합·열 조작 diff --git "a/docs/\354\213\244\352\270\260\352\262\200\354\246\235-\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" "b/docs/\354\213\244\352\270\260\352\262\200\354\246\235-\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" index b08b1af..d9e4f7b 100644 --- "a/docs/\354\213\244\352\270\260\352\262\200\354\246\235-\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" +++ "b/docs/\354\213\244\352\270\260\352\262\200\354\246\235-\354\262\264\355\201\254\353\246\254\354\212\244\355\212\270.md" @@ -186,6 +186,20 @@ md→hwp 합성만 TOP(0) 방출(hwpx는 상수 CENTER라 무관). 셀 합성 **✅ 2차 재실기 통과(2026-07-19): 세로 가운데 정렬 확인 — K 시리즈 완전 종결, GK-1·GK-2 실기 확정(셀 세로 정렬 CENTER 기본값 규칙 포함).** +## L. 수식 방출 (2026-07-27 — GE-14·GE-15, [12](design/12-feature-gaps.md) §5.1) + +hwpx writer가 새로 방출하기 시작한 ``의 실기 수용 확인. **정답지(수식 든 정품 +hwpx)를 확보하지 못해 수식 전용 속성(`version`·`baseLine`·`baseUnit`·`font`)이 표준 +추정값**이라, 이 케이스만은 "열리는가"보다 **"수식이 실제로 조판돼 보이는가"** 가 핵심 판정이다. + +| 파일 | 무엇을 테스트 | 정상 확인법 | +|---|---|---| +| `L1_수식.hwpx` | 인라인 수식 3종(분수+근호 / 합기호 / XML 특수문자) | "수식 자리:" 문단 끝에 수식 3개가 **글자처럼 나란히** 보인다. ① `a/b + √(x²+y²)` 꼴 분수·근호 ② Σ 상하한이 붙은 합 ③ `x < y & y > z`의 **부등호와 앰퍼샌드가 살아 있음**(빠지면 esc↔엔티티 왕복 결함). 수식을 더블클릭하면 수식 편집기가 원래 스크립트로 열려야 한다 | + +실패 시 회수할 것: 수식이 **빈 상자/물음표로 보이는지**, 아예 **자리만 있고 안 보이는지**, +수식 편집기가 열리는지. 셋 중 무엇이냐에 따라 잘못된 속성이 갈린다(`font`/`baseUnit` vs +`version` vs 자식 요소 순서). 정품 저장본 1개만 확보되면 그 속성으로 즉시 교체한다. + ## 결과 회수 각 파일에 O/X와 (실패 시) 팝업 메시지·증상을 알려주시면, 실패 항목만 정품 대조 패턴 (가나다·다문단 실측)으로 수정합니다. 통과 항목은 미검증 → 실기 통과로 확정합니다. diff --git a/tools/gen_effects_cases.py b/tools/gen_effects_cases.py index 8c4a129..713680d 100644 --- a/tools/gen_effects_cases.py +++ b/tools/gen_effects_cases.py @@ -1225,6 +1225,63 @@ def k3_cols_hwpx(dest): return out, "3열 표 열 추가+삭제 → 순 3열·6셀, validate ✅" +# ── L 시리즈: 수식 방출 (GE-14·GE-15) ─────────────────────────────────────── +# hwpx writer 의 arm 은 정답지(수식 든 정품 hwpx) 없이 표준 추정 속성으로 +# 방출한다. 한글이 이 수식을 열어 실제로 조판하는지가 유일한 최종 판정이라 실기 케이스를 둔다. + +EQED_ID = [101, 113, 101, 100] # b"eqed" +EQED_PAYLOAD = "646571650000000000000000" # 역순 ctrl_id("deqe") + 0 패딩 12B + + +def _attach_equation(p, script, width=4000, height=1300, inline=True): + """문단 p 끝에 수식 컨트롤(ExtCtrl 코드 11 + Generic.equation)을 단다.""" + controls = p.setdefault("controls", []) + p["chars"].append({"ExtCtrl": { + "code": 11, "ctrl_id": EQED_ID, "payload": EQED_PAYLOAD, + "ctrl_index": len(controls), + }}) + controls.append({"Generic": { + "ctrl_id": EQED_ID, "data": "", "paragraph_lists": [], "extras": [], + "equation": {"script": script, "width": width, "height": height, + "inline": inline, "x": 0, "y": 0}, + }}) + + +def l1_equation_hwpx(dest): + # 분수·근호·합·첨자 + XML 특수문자(<, &, >) — esc() ↔ 엔티티 해석의 짝을 실기로 검증. + scripts = [ + "a over b + sqrt {x^2 + y^2}", + "sum from {i=1} to {n} i = {n(n+1)} over 2", + "x < y & y > z", + ] + ir = base_ir( + "# 수식 실기 검증\n\n아래 문단 끝에 수식 3개가 글자처럼 들어간다.\n\n" + "수식 자리:\n\n분수·근호·합기호와 부등호(<)·앰퍼샌드(&)가 모두 보여야 한다.\n", + "l1", + ) + p = find_para(ir, "수식 자리") + for s in scripts: + _attach_equation(p, s) + out = os.path.join(dest, "L1_수식.hwpx") + r = emit(ir, out, "l1") + if not os.path.exists(out): + raise RuntimeError(f"생성 실패: {r.stderr.strip()[:200]}") + # 재읽기 단언: 3개가 다 살아 있고 스크립트 원문(특수문자 포함)이 그대로여야 한다. + got = [c["Generic"]["equation"] + for sec in reread(out)["sections"] for q in sec["paragraphs"] + for c in q.get("controls", []) + if isinstance(c, dict) and "Generic" in c and c["Generic"].get("equation")] + if len(got) != len(scripts): + raise RuntimeError(f"수식 {len(got)}개 왕복(3 기대) — writer arm 또는 read 유실") + for want, have in zip(scripts, got): + if have["script"] != want: + raise RuntimeError(f"스크립트 변형: {have['script']!r} != {want!r}") + ok, msg = validate_ok(out) + if not ok: + raise RuntimeError(f"validate 실패: {msg}") + return out, "수식 3종(분수·합·특수문자) hwpx 방출 → 스크립트 원문 왕복, validate ✅" + + CASES = [ ("C1_그림자.hwpx", c1_shadow), ("C2_외곽선.hwpx", c2_outline), @@ -1245,6 +1302,7 @@ def k3_cols_hwpx(dest): ("K1_셀병합.hwpx", k1_merge_hwpx), ("K2_셀병합.hwp", k2_merge_hwp), ("K3_열조작.hwpx", k3_cols_hwpx), + ("L1_수식.hwpx", l1_equation_hwpx), ]