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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

### 고침

- 줄 캐시(`write_line_caches`)가 머리에 정의되지 않은 글머리표·번호를 가리키는 문단 모양(예: 글머리표 idRef 0)의
문단에는 캐시를 쓰지 않는다. 한/글은 정의가 없어도 글머리 자리를 잡아 첫 줄에 글자를 덜 넣는데, 전에는 글머리 없이
나눈 줄을 써서 한/글이 그 줄을 눌러 담을 수 있었다.
- 줄 캐시가 왼쪽이나 오른쪽 여백이 음수인 문단(줄이 단·칸 가장자리 밖으로 나가는 문단)에도 캐시를 쓰지 않는다. 한/글이
그런 줄을 어떤 폭으로 나누는지 확정하지 못했고, 한 글자 이상 다르게 나눈 문단이 있었다.
- 쪽 수 추정이 표 칸 안에서 양쪽에 글 자리가 있는 어울림 개체 곁에 빈 줄만 오는 칸도 추정한다. 한/글처럼 빈 줄은 개체 곁에
한 높이 두 조각으로 둔다(곁에 글이 오면 지금처럼 지원 밖). 한/글이 저장한 줄 캐시가 있는 칸에서 그런 두 조각을 줄 둘로
세어 칸을 조각 수만큼 높게 잡던 것도 바로잡았다.
Expand Down
30 changes: 28 additions & 2 deletions src/hwpx/layout/line_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
for, a word too long for a line, a line break that moves when every line is laid out :data:`_MARGIN` narrower or
wider (a line ending that close to its room may end elsewhere in Hancom), or a tab whose text before it is closer
to a tab stop than a layout unit for each character since the line's start or the last tab stop (at most
:data:`_MARGIN`). A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is
:data:`_MARGIN`), a paragraph shape naming a bullet or a number the header does not define (Hancom draws a label
there all the same), or one with a negative left or right margin. A paragraph that has a cache keeps it. A document mixing paragraphs with and without a cache is
laid out as one. Nothing is written when the estimate does not follow the document.
"""

Expand Down Expand Up @@ -260,7 +261,8 @@ def _picture_lines(measure: Any, paragraph: Any, width: int) -> tuple[list[dict[
return None
vertical = _vertical_align(measure, paragraph.get("paraPrIDRef"))
shape = measure.shape(paragraph.get("paraPrIDRef"))
if vertical not in _BASELINES or shape.font_line:
if vertical not in _BASELINES or shape.font_line or _unknown_label(measure, paragraph.get("paraPrIDRef")) \
or shape.left < 0 or shape.right < 0:
return None
room = width - shape.left - shape.right
headed = measure.headed(paragraph)
Expand Down Expand Up @@ -463,6 +465,11 @@ def _lines(measure: Any, paragraph: Any, width: int, *, first: bool,
vertical = _vertical_align(measure, paragraph.get("paraPrIDRef"))
if shape.font_line or vertical not in _BASELINES or not _shapes_sure(measure, paragraph, runs):
return None
if _unknown_label(measure, paragraph.get("paraPrIDRef")):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] 표 문단의 fallback에도 동일한 차단 조건을 적용해 주세요. write_line_caches는 _lines(...) or _table_line(...) or _picture_lines(...)로 작성 경로를 고르므로, 여기서 None을 반환해도 _table_line은 _unknown_label/음수 여백 검사를 하지 않아 캐시를 만듭니다. 공개 API 재현: 새 문서에 1×1 표(칸 텍스트 '가나다')를 만들고, 표의 holder p에만 기존 hh:paraPr을 복사한 별도 id=999를 참조시킵니다(칸 문단의 모양은 그대로). 그 모양을 hh:heading type=BULLET/NUMBER,idRef=999(정의 없음) 또는 왼쪽/오른쪽 margin=-500으로 바꾸고 write_line_caches를 호출하면 네 경우 모두 written=2, skipped=0이고 holder에도 linesegarray가 생깁니다. 왼쪽 음수 사례에는 horzpos=-500,horzsize=43020이 기록됩니다. 같은 모양의 텍스트 문단은 올바르게 건너뜁니다. PR이 한/글과의 폭을 확정하지 못해 차단하는 조건이므로 표 holder도 캐시 작성 대상에서 제외하거나 이 경로를 별도로 검증해 주세요. _table_line 공통 사전검사와 holder별 공개 API 회귀를 추가하면 이 우회를 막을 수 있습니다.

return None # a bullet or number the header does not define: Hancom draws a label all the same
if shape.left < 0 or shape.right < 0:
return None # a margin reaching past the column's or the cell's edge: how wide Hancom lays the lines out
# then was not pinned down
if measure.pictured(paragraph.get("paraPrIDRef")):
return None # a picture bullet sized by its picture: its room was not measured
size, refs, sizes = _pages._text_size(measure, runs)
Expand Down Expand Up @@ -604,6 +611,25 @@ def _shapes_sure(measure: Any, paragraph: Any, runs: list[Any]) -> bool:
return True


def _unknown_label(measure: Any, para_pr_id: str | None) -> bool:
"""Whether a paragraph shape *para_pr_id* heads its paragraphs with a bullet or a number the header does not
define (``hh:heading`` naming no ``hh:bullet`` or ``hh:numbering``): Hancom still draws a label before their
text, of a room not known here."""

unknown = getattr(measure, "_line_cache_unknown_labels", None)
if unknown is None:
defined = {"BULLET": {item.get("id") for item in measure._header.iter(f"{HH}bullet")},
"NUMBER": {item.get("id") for item in measure._header.iter(f"{HH}numbering")}}
unknown = set()
for shape in measure._header.iter(f"{HH}paraPr"):
heading = shape.find(f"{HH}heading")
if heading is not None and heading.get("type") in defined \
and heading.get("idRef") not in defined[heading.get("type")]:
unknown.add(shape.get("id"))
measure._line_cache_unknown_labels = unknown
return para_pr_id in unknown


def _vertical_align(measure: Any, para_pr_id: str | None) -> str | None:
"""Where a paragraph of the shape *para_pr_id* sets its characters in their lines (``hh:align``'s
``vertical``): BASELINE (the default), TOP, CENTER or BOTTOM; None for a shape not in the header."""
Expand Down
31 changes: 31 additions & 0 deletions tests/test_layout_line_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,37 @@ def test_a_page_control_among_the_text_is_left_for_hancom() -> None:
assert report.written == 0 and report.skipped == 1


@pytest.mark.parametrize("kind", ["BULLET", "NUMBER"])
def test_a_paragraph_headed_by_a_label_the_header_does_not_define_is_left_for_hancom(kind: str) -> None:
# A paragraph shape naming a bullet or a number the header lacks: Hancom still draws a label before the text
# (seen with a bullet: its first line holds fewer characters), of a room not known.
document = HwpxDocument.new()
document.add_paragraph("가나다 " * 40)
ref = _body(document)[-1].get("paraPrIDRef")
shape = next(s for s in document.oxml.headers[0].element.iter(f"{HH}paraPr") if s.get("id") == ref)
shape.find(f"{HH}heading").attrib.update({"type": kind, "idRef": "99"})

report = write_line_caches(document)

assert report.written == 0


@pytest.mark.parametrize("side", ["left", "right"])
def test_a_paragraph_with_a_negative_margin_is_left_for_hancom(side: str) -> None:
# A margin reaching past the column's edge: Hancom lays such lines out at a width not pinned down (one 500
# out on the right, or 339 out on the left in a cell, broke a character or more off FormFit's).
document = HwpxDocument.new()
document.add_paragraph("가나다 " * 40)
ref = _body(document)[-1].get("paraPrIDRef")
shape = next(s for s in document.oxml.headers[0].element.iter(f"{HH}paraPr") if s.get("id") == ref)
for margin in shape.iter("{http://www.hancom.co.kr/hwpml/2011/core}" + side):
margin.set("value", "-500")

report = write_line_caches(document)

assert report.written == 0


@pytest.mark.parametrize(("condense", "spacing", "written"), [("0", 0, 1), ("10", 0, 0), ("0", -10, 0)])
def test_a_no_break_space_among_spaces_that_shrink_is_left_for_hancom(condense: str, spacing: int, written: int) -> None:
# A no-break space in a paragraph with 최소 공백 was seen to break a line elsewhere than FormFit, and one with
Expand Down
Loading