diff --git a/src/hwpx/oxml/body.py b/src/hwpx/oxml/body.py index 4cf51a1..5b6eaf1 100644 --- a/src/hwpx/oxml/body.py +++ b/src/hwpx/oxml/body.py @@ -32,7 +32,10 @@ "ole", "chart", "video", - "audio", + # "audio"는 제외 — 실한컴(Windows 한글 2024 실측)은 소리 삽입을 + # hp:ole(EMBEDDED/ICON)로 방출하고 hp:audio를 생성하지 않으며, 보유 + # 실코퍼스 표본도 0건이다. 실물 미검증 요소를 인라인 읽기 지원으로 + # 분류하지 않는다(빈도 0 = 읽기측 불투명 보존만). python-hwpx#89. "textart", } diff --git a/src/hwpx/tools/package_validator.py b/src/hwpx/tools/package_validator.py index fbc6468..9dfb581 100644 --- a/src/hwpx/tools/package_validator.py +++ b/src/hwpx/tools/package_validator.py @@ -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": @@ -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 ```` 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( @@ -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 @@ -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( diff --git a/tests/test_layout_lint.py b/tests/test_layout_lint.py index 85324c9..7d95455 100644 --- a/tests/test_layout_lint.py +++ b/tests/test_layout_lint.py @@ -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). # --------------------------------------------------------------------------- #