Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@

### 고침

- `.hwp`를 열 때 그림의 투명도와 반전을 한/글이 읽는 자리에서 읽는다. 한/글은 그림 레코드의 투명도를 그림 원래 크기
뒤 마지막 바이트에서 읽고, 바이너리 항목 번호 뒤 바이트는 읽지 않는다(저장할 때 0으로 쓴다). 전에는 그 바이트를
투명도로 옮겨, 거기에 255가 든 문서의 그림이 HWPX에서 보이지 않았다. 반전(`hp:pic@reverse`)은 그림 개체 속성의
24번 비트에서 읽는다(전에는 늘 0). HWP 5.0으로 저장할 때도 두 값을 같은 자리에 쓴다.
- 쪽 수 추정이 문단 위로 올려 놓은 도형(문단 기준 음수 `vertOffset`, 파일에는 부호 없는 32비트로 남는다)을 그만큼 위에
놓인 것으로 센다. 전에는 그 값을 큰 양수로 읽어 문단 줄이 40억 넘게 높아지고, 한 쪽짜리 문서가 세 쪽으로 나왔다.
한/글은 그 도형을 문단 위가 있었을 자리에서 그만큼 위에 놓고, 그 띠에 걸린 앞 줄(아래끝이 띠 위끝보다 아래인 줄)을
Expand Down
6 changes: 4 additions & 2 deletions src/hwpx/hwp5/section_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
LINE_STYLE,
OLE_TYPE,
OUTLINE_STYLE,
PICTURE_REVERSE,
ROTATE_IMAGE,
SHADOW,
SHADOW_STYLE,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
10 changes: 6 additions & 4 deletions src/hwpx/hwp5/shape_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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 [
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions src/hwpx/hwp5/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
40 changes: 34 additions & 6 deletions tests/test_hwp5_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
23 changes: 23 additions & 0 deletions tests/test_hwp5_save.py
Original file line number Diff line number Diff line change
Expand Up @@ -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로 저장")
Expand Down
Loading