post-replace-relayout

findTextContainer 로직 분석

요청에서 사용한 findContainer에 해당하는 실제 함수명은 findTextContainer다. 이 문서는 현재 구현을 기준으로 텍스트의 배경 도형을 선택하는 과정과 그 영향을 정리한다.

01역할

findTextContainer는 주어진 텍스트가 어느 배경 도형 위에 놓여 있는지를 판정한다.

이 판정은 단순한 트리 부모 찾기가 아니다. 실제 sheetJson에서는 텍스트와 배경 도형이 부모·자식이 아니라 같은 그룹의 형제로 놓이는 경우가 많기 때문에 다음 두 정보를 순서대로 사용한다.

  1. 노드 트리의 조상 관계
  2. 절대 bounding box 사이의 기하학적 겹침

찾은 컨테이너는 다음 기능들이 공통으로 사용한다.

  • 텍스트가 배경 도형을 벗어났는지 계산
  • 텍스트와 함께 이동하거나 확장할 배경 도형 결정
  • 뷰어에서 컨테이너 오버레이 표시
  • overlapAreaMap에서 텍스트와 자기 배경 도형 사이의 정상적인 겹침 제외

컨테이너 판정이 달라지면 재배치 대상과 지표 결과가 동시에 달라진다. 그래서 재배치 후보도 별도 판정 로직을 만들지 않고 이 함수를 공유한다.

02함수 계약

findTextContainer(
  textNodeId: string,
  doc: Doc,
  abs: Map<string, AbsBox>,
  parent: Map<string, string>,
  byId: Map<string, Node>,
): ContainerId
인자의미
textNodeId컨테이너를 찾을 텍스트 노드 ID
doc전체 sheetJson 문서
abs노드 ID → 절대 bounding box
parent자식 ID → 부모 ID
byId노드 ID → 노드 객체

반환값은 다음 둘 중 하나다.

  • 실제 배경 도형의 노드 ID
  • 유효한 배경 도형을 찾지 못했을 때 ROOT_CONTAINER ("rootContainer")

ROOT_CONTAINER는 실제 노드 ID가 아니라 페이지가 바깥 컨테이너임을 나타내는 sentinel이다. 실제로 이동하거나 확장할 배경 도형만 필요하다면 validContainerOf를 거쳐야 한다.

validContainerOf(ROOT_CONTAINER); // null
validContainerOf('shape-1'); // 'shape-1'

ROOT_CONTAINER는 truthy 문자열이므로 if (!containerId)로는 구분할 수 없다.

03좌표계

data.props.boundingBox는 부모 기준 상대좌표다. 컨테이너 판정에 전달하는 absabsBoxMap으로 만든 절대좌표 맵이다.

buildDocAbsBoxMap(doc, { preferRotated: false });

preferRotated: false가 중요한 이유는 렌더된 pageJson의 루트 GroupItem에 stale rotatedBoundingBox가 남아 있는 사례가 있기 때문이다. 이를 우선하면 문서 전체 절대좌표가 어긋날 수 있다.

04컨테이너 후보 타입

후보 타입은 TEXT_CONTAINER_TYPES로 정의한다.

const TEXT_CONTAINER_TYPES = new Set([
  ...GRAPHIC_TYPES,
  'ExtendableShapeItem',
]);

현재 후보는 다음과 같다.

  • BitmapItem
  • ImageItem
  • PatternItem
  • PhotoItem
  • ShapeItem
  • VectorGraphicItem
  • ExtendableShapeItem

GRAPHIC_TYPES와 별도 이름을 사용하는 이유는 두 집합이 답하는 질문이 다르기 때문이다.

  • GRAPHIC_TYPES: 이 노드가 그림인가
  • TEXT_CONTAINER_TYPES: 이 노드가 텍스트를 얹을 바탕이 될 수 있는가

ExtendableShapeItem은 comp-sign의 일반 그래픽 분류에는 포함되지 않지만, 카드·칩의 바탕으로 사용되는 경우가 있어 이 로직에서만 후보에 추가한다.

05주요 상수

상수의미
CONTAINER_MIN_COVER0.5기하 탐색에서 도형이 텍스트 면적의 50% 이상과 겹쳐야 함
PAGE_BACKDROP_RATIO0.9페이지 면적의 90% 이상인 도형은 전면 배경판으로 간주
ROOT_CONTAINER"rootContainer"실제 배경 도형이 없고 페이지를 바깥 컨테이너로 써야 함

배경판 판정은 도형과 페이지의 실제 교집합 면적이 아니라 도형 bounding box 자체의 면적을 사용한다.

도형 width × height >= 페이지 width × height × 0.9

06판정 알고리즘

6.1 페이지 배경판 판정 함수 구성

먼저 문서 페이지 면적을 계산하고, 후보 노드의 절대 박스 면적이 그 90% 이상인지 확인하는 로컬 함수 isPageBackdrop을 만든다.

절대 박스를 찾지 못한 노드는 배경판으로 간주하지 않는다.

6.2 1순위: 조상 체인 탐색

텍스트의 직접 부모부터 루트 방향으로 올라간다.

현재 노드 = 텍스트의 부모

while 현재 노드가 존재:
  후보 타입이고 페이지 배경판이 아니면 즉시 반환
  현재 노드 = 현재 노드의 부모

특징은 다음과 같다.

  • 텍스트에 가장 가까운 유효 조상이 우선한다.
  • 페이지 배경판인 조상은 건너뛰고 더 위로 올라간다.
  • 조상 후보와 텍스트가 실제로 겹치는지는 검사하지 않는다.
  • 조상이라는 구조적 관계가 기하 관계보다 우선한다.

6.3 2순위: 문서 전체 기하 탐색

유효한 조상을 찾지 못하면 텍스트의 절대 박스를 읽는다. 박스가 없거나 폭·높이가 0 이하이면 즉시 ROOT_CONTAINER를 반환한다.

유효한 텍스트 박스가 있으면 문서 전체 노드를 순회하며 다음 조건을 모두 만족하는 도형만 후보로 남긴다.

  1. TEXT_CONTAINER_TYPES에 포함된다.
  2. 절대 박스가 존재한다.
  3. 페이지 전면 배경판이 아니다.
  4. 텍스트 박스와의 교집합이 텍스트 박스 면적의 50% 이상이다.
intersectArea(textBox, candidateBox) >= textBoxArea × 0.5

조건을 통과한 후보 중에는 후보 도형 자체의 면적이 가장 작은 것을 선택한다.

best = min(candidates, candidate.width × candidate.height)

여기서 기준은 교집합 면적의 최댓값이 아니다. 코드 주석의 “가장 많이 겹치는 도형”이라는 표현과 달리 실제 구현은 다음 규칙이다.

텍스트의 50% 이상과 겹치는 도형 중 전체 면적이 가장 작은 도형

면적이 같은 후보가 여러 개면 < 비교만 사용하므로 doc.nodes에서 먼저 나온 후보가 유지된다.

6.4 3순위: 페이지 반환

기하 탐색에서도 후보를 찾지 못하면 ROOT_CONTAINER를 반환한다.

넘침 지표에서는 실제 도형이 없을 때 페이지 박스를 외곽 경계로 사용한다. 반면 재배치 알고리즘에서는 확장하거나 함께 옮길 실제 도형이 없다는 뜻이므로 null로 변환해 처리한다.

07전체 의사 코드

function findTextContainer(textId):
  pageArea = doc.width × doc.height

  for ancestor in nearest-to-farthest ancestors(textId):
    if ancestor is container type and not page backdrop:
      return ancestor.id

  textBox = abs[textId]
  if textBox is missing or non-positive:
    return ROOT_CONTAINER

  best = ROOT_CONTAINER
  bestArea = Infinity

  for node in doc.nodes:
    if node is not container type:
      continue
    if node box is missing or node is page backdrop:
      continue
    if intersection(textBox, nodeBox) < textArea × 0.5:
      continue
    if nodeArea < bestArea:
      best = node.id
      bestArea = nodeArea

  return best

08호출 흐름

pairTextWithContainer

pairTextWithContainer는 문서의 모든 TextItemfindTextContainer를 적용한다. method 1·2는 이 결과로 텍스트와 컨테이너를 하나의 재배치 단위로 취급한다.

computeLayoutMetrics

computeLayoutMetrics는 텍스트 박스가 컨테이너 밖으로 벗어난 면적과 세로 중앙 오차를 계산할 때 이 결과를 사용한다. 실제 배경 도형이 없으면 페이지를 넘침 기준으로 사용한다.

overlapAreaMap

overlapAreaMap은 각 텍스트의 컨테이너 ID를 저장한 뒤 텍스트 ↔ 자기 컨테이너 쌍을 정상 배치로 간주해 겹침 결과에서 제외한다.

textContainerBoxOf

textContainerBoxOfROOT_CONTAINERnull로 바꾼 뒤 실제 배경 도형의 절대 박스를 반환한다. center-text 같은 알고리즘이 페이지 중앙으로 배경 없는 제목을 끌어오는 일을 막는다.

09경계 조건과 알려진 한계

조상 후보는 기하 검사를 생략한다

후보 타입의 조상이 존재하면 텍스트가 그 도형 밖에 있더라도 컨테이너로 선택한다. 트리 구조가 신뢰할 수 있다는 전제다.

작은 겹침 후보를 잘못 고를 수 있다

형제 기반 탐색은 의미나 z-order를 보지 않는다. 텍스트 면적의 50% 이상과 겹치는 작은 이미지나 아이콘이 있으면 큰 카드보다 그 노드가 선택될 수 있다.

큰 패널과 실제 본문 카드를 면적만으로 구분하지 못한다

페이지의 90% 미만을 차지하는 큰 패널은 후보로 남는다. 알려진 사례에서는 페이지의 64.3%를 덮는 패널을 제목의 컨테이너로 잡았다. 그렇다고 임계를 50% 수준으로 낮추면 페이지 절반 정도를 차지하는 정상 본문 카드도 모두 후보에서 빠질 수 있어 현재는 90%를 유지한다.

자세한 실측 배경은 architecture.md의 findTextContainer을 참고한다.

tight box와 독립적으로 컨테이너를 찾는다

넘침·겹침 지표가 텍스트의 tight box를 사용하더라도 컨테이너 판정에는 abs에 들어 있는 rendered bounding box를 사용한다. 따라서 glyph 영역과 프레임 영역의 차이는 컨테이너 선택에 반영되지 않는다.

시간 복잡도

  • 조상 탐색: O(h) (h는 트리 높이)
  • 기하 탐색: 최악 O(n) (n은 전체 노드 수)
  • 텍스트 전체를 각각 찾으면 최악 O(t × n)

호출자가 abs, parent, byId를 미리 만들어 전달하는 이유는 텍스트마다 공통 맵을 다시 구축하는 비용을 피하기 위해서다.