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: 4 additions & 1 deletion src/hwpx/oxml/body.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@
"ole",
"chart",
"video",
"audio",
# "audio"는 제외 — 실한컴(Windows 한글 2024 실측)은 소리 삽입을
# hp:ole(EMBEDDED/ICON)로 방출하고 hp:audio를 생성하지 않으며, 보유
# 실코퍼스 표본도 0건이다. 실물 미검증 요소를 인라인 읽기 지원으로
# 분류하지 않는다(빈도 0 = 읽기측 불투명 보존만). python-hwpx#89.
"textart",
}

Expand Down
86 changes: 78 additions & 8 deletions src/hwpx/tools/package_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,17 @@ def _first_child_by_local(element: ET.Element, name: str) -> ET.Element | None:
return None


def _simple_paragraph_text_length(paragraph: ET.Element) -> int | None:
"""Return visible text length for plain text-only paragraphs.
def _simple_paragraph_text(paragraph: ET.Element) -> str | None:
"""Return visible text for plain text-only paragraphs (``None`` = 판정 불가).

Paragraphs containing fields, shapes, tables, or other embedded controls are
skipped to avoid guessing how a specific editor counts their layout units.
Single-position control characters (tab/linebreak/hyphen/nbspace) are
represented as one space — a deliberate width underestimate so downstream
width judgments stay conservative.
"""

total = 0
pieces: list[str] = []
for child in paragraph:
child_name = _local_name(child).lower()
if child_name == "linesegarray":
Expand All @@ -348,12 +351,73 @@ def _simple_paragraph_text_length(paragraph: ET.Element) -> int | None:
for run_child in child:
run_child_name = _local_name(run_child).lower()
if run_child_name == "t":
total += len("".join(run_child.itertext()))
pieces.append("".join(run_child.itertext()))
elif run_child_name in {"tab", "linebreak", "hyphen", "nbspace"}:
total += 1
pieces.append(" ")
else:
return None
return total
return "".join(pieces)


def _simple_paragraph_text_length(paragraph: ET.Element) -> int | None:
text = _simple_paragraph_text(paragraph)
return None if text is None else len(text)


#: 꼬리 폭 판정 마진 — 추정 폭이 줄 폭의 이 배수를 넘을 때만 증명으로 취급.
_STALE_TAIL_WIDTH_FACTOR = 2.0
_STALE_TAIL_MIN_CHARS = 8


def _check_line_seg_tail_coverage(
issues: list[PackageValidationIssue],
part_name: str,
paragraph_index: int,
text: str,
line_segs: list[ET.Element],
) -> None:
"""Catch caches that lack lines for text grown past the last cached line.

The stale-cache direction the textpos range check cannot see: an edit made
the paragraph *longer* but an old ``<hp:linesegarray>`` survived, so every
cached ``textpos`` is still in range — yet the cache has too few lines and
Hancom renders the tail overlapped (the tracked-change overlap defect).
Provable-with-margin: the tail after the last cached line start must not
measure wider than that line's extent × ``_STALE_TAIL_WIDTH_FACTOR``,
with width estimated by the same conservative metrics the overflow lint
uses.
"""

last: tuple[int, int, int] | None = None
for seg in line_segs:
try:
textpos = int(seg.get("textpos") or "")
horzsize = int(seg.get("horzsize") or "")
textheight = int(seg.get("textheight") or "")
except ValueError:
return
if last is None or textpos > last[0]:
last = (textpos, horzsize, textheight)
if last is None:
return
last_pos, horzsize, textheight = last
if horzsize <= 0 or textheight <= 0 or not 0 <= last_pos <= len(text):
return
tail = text[last_pos:]
if len(tail) < _STALE_TAIL_MIN_CHARS:
return
from hwpx.form_fit.measure import estimate_text_width

tail_width = estimate_text_width(tail, textheight / 100.0)
if tail_width > horzsize * _STALE_TAIL_WIDTH_FACTOR:
_error(
issues,
part_name,
f"paragraph {paragraph_index} lineseg cache lacks lines for its tail: "
f"{len(tail)} chars after the last cached line start (textpos={last_pos}) "
f"measure ~{tail_width / horzsize:.1f}x the cached line extent {horzsize} "
"— stale layout cache; Hancom reuses it and renders the tail overlapped",
)


def _check_line_seg_text_positions(
Expand All @@ -367,15 +431,18 @@ def _check_line_seg_text_positions(
for paragraph_index, paragraph in enumerate(
element for element in root.iter() if _local_name(element) == "p"
):
text_length = _simple_paragraph_text_length(paragraph)
if text_length is None:
text = _simple_paragraph_text(paragraph)
if text is None:
continue
text_length = len(text)
for child in paragraph:
if _local_name(child).lower() != "linesegarray":
continue
line_segs: list[ET.Element] = []
for line_seg in child:
if _local_name(line_seg).lower() != "lineseg":
continue
line_segs.append(line_seg)
textpos_raw = line_seg.get("textpos")
if textpos_raw is None:
continue
Expand All @@ -396,6 +463,9 @@ def _check_line_seg_text_positions(
f"{paragraph_index} has stale lineseg textpos={textpos} "
f"beyond text length {text_length}",
)
_check_line_seg_tail_coverage(
issues, part_name, paragraph_index, text, line_segs
)


def _check_table_editor_acceptance(
Expand Down
82 changes: 82 additions & 0 deletions tests/test_layout_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,88 @@ def test_seeded_stale_lineseg_is_caught():
assert any(f.code == STALE_LINESEG_DETECTED for f in report.errors)


def _add_measured_lineseg(root, text: str, seg_attrs: list[dict[str, str]]) -> None:
"""Attach a full-attribute cache (textpos/horzsize/textheight) to *text*'s paragraph."""

para = _text_paragraph(root, text)
ns = para.tag.rsplit("}", 1)[0].lstrip("{")
lsa = etree.SubElement(para, f"{{{ns}}}lineSegArray")
for attrs in seg_attrs:
etree.SubElement(lsa, f"{{{ns}}}lineSeg", attrs)


_A4_LINE = {"horzsize": "42520", "textheight": "1000"} # 10pt · 표준 본문 줄 폭


def test_grown_text_with_stale_single_line_cache_is_caught():
"""#85 방향: 텍스트가 캐시 줄 수 너머로 자랐는데 textpos는 전부 유효한 경우.

범위 검사(textpos > len)는 이 방향을 못 본다 — 꼬리 폭 판정이 잡아야 한다.
120 한글자 문단에 1줄짜리 캐시: 꼬리 폭 ~120em ≫ 줄 폭 42520×2.
"""

text = "가" * 120
doc = HwpxDocument.new()
doc.add_paragraph(text)
data = _inject_section0(
_bytes(doc),
lambda root: _add_measured_lineseg(root, text, [{"textpos": "0", **_A4_LINE}]),
)
report = lint_layout(data)
assert not report.ok
assert any(
f.code == STALE_LINESEG_DETECTED and "lacks lines" in f.message
for f in report.errors
)


def test_valid_multiline_cache_tail_passes():
"""정상 다줄 캐시(한컴 저장 형태): 마지막 줄 이후 꼬리가 한 줄 안 → 침묵."""

text = "가" * 120
doc = HwpxDocument.new()
doc.add_paragraph(text)
segs = [
{"textpos": "0", **_A4_LINE},
{"textpos": "42", **_A4_LINE},
{"textpos": "84", **_A4_LINE},
]
data = _inject_section0(
_bytes(doc), lambda root: _add_measured_lineseg(root, text, segs)
)
assert lint_layout(data).ok


def test_tracked_insert_prefix_artifact_replay():
"""#85 실물 재현 쌍: 수리 전 산출물은 FAIL, 현행(수리 후) 산출물은 PASS.

현행 코드로 변경추적 삽입을 하면 편집 문단의 캐시가 제거된다(a22342c) —
그 산출물이 PASS 쪽. FAIL 쪽은 같은 편집에 수리 전처럼 옛 1줄 캐시를
바이트에 되붙여 재구성한다.
"""

base = "시행문 본문 첫 문단"
doc = HwpxDocument.new()
paragraph = doc.add_paragraph(base, char_pr_id_ref="0")
doc.tracking.insert(paragraph, " 추가로 붙는 안내 문구가 길게 이어진다 " + "안내" * 50, date="2026-08-19")

fixed = doc.to_bytes() # 수리 후: 캐시 없음
assert lint_layout(fixed).ok

edited_text = paragraph.text

def reattach(root): # 수리 전 재구성: 옛 1줄 캐시가 그대로 남았던 상태
_add_measured_lineseg(root, edited_text, [{"textpos": "0", **_A4_LINE}])

broken = _inject_section0(fixed, reattach)
report = lint_layout(broken)
assert not report.ok
assert any(
f.code == STALE_LINESEG_DETECTED and "lacks lines" in f.message
for f in report.errors
)


# --------------------------------------------------------------------------- #
# 2: dirty ↔ lineseg leak (ledger-gated).
# --------------------------------------------------------------------------- #
Expand Down