From ed534ef0bb46ad7d2e12c9b3964819585a539def Mon Sep 17 00:00:00 2001 From: Kyuhyun Ko <38392618+airmang@users.noreply.github.com> Date: Tue, 13 Oct 2026 12:56:05 +0900 Subject: [PATCH] fix(hwp5): read and write a picture's alpha and reverse where Hancom keeps them Hancom reads a picture's transparency from the last byte of the picture record, after the image's own size, and does not read the byte after the binary item id (it writes that byte as 0). The reader took that byte as the alpha, so a picture with 255 there opened invisible. Bit 24 of a picture's component flags is hp:pic reverse, which was always written as 0. The HWP 5.0 writer now puts both values in the same places. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/hwpx/hwp5/section_writer.py | 6 +++-- src/hwpx/hwp5/shape_xml.py | 10 +++++---- src/hwpx/hwp5/shapes.py | 8 +++++++ tests/test_hwp5_open.py | 40 ++++++++++++++++++++++++++++----- tests/test_hwp5_save.py | 23 +++++++++++++++++++ 6 files changed, 79 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4717cd3f..568f296c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,10 @@ ### 고침 +- `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기 + 뒤 마지막 바이트에서 읽고, 바이너리 항목 번호 뒤 바이트는 읽지 않는다(저장할 때 0으로 쓴다). 전에는 그 바이트를 + 투명도로 옮겨, 거기에 255가 든 문서의 그림이 HWPX에서 보이지 않았다. 반전(`hp:pic@reverse`)은 그림 개체 속성의 + 24번 비트에서 읽는다(전에는 늘 0). HWP 5.0으로 저장할 때도 두 값을 같은 자리에 쓴다. - 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에 놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다. 한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을 diff --git a/src/hwpx/hwp5/section_writer.py b/src/hwpx/hwp5/section_writer.py index c20182a1..221fe6ed 100644 --- a/src/hwpx/hwp5/section_writer.py +++ b/src/hwpx/hwp5/section_writer.py @@ -100,6 +100,7 @@ LINE_STYLE, OLE_TYPE, OUTLINE_STYLE, + PICTURE_REVERSE, ROTATE_IMAGE, SHADOW, SHADOW_STYLE, @@ -1145,6 +1146,7 @@ def shape_records(self, element: etree._Element, level: int, *, top: bool) -> li flags |= ROTATE_IMAGE if _flag(rotation, "rotateimage") else 0 flags |= _FLAG_TEXT_BOX if draw_text is not None else 0 flags |= _FLAG_PICTURE if kind == "$pic" else 0 + flags |= PICTURE_REVERSE if kind == "$pic" and _flag(element, "reverse") else 0 flags |= _FLAG_OLE if kind == "$ole" else 0 flags |= _FLAG_GROUP if kind == "$con" or not top else 0 flags |= _FLAG_GROUP_MEMBER if not top and kind != "$con" else 0 @@ -1375,11 +1377,11 @@ def picture(self, element: etree._Element) -> bytes: max(-128, min(127, _int(image, "contrast"))), index_of(IMAGE_EFFECT_CODES, image.get("effect") if image is not None else None, 0), self.bin_ids.get(ref, 0), - _int(image, "alpha") & 0xFF, + 0, _int(element, "instid") & 0xFFFFFFFF, effects.flags if effects is not None else 0, (_int(dim, "dimwidth"), _int(dim, "dimheight")), - bytes(1), + bytes([_int(image, "alpha") & 0xFF]), effects, ).encode() diff --git a/src/hwpx/hwp5/shape_xml.py b/src/hwpx/hwp5/shape_xml.py index f60c243e..e1562b25 100644 --- a/src/hwpx/hwp5/shape_xml.py +++ b/src/hwpx/hwp5/shape_xml.py @@ -181,6 +181,8 @@ ELLIPSE_POINTS = ("center", "ax1", "ax2", "start1", "end1", "start2", "end2") #: Flags bit of a shape component that becomes ``rotateimage``. ROTATE_IMAGE = 1 << 19 +#: Flags bit of a picture's component that becomes ``reverse``. +PICTURE_REVERSE = 1 << 24 #: Picture effects: shadow styles, align styles, colour types (Hancom keeps #: no CMYK) and colour effects by code (Hancom keeps no others). SHADOW_STYLE = ("OUTSIDE", "INSIDE") @@ -445,7 +447,7 @@ def shape(self, parent: etree._Element, record: rec.Record, common: ct.ObjectCom raise damaged("text art without its record") art = sh.TextArt.decode(geometry.payload) attrs += [("href", href), ("groupLevel", sc.group_level), ("instid", instance_id)] - attrs += self.shape_attrs(sc.kind, geometry) if ole is None else self.ole_attrs(ole) + attrs += self.shape_attrs(sc.kind, geometry, sc.flags) if ole is None else self.ole_attrs(ole) if art is not None: attrs.append(("text", "".join(TEXTART_BREAKS.get(char, char) for char in art.text))) element = sub(parent, f"hp:{name}", attrs) @@ -484,7 +486,7 @@ def shape(self, parent: etree._Element, record: rec.Record, common: ct.ObjectCom return element @staticmethod - def shape_attrs(kind: str, geometry: rec.Record | None) -> list[tuple[str, object]]: + def shape_attrs(kind: str, geometry: rec.Record | None, flags: int = 0) -> list[tuple[str, object]]: if geometry is None: return [] if kind == "$rec": @@ -503,7 +505,7 @@ def shape_attrs(kind: str, geometry: rec.Record | None) -> list[tuple[str, objec if kind == "$col": return [("type", token(CONNECT_TYPE, sh.ConnectLine.decode(geometry.payload).kind))] if kind == "$pic": - return [("reverse", 0)] + return [("reverse", flag(flags & PICTURE_REVERSE))] if kind == "$vid": video = sh.Video.decode(geometry.payload) return [ @@ -590,7 +592,7 @@ def picture(self, element: etree._Element, pic: sh.Picture) -> None: ("bright", pic.bright), ("contrast", pic.contrast), *image_effect(pic.effect), - ("alpha", pic.alpha or 0), + ("alpha", pic.transparency), ), ) if _bits(pic.line_props, 0, 6): diff --git a/src/hwpx/hwp5/shapes.py b/src/hwpx/hwp5/shapes.py index 3e82911f..8bbc65fd 100644 --- a/src/hwpx/hwp5/shapes.py +++ b/src/hwpx/hwp5/shapes.py @@ -582,6 +582,14 @@ def decode(cls, payload: bytes) -> "Picture": value.extra = c.rest() return value + @property + def transparency(self) -> int: + """The alpha Hancom reads: the byte after the image's own size, 0 when + the record has none. Hancom does not read ``alpha`` (the byte after the + binary item id) and writes it as 0.""" + + return self.extra[0] if self.dim is not None and self.extra else 0 + def encode(self) -> bytes: b = Builder().u32(self.line_color).i32(self.line_width).u32(self.line_props) _points(b, self.corners) diff --git a/tests/test_hwp5_open.py b/tests/test_hwp5_open.py index e70bf316..8d9810c3 100644 --- a/tests/test_hwp5_open.py +++ b/tests/test_hwp5_open.py @@ -420,16 +420,20 @@ def _memo() -> list[rec.Record]: return records + _paragraph(1, "메모 내용".encode("utf-16-le") + _u16(13), [(0, 0)], []) -def _picture(effect: int = 0) -> list[rec.Record]: - """A captioned picture with a first-letter decoration parameter set, and - the picture effect code *effect*.""" +def _picture(effect: int = 0, flags: int = 0x24080000, legacy_alpha: int = 0, alpha: int = 0) -> list[rec.Record]: + """A captioned picture with a first-letter decoration parameter set, the + picture effect code *effect*, the component *flags*, the byte after the + binary item id (*legacy_alpha*) and the last byte, after the image size + (*alpha*).""" common = ct.ObjectCommon("gso ", 0x240A2211, 0, 0, 10000, 8000, 0, (0, 0, 0, 0), 297, 0, "그림입니다.", bytes(2)) component = sh.ShapeComponent( - "$pic", True, 0, 0, 0, 1, 10000, 8000, 10000, 8000, 0x24080000, 0, 5000, 4000, [_IDENTITY, _IDENTITY, _IDENTITY] + "$pic", True, 0, 0, 0, 1, 10000, 8000, 10000, 8000, flags, 0, 5000, 4000, [_IDENTITY, _IDENTITY, _IDENTITY] ) corners = [(0, 0), (10000, 0), (10000, 8000), (0, 8000)] - picture = sh.Picture(0, 0, 0, corners, (0, 0, 10000, 8000), (0, 0, 0, 0), 0, 0, effect, 0, 0, 297, 0, (10000, 8000), bytes(1)) + picture = sh.Picture( + 0, 0, 0, corners, (0, 0, 10000, 8000), (0, 0, 0, 0), 0, 0, effect, 0, legacy_alpha, 297, 0, (10000, 8000), bytes([alpha]) + ) caption = ct.CaptionHeader(1, 0, 0, 1, 8504, 850, 8504).encode() dropcap = ct.ParameterSet(0x021B, [ct.ParameterItem(0x3003, ct.PIT_SET, ct.ParameterSet(0x3003, [ct.ParameterItem(0x7001, 9, 2)]))]) return _paragraph( @@ -500,6 +504,8 @@ def make_hwp( hidden_comment: bool = False, picture_effects: bool = False, picture_effect: int = 0, + picture_flags: int = 0x24080000, + picture_alphas: tuple[int, int] = (0, 0), dutmal_position: int = 1, ) -> bytes: section = _section() @@ -520,7 +526,7 @@ def make_hwp( if text_box: section += _text_box() if picture: - section += _picture(picture_effect) + section += _picture(picture_effect, picture_flags, *picture_alphas) if markers: section += _markers(dutmal_position) if label: @@ -1111,6 +1117,28 @@ def test_a_picture_opens_with_its_caption_comment_and_parameter_set() -> None: assert value is not None and (value.get("name"), value.text) == ("28673", "2") +@pytest.mark.parametrize(("alphas", "expected"), [((255, 0), "0"), ((0, 128), "128"), ((7, 255), "255"), ((0, 0), "0")]) +def test_a_picture_opens_with_the_alpha_from_its_last_byte(alphas: tuple[int, int], expected: str) -> None: + """Hancom keeps a picture's transparency in the last byte of the picture + record, after the image size, and does not read the byte after the + binary item id.""" + + with warnings.catch_warnings(): + warnings.simplefilter("error") + document = HwpxDocument.open(make_hwp(picture=True, picture_alphas=alphas)) + [pic] = list(document.sections[0].element.iter(f"{HP}pic")) + assert pic.find(f"{HC}img").get("alpha") == expected + + +@pytest.mark.parametrize(("flags", "reverse"), [(0x25000000, "1"), (0x25080000, "1"), (0x24080000, "0")]) +def test_a_picture_opens_reversed_when_its_component_says_so(flags: int, reverse: str) -> None: + with warnings.catch_warnings(): + warnings.simplefilter("error") + document = HwpxDocument.open(make_hwp(picture=True, picture_flags=flags)) + [pic] = list(document.sections[0].element.iter(f"{HP}pic")) + assert pic.get("reverse") == reverse + + @pytest.mark.parametrize(("code", "effect"), [(1, "GRAY_SCALE"), (2, "BLACK_WHITE"), (3, None)]) def test_an_image_effect_opens_with_its_owpml_name_or_without_one(code: int, effect: str | None) -> None: """Hancom leaves the effect out of OWPML for the HWP code 3.""" diff --git a/tests/test_hwp5_save.py b/tests/test_hwp5_save.py index 3320dda5..1d83623e 100644 --- a/tests/test_hwp5_save.py +++ b/tests/test_hwp5_save.py @@ -1090,6 +1090,29 @@ def test_image_effects_are_written_with_the_codes_hancom_writes(effect: str, cod assert [sh.Picture.decode(r.payload).effect for r in pictures] == [code] +def test_a_picture_alpha_and_reverse_are_written_where_hancom_reads_them(tmp_path: Path) -> None: + """The alpha goes in the last byte of the picture record, after the image + size (the byte after the binary item id stays 0), and reverse in bit 24 of + the component flags.""" + + document = HwpxDocument.new() + document.add_picture(_PNG, "png") + [pic] = [p for s in document.sections for p in s.element.iter(f"{HP}pic")] + pic.set("reverse", "1") + pic.find("{http://www.hancom.co.kr/hwpml/2011/core}img").set("alpha", "128") + target = tmp_path / "alpha.hwp" + document.save_to_path(target) + doc = read_hwp5(target.read_bytes()) + records = [r for s in doc.sections for r in s.records] + [picture] = [sh.Picture.decode(r.payload) for r in records if r.tag == rec.SHAPE_COMPONENT_PICTURE] + assert (picture.alpha, picture.extra) == (0, bytes([128])) + [component] = [sh.ShapeComponent.decode(r.payload, top=True) for r in records if r.tag == rec.SHAPE_COMPONENT] + assert component.flags & 0x01000000 + [reopened] = [p for s in HwpxDocument.open(target).sections for p in s.element.iter(f"{HP}pic")] + assert reopened.get("reverse") == "1" + assert reopened.find("{http://www.hancom.co.kr/hwpml/2011/core}img").get("alpha") == "128" + + def test_hwpx_targets_are_unchanged(tmp_path: Path) -> None: document = HwpxDocument.new() document.add_paragraph("HWPX로 저장")