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로 저장")