0 · 30초 요약
의도된 겹침 다섯 갈래를 규칙으로 빼고 나면, 남는 것은 「무언가가 무언가에 부분적으로 포개졌다」뿐이다.
그중 expected보다 100px² 이상 늘어난 것만 재배치의 잘못(신규 겹침)으로 센다. 이것이 지표 ⑥ — 유일한 PASS/FAIL 게이트다.
- 무엇을 낸다 — 문서 하나의
Map<"idA|idB", 겹친면적px²>. leaf 쌍 전수 판정. - 왜 별도 지표인가 — ④ 유휴 여백은 격자 union이라 union = Σ면적 − Σ겹침. 노드를 키우면서 겹치는 알고리즘에서는 면적 증가가 겹침 손실을 덮어 글자가 포개진 문서가 ④ 기준 「개선」으로 찍힌다.
- 판정은 절대량이 아니라 차분 — 컴포넌트에는 원래부터 의도적으로 겹친 장식이 흔하다. expected와 after를 같은 규칙으로 재고 area − baseArea > 100px²인 쌍만 신규로 센다.
- 실전 검증 — 6357697에서
center-text가 만든 텍스트 6쌍 겹침이 이 게이트에 걸렸다. 「중앙 정렬은 방향은 맞지만 겹침 제약 없이는 못 쓴다」는 첫 실험 결론이 이 함수에서 나왔다.
1 · 게이트 파이프라인 — 함수가 있는 자리
overlapAreaMap은 baseline 비교를 모른다. 문서 하나의 겹침 지도만 내놓는
자립 순수 함수이고, 차분은 소비자인 computeLayoutMetrics가 한다.
→ overlapNow
→ overlapBase
area − base > 100px²
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;
}
}
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와 같은 기준 |
| ③ | 텍스트 ↔ 자기 컨테이너 | 도형 위에 글자가 놓이는 건 당연하다 | isOwnContainer — findTextContainer가 지정한 그 도형만.
단 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_EPS | 100 px² | 원래 부동소수 잡음 하한(1px²) 용도였으나, 변끼리 딱 붙은 박스(공유 경계)와 모서리가 스치는 접촉이 오차 범위에서 걸려 100으로 올렸다. 쌍 판정과 게이트 차분(area − base > 100) 양쪽에 같은 값을 쓴다 |
| CONTAINED_RATIO | 0.95 | 「얹힘」 판정의 포함 비율(작은 쪽 면적 기준). export되어 topology.ts의
유닛·프레임 판정이 같은 값을 본다 — 지표가 「얹힌 것」으로 안 세는 관계를
알고리즘이 「포갰다」고 보면 개선이 숫자로 안 나오므로, 판정기와 재배치의 세계관을 상수 하나로 묶었다 |
| PAGE_BACKDROP_RATIO | 0.9 | 페이지 백그라운드 기준. ⑥·④·findTextContainer·topology.ts가 전부 공유
(커밋 47678d2f "페이지 배경판 기준을 상수 하나로 모은다") |
| DECORATION_TYPES | 2종 | LineItem · VectorGraphicItem. 실측이 계기 — 6174028에서 구분선 하나가 텍스트와 1410px² '겹침'으로 잡혔다. 획이 얇아도 bbox가 크게 잡혀 판정 자체가 부정확하다 |
| TEXT_CONTAINER_TYPES | GRAPHIC + 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 접촉 → 겹침 순으로 판정이 바뀐다. 「자기 컨테이너」 토글은 규칙 ③의 효과를 보여준다.
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
- TODO
isNested주석 원문 — "[TODO] 이 규칙이 일반적으로 사용될 수 있는지 확인 필요." 0.95라는 값과 「큰 쪽이 컨테이너 타입」 조건이 케이스 5건에서 맞춰진 것이라, 20건 확장 시 재검증 대상. - 주의 ⑤ 얹힘 면제는 z-order를 안 본다. 기하로만 판정하므로 「작은 것이 큰 도형 뒤에 완전히 가려진」 병리적 케이스도 얹힘으로 면제된다. 실무에서는 드물다고 보고 수용.
- 주의 회전 노드 — 판정은 AABB(
absBoxMap) 기준이다. 회전된 요소는 bbox가 실체보다 커서 과탐 가능. tight box가 있는 텍스트만 완화된다. - 주의
DECORATION_TYPES는 쌍 전체를 면제한다 — 장식 위에 텍스트를 밀어 넣는 재배치 실수는 이 게이트가 못 잡는다. 의도된 트레이드오프 (상시 오탐 > 드문 미탐). - 의존 ③은
findTextContainer·containerTypeShim의 정확성에 기대고 있다. shim이 빠지면(정식 반영 시) 컨테이너 판정이 바뀌어 ⑥의 면제 대상도 함께 바뀐다 — 회귀 테스트가 필요한 또 하나의 이유.
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쌍 | ② 백그라운드 면제 |
| 그룹 박스 ↔ 남의 leaf | 0쌍 | ① 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)을 공유한다.