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
2 changes: 2 additions & 0 deletions crates/hwp-convert/src/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,7 @@ mod tests {
inline,
x: 0,
y: 0,
..Equation::default()
}),
column_def: None,
})
Expand Down Expand Up @@ -2486,6 +2487,7 @@ mod tests {
inline,
x: 0,
y: 0,
..Equation::default()
}),
column_def: None,
})
Expand Down
13 changes: 12 additions & 1 deletion crates/hwp-model/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -278,6 +278,17 @@ pub struct Equation {
/// 떠 있는 경우 페이지 절대 오프셋(HWPUNIT).
pub x: i32,
pub y: i32,
/// hwpx `<hp:equation>`의 시작 태그 속성 원문(`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<String>,
/// 개체 공통 자식(`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<String>,
}

/// 도형 종류 (hwpx 그리기 개체).
Expand Down
1 change: 1 addition & 0 deletions crates/hwp-render/tests/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ fn 수식_조판_렌더() {
inline: false,
x: 8000,
y: 6000 + i as i32 * 5000,
..Equation::default()
}),
column_def: None,
}));
Expand Down
3 changes: 3 additions & 0 deletions crates/hwp5/src/body_text.rs
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,9 @@ fn parse_eqed(data: &[u8], children: &[RecordNode]) -> Option<Equation> {
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()
})
}

Expand Down
77 changes: 63 additions & 14 deletions crates/hwpx/src/read/section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,19 +286,7 @@ fn parse_text(
}
// 엔티티 참조(&amp; &#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);
}
}
Expand Down Expand Up @@ -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,
Expand All @@ -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<String> {
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)
}

/// 엔티티 참조(`&amp;` `&#x2264;`)를 문자로 되돌린다. quick-xml은 참조를 텍스트에
/// 합치지 않고 `Event::GeneralRef`로 따로 주므로, 텍스트를 모으는 쪽마다 이 해석이
/// 필요하다. DTD 없이는 외부 엔티티를 풀 수 없어 미리 정의된 5종 + 숫자 참조만 푼다.
fn resolve_entity(r: &quick_xml::events::BytesRef<'_>) -> Option<char> {
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<String> {
let mut out = String::new();
Expand All @@ -1290,6 +1323,22 @@ fn read_element_text(reader: &mut XmlReader<'_>, end: &[u8]) -> Result<String> {
})?;
out.push_str(&s);
}
// 수식 스크립트는 `x &lt; y`처럼 XML 특수문자를 담는다 — 참조를 풀지 않으면
// 그 글자가 통째로 사라진다(writer의 esc()와 짝을 이루는 역변환).
Event::GeneralRef(r) => {
if let Some(c) = resolve_entity(&r) {
out.push(c);
}
}
// CDATA 구획(`<![CDATA[a<b]]>`)은 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,
_ => {}
Expand Down
24 changes: 23 additions & 1 deletion crates/hwpx/src/read/xml.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,20 @@
use quick_xml::events::BytesStart;

/// 로컬 이름(네임스페이스 접두사 제거) 기준 속성 조회.
/// 속성값을 **엔티티 해석까지 마친** 문자열로 돌려준다. quick-xml은 속성값을 파일에 있던
/// 이스케이프 형태 그대로 주므로, 풀지 않으면 `name="A&amp;B"`가 IR에 `A&amp;B`로 올라가고
/// writer의 `esc()`가 다시 감싸 `A&amp;amp;B`로 이중 이스케이프된다(책갈피·필드 이름,
/// 수식 script 속성에서 실제로 발생). 해석 실패(잘못된 참조)면 원문을 그대로 둔다.
pub fn attr(e: &BytesStart<'_>, name: &str) -> Option<String> {
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
}
Expand Down Expand Up @@ -56,4 +65,17 @@ mod tests {
assert_eq!(parse_color("#000000"), 0);
assert_eq!(parse_color("none"), 0xFFFF_FFFF);
}

/// 속성값 엔티티는 읽는 즉시 풀린다 — 안 풀면 writer의 esc()가 다시 감싸
/// `A&amp;B` → `A&amp;amp;B`로 왕복마다 이중 이스케이프가 쌓인다.
#[test]
fn 속성값_엔티티_해석() {
let el = quick_xml::events::BytesStart::from_content(
r#"hp:bookmark name="A&amp;B &lt;중&gt; &#48124;" bad="A&nosuch;B""#,
12,
);
assert_eq!(attr(&el, "name").as_deref(), Some("A&B <중> 민")); // &#48124; = U+BBFC
// 해석 불가한 참조는 원문 유지(정보 손실 없이 그대로 되쓴다).
assert_eq!(attr(&el, "bad").as_deref(), Some("A&nosuch;B"));
}
}
87 changes: 84 additions & 3 deletions crates/hwpx/src/write/section.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -102,8 +102,10 @@ const SHAPE_RUN_LIMIT: usize = 12;

/// 방출된 XML 조각에서 최상위 그리기 도형 요소 수를 센다. `<hp:line `은 뒤에 공백을 둬
/// `<hp:lineShape`·`<hp:lineseg`·`<hp:lineBreak`와 구분한다(도형 요소는 항상 속성이 따름).
/// 수식도 개체라 같이 센다 — 한글의 run당 한도가 수식까지 포함하는지는 미확정이나,
/// 과다 계상은 run이 더 일찍 갈라질 뿐이고 과소 계상은 개체 유실이라 안전한 쪽을 택한다.
fn count_shape_tags(s: &str) -> usize {
const OPENS: [&str; 8] = [
const OPENS: [&str; 9] = [
"<hp:rect ",
"<hp:ellipse ",
"<hp:line ",
Expand All @@ -112,6 +114,7 @@ fn count_shape_tags(s: &str) -> usize {
"<hp:curve ",
"<hp:pic ",
"<hp:connectLine ",
"<hp:equation ",
];
OPENS.iter().map(|t| s.matches(t).count()).sum()
}
Expand Down Expand Up @@ -383,6 +386,18 @@ fn write_paragraph(
flush_text(out, &mut text_buf, &mut pending_tabs);
write_foot_end_note(out, doc, g, ids, bins, preserve_linesegs, warnings);
}
Control::Generic(g) if g.equation.is_some() => {
// 수식 — 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 쓰기 미지원 컨트롤 드롭: {:?}",
Expand Down Expand Up @@ -979,6 +994,72 @@ fn write_foot_end_note(
let _ = write!(out, "</hp:{el}></hp:ctrl>");
}

/// 수식 → `<hp:equation>`(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##"<hp:equation id="{id}" {raw}>"##);
}
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##"<hp:equation id="{id}" zOrder="{z}" numberingType="EQUATION" textWrap="{wrap}" textFlow="BOTH_SIDES" lock="0" dropcapstyle="None" version="Equation Version 60" baseLine="85" textColor="#000000" baseUnit="1000" lineMode="CHAR" font="HYhwpEQ">"##
);
}
}
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##"<hp:pos treatAsChar="{treat}" affectLSpacing="0" flowWithText="{flow}" allowOverlap="{overlap}" holdAnchorAndSO="0" vertRelTo="PARA" horzRelTo="PARA" vertAlign="TOP" horzAlign="LEFT" vertOffset="{}" horzOffset="{}"/>"##,
eq.y, eq.x,
)
}
};
let _ = write!(
out,
r##"<hp:sz width="{}" widthRelTo="ABSOLUTE" height="{}" heightRelTo="ABSOLUTE" protect="0"/>{pos_xml}<hp:outMargin left="0" right="0" top="0" bottom="0"/>"##,
eq.width.max(0),
eq.height.max(0),
);
} else {
for p in &eq.raw_props {
out.push_str(p);
}
}
let _ = write!(
out,
"<hp:script>{}</hp:script></hp:equation>",
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"`로 뭉개면 한글이 다중 도형을
Expand Down
13 changes: 13 additions & 0 deletions crates/hwpx/src/write/templates.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ pub fn esc(s: &str) -> String {
// 해도 무효이고, raw로 방출하면 한글이 파일을 거부한다. 탭/개행은 상위
// (flush_text)에서 이미 <hp:tab …/>·<hp:lineBreak/> 요소로 변환돼 여기 오지 않는다.
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),
}
}
Expand Down Expand Up @@ -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<b&c\"d"), "a&lt;b&amp;c&quot;d");
assert_eq!(esc("a\u{1}b\u{1F}c"), "abc"); // C0 제어문자
assert_eq!(esc("a\u{FFFE}b\u{FFFF}c"), "abc"); // 비문자
assert_eq!(esc("탭\t줄\n복귀\r"), "탭\t줄\n복귀\r"); // 허용 3종은 유지
}

/// 구형 형식(dc:subject, name="keywords" 복수형+content 속성)도 하위호환 파싱.
#[test]
fn parse_구형_형식_하위호환() {
Expand Down
Loading
Loading