← 병합 대치 정렬 문제

post-replace-relayout · relayoutCore/metrics.ts

overlapAreaMap — 지표 ⑥ 신규 겹침 게이트 해부

동작 로직과 각 결정의 이유. 근거는 src/post-replace-layout/relayoutCore/metrics.ts (브랜치 feat/post-replace-relayout, 2026-08-21 실측)의 코드·주석과 케이스 실측 기록.
W1 예정인 overlapAreaMap 회귀 테스트의 스펙 문서를 겸한다 (§8).

MOR-2018 · post-replace-relayout 원본 MOR-2018/overlap-gate-notes.html

0 · 30초 요약

의도된 겹침 다섯 갈래를 규칙으로 빼고 나면, 남는 것은 「무언가가 무언가에 부분적으로 포개졌다」뿐이다.

그중 expected보다 100px² 이상 늘어난 것만 재배치의 잘못(신규 겹침)으로 센다. 이것이 지표 ⑥ — 유일한 PASS/FAIL 게이트다.

1 · 게이트 파이프라인 — 함수가 있는 자리

overlapAreaMap은 baseline 비교를 모른다. 문서 하나의 겹침 지도만 내놓는 자립 순수 함수이고, 차분은 소비자인 computeLayoutMetrics가 한다.

overlapAreaMap(after, tight)
→ overlapNow
overlapAreaMap(expected, expectedTight)
→ overlapBase
쌍별 차분
area − base > 100px²
newOverlapPairs
newOverlapArea
= 지표 ⑥ 게이트
// metrics.ts · computeLayoutMetrics 내부 (646행~)
const overlapNow  = overlapAreaMap(doc, tight);
const overlapBase = expected ? overlapAreaMap(expected, expectedTight) : null;
for (const [key, area] of overlapNow) {
  const baseArea = overlapBase?.get(key) ?? 0;
  // 새로 겹친 쌍(baseArea = 0)뿐 아니라 **더 많이 겹치게 된 쌍**도 신규로 센다.
  if (overlapBase && area - baseArea > OVERLAP_EPS) {
    newOverlapPairs++;
    newOverlapArea += area - baseArea;
  }
}
같은 규칙으로 두 번 재는 것이 게이트의 성립 조건이다. expected 쪽에도 같은 필터·같은 판정 박스·같은 EPS가 적용되므로, 「원래부터 겹쳐 있던 장식」은 양쪽에서 같은 키로 잡혀 차분에서 자연히 상쇄된다. ③ 정렬선 보존율이 expected 대비로 재는 것과 같은 논리다.

2 · 2단계 알고리즘

1단계 — 판정 대상(items) 수집

문서의 노드를 한 번 순회하며 거른다. 살아남은 노드마다 판정 박스자기 컨테이너 id를 붙여 둔다.

필터이유
SKIP 자식이 있는 노드 leaf만 본다. 그룹 박스는 자식들의 합집합이라 그룹끼리는 항상 겹치기 마련이고, 부모–자손 겹침(당연한 것)도 이 규칙 하나로 함께 빠진다
SKIP DECORATION_TYPES LineItem·VectorGraphicItem — §3-④
SKIP 폭·높이 ≤ 0, 절대 박스 없음 잴 수 없는 것은 판정하지 않는다
SKIP 면적 ≥ 페이지의 90% 페이지 백그라운드 — §3-②
BOX 텍스트 → judgeBoxOf tight(글리프 실측 박스)가 있으면 그것, 없으면 rendered bounding box. status:'empty'(빈 텍스트) → null, 구운 값 없음 → undefined둘 다 판정에서 제외
BOX 텍스트 → findTextContainer 배경 도형 id를 미리 붙여 둔다. 도형은 ROOT_CONTAINER

2단계 — O(n²) 쌍 판정

for (let i = 0; i < items.length; i++) {
  for (let j = i + 1; j < items.length; j++) {
    if (isOwnContainer(a, b) || isOwnContainer(b, a)) continue;  // §3-③
    const area = intersectArea(a.box, b.box);
    if (area <= OVERLAP_EPS) continue;                           // §4 · 100px²
    if (isNested(a, b, area)) continue;                          // §3-⑤
    out.set(pairKey(a.id, b.id), area);                          // 사전순 결합 → 순서 무관
  }
}

pairKey(a,b)는 id를 사전순으로 붙인다(a<b ? "a|b" : "b|a") — 쌍에는 순서가 없으므로 같은 쌍이 두 키로 갈라지지 않게 하기 위함. 케이스 규모(leaf 수십 개)에서 O(n²)는 문제가 아니다.

3 · 제외 규칙 다섯 가지 — 「의도된 겹침」의 목록

이 함수의 정체성은 겹침을 세는 쪽이 아니라 무엇을 겹침으로 치지 않을지에 있다. 다섯 규칙은 전부 「디자이너가 일부러 포개 놓는 패턴」 하나씩에 대응한다.

#규칙거르는 패턴구현
leaf만 그룹 박스끼리 · 부모–자손 childNodeIds.length > 0 → skip
페이지 백그라운드 전면 배경판은 모든 것과 겹친다 면적 ≥ 페이지 × PAGE_BACKDROP_RATIO(0.9). ④·findTextContainer와 같은 기준
텍스트 ↔ 자기 컨테이너 도형 위에 글자가 놓이는 건 당연하다 isOwnContainerfindTextContainer가 지정한 그 도형만. 단 ROOT_CONTAINER는 부모로 안 친다
장식 타입 구분선·화살표·리본은 다른 요소 위에 걸치라고 놓는 것 DECORATION_TYPES = LineItem · VectorGraphicItem. 한쪽이라도 끼면 쌍 전체 제외
포함 관계(얹힘) 카드 위의 칩, 칩 위의 글자 — 포갠 게 아니라 얹은 isNested — 작은 쪽이 자기 면적의 CONTAINED_RATIO(0.95) 이상 큰 쪽 안에 있고, 큰 쪽이 TEXT_CONTAINER_TYPES일 때
③과 ⑤의 관계 — ⑤는 ③의 일반형이다. ③은 findTextContainer가 뽑아준 그 하나만 면제한다. 그런데 컨테이너 판정은 「텍스트를 50% 이상 덮는 가장 작은 도형」이라 아래층 도형(칩 밑의 패널)은 컨테이너로 못 뽑힌다. ⑤는 containerId를 아예 안 보고 기하만으로 판정하므로 그 아래층까지 함께 걸러진다.
⑤가 텍스트끼리는 절대 안 걸리는 이유. isNested는 큰 쪽이 TEXT_CONTAINER_TYPES(= GRAPHIC_TYPES + ExtendableShapeItem)여야 성립한다. 텍스트는 이 집합에 없으므로 텍스트가 텍스트에 아무리 깊이 포개져도 항상 겹침으로 잡힌다. 6357697에서 center-text의 텍스트 6쌍 겹침이 FAIL로 잡힌 게 정확히 이 설계 덕분이다.
③에서 ROOT_CONTAINER를 부모로 치면 안 되는 이유 (주석 명시). 배경 도형 없는 텍스트들은 전부 containerId = ROOT다. 이걸 「같은 부모를 둔 사이」로 오인해 면제하면 배경 없는 텍스트끼리의 겹침이 통째로 판정에서 빠진다 — 가장 흔하고 가장 나쁜 실패(글자 위 글자)를 못 보게 된다.

4 · 상수와 그 값의 이유

상수왜 이 값인가
OVERLAP_EPS100 px² 원래 부동소수 잡음 하한(1px²) 용도였으나, 변끼리 딱 붙은 박스(공유 경계)와 모서리가 스치는 접촉이 오차 범위에서 걸려 100으로 올렸다. 쌍 판정과 게이트 차분(area − base > 100) 양쪽에 같은 값을 쓴다
CONTAINED_RATIO0.95 「얹힘」 판정의 포함 비율(작은 쪽 면적 기준). export되어 topology.ts의 유닛·프레임 판정이 같은 값을 본다 — 지표가 「얹힌 것」으로 안 세는 관계를 알고리즘이 「포갰다」고 보면 개선이 숫자로 안 나오므로, 판정기와 재배치의 세계관을 상수 하나로 묶었다
PAGE_BACKDROP_RATIO0.9 페이지 백그라운드 기준. ⑥·④·findTextContainer·topology.ts가 전부 공유 (커밋 47678d2f "페이지 배경판 기준을 상수 하나로 모은다")
DECORATION_TYPES2종 LineItem · VectorGraphicItem. 실측이 계기 — 6174028에서 구분선 하나가 텍스트와 1410px² '겹침'으로 잡혔다. 획이 얇아도 bbox가 크게 잡혀 판정 자체가 부정확하다
TEXT_CONTAINER_TYPESGRAPHIC + Extendable 「텍스트를 얹을 바탕이 될 수 있는 타입」. ⑤ isNested의 큰 쪽 조건

판정 박스 — 왜 텍스트만 tight box인가

텍스트의 rendered bounding box는 오토핏 여백까지 포함해서, 글자가 실제로 닿지 않는데도 겹침으로 세는 오탐이 난다. 그래서 tight(= scripts/tight-box-cases.ts가 구워 둔 글리프 실측 박스, 프레임 원점 기준이라 절대 박스를 더해 변환)가 있으면 그걸 우선한다. 도형은 bbox가 곧 실체이므로 절대 박스 그대로.

// judgeBoxOf 의 3분 반환 — 호출 측이 셋을 구분해야 한다
tight 없음            → box        (rendered bbox로 잰다)
tight[id] 없음        → undefined  ("잴 수 없음" — 판정 제외)
tight[id].status≠ok   → null       (빈 텍스트 — 칠해진 게 없으니 제외)
tight[id].status=ok   → 절대좌표로 변환한 glyph extent

5 · 판정 시뮬레이터 — 규칙이 어느 순서로 무는가

도형(컨테이너 타입) 위에 텍스트가 있다. 텍스트를 아래로 끌어내리면 얹힘(⑤) → EPS 접촉 → 겹침 순으로 판정이 바뀐다. 「자기 컨테이너」 토글은 규칙 ③의 효과를 보여준다.

텍스트 T (240×60) vs 도형 S (300×160) — T의 세로 위치를 움직여 보세요
50px
도형 S 텍스트 T (판정 박스) 겹침으로 세는 영역 겹치지만 면제된 영역

이 데모가 보여주는 것 세 가지. ① T가 S 안에 95% 이상 들어가 있으면(⑤ 얹힘) 컨테이너 지정 여부와 무관하게 면제된다 — 「자기 컨테이너 아님」으로 바꿔도 verdict가 안 변하는 구간이 그것. ② S를 TextItem으로 바꾸면 얹힘 면제가 사라져 텍스트끼리는 항상 겹침이 된다. ③ 걸치는 면적이 100px² 이하로 내려가는 순간(거의 다 빠져나갔을 때) EPS가 끊는다.

6 · 설계 결정 Q&A

왜 이렇게 짰나
왜 ④(유휴 여백)로 못 잡고 별도 지표인가 ④는 4px 격자 union. union = Σ면적 − Σ겹침이므로 겹치면 ④도 나빠지긴 하지만, 노드를 키우면서 겹치는 알고리즘(hug·여백 재분배 계열 = 우리가 만들 것)에서는 Σ면적 증가가 겹침 손실을 덮는다. 우리 알고리즘의 주 실패 모드가 정확히 ④의 사각지대에 있어서 직접 세는 게이트가 필요했다
왜 Doc 하나만 받는 자립 함수인가 「신규 겹침」이 뜻을 가지려면 expected와 after를 완전히 같은 규칙으로 재야 한다. baseline 비교를 함수 안에 넣으면 두 문서에 다른 필터가 적용될 여지가 생긴다. 순수 함수라 W1 회귀 테스트 대상으로도 좋다
왜 절대량이 아니라 expected 대비 차분인가 컴포넌트에는 원래부터 의도적으로 겹친 장식이 흔해서 절대량으로는 재배치의 잘못을 못 가려낸다. 제외 규칙으로 못 거른 「원래부터 겹쳐 있던 것」의 최종 방어선이 차분이다
왜 「더 겹치게 된 쌍」도 신규인가 이미 10px² 겹치던 쌍을 5000px² 겹치게 만든 것도 재배치의 잘못이다. baseArea=0 조건 대신 area − base > EPS로 판정해 악화를 함께 잡는다
왜 텍스트만 line/tight box인가 텍스트 bbox는 오토핏 여백 포함 → 실제로는 안 겹치는 것도 센다 (오탐). 도형은 bbox가 실체 그대로다
왜 빈 텍스트를 빼나 칠해진 것이 없으면 시각적으로 겹칠 수 없다. status:'empty'null → 제외
왜 CONTAINED_RATIO를 export 하나 topology.ts(재배치의 유닛·프레임 판정)와 값을 공유하기 위해. 판정기가 면제하는 관계와 알고리즘이 보존하는 관계가 어긋나면 개선이 숫자로 안 나온다
왜 pairKey를 사전순으로 붙이나 쌍에는 순서가 없다. (a,b)와 (b,a)가 다른 키가 되면 expected와 after의 같은 쌍이 차분에서 안 만난다

7 · 알려진 구멍 · TODO

8 · W1 회귀 테스트 스펙 (제안)

전부 순수 함수 대상이라 sheetJson 최소 fixture로 만들 수 있다. 각 행이 제외 규칙 하나 또는 경계값 하나에 대응한다.

케이스기대확인하는 규칙
텍스트 두 개, 50% 포갬겹침 1쌍기본 동작 + ⑤가 텍스트끼리 안 걸림
텍스트 두 개, 변만 공유 (면적 0)0쌍EPS — 접촉은 겹침이 아니다
텍스트 두 개, 교차 99px²0쌍EPS 경계 (100 초과부터)
텍스트 ↔ findTextContainer가 뽑은 도형0쌍③ 자기 컨테이너
배경 없는 텍스트 둘 (containerId 둘 다 ROOT) 포갬겹침 1쌍③에서 ROOT는 부모가 아니다
칩(96%가 카드 안) ↔ 카드0쌍⑤ 얹힘 (0.95 초과)
칩(90%만 카드 안) ↔ 카드겹침 1쌍⑤ 경계 (0.95 미달 → 부분 포갬)
LineItem ↔ 텍스트 대면적 교차0쌍④ 장식 면제 (6174028 재현)
페이지 95% 배경판 ↔ 아무 노드0쌍② 백그라운드 면제
그룹 박스 ↔ 남의 leaf0쌍① leaf만
빈 텍스트(tight status:'empty') 포갬0쌍judgeBoxOf null 제외
같은 문서 두 번 → 차분신규 0게이트 항등성 (expected = after)
base 10px² → after 5000px²신규 1쌍「더 겹치게 된 쌍」도 신규
6357697 replaced + center-text신규 6쌍실측 케이스 고정 (골든)

근거: src/post-replace-layout/relayoutCore/metrics.ts (overlapAreaMap 422행~ · computeLayoutMetrics 차분 646행~ · 상수 55~281행 · judgeBoxOf 111행~) · topology.ts의 상수 공유 · relayout-status-2026-08-18.md §1.4(지표 ⑥) · relayout-workspace.md(케이스 실측) · 커밋 47678d2f(배경판 상수 통합) 33cda32d(findContainer 통합). 데모의 판정 로직은 본문 규칙의 참조 재구현이며 실제 코드와 값(EPS 100 · RATIO 0.95)을 공유한다.