← 미리디 아카이브

데이터 포맷 세 저장소 코드 실측 2026-08-26

sheetJson 해부도

페이지 한 장을 통째로 담은 JSON. 미리캔버스 에디터·컨버터·컴포넌트 파이프라인이 전부 이 한 덩어리를 주고받는다. 어떤 모양인지, 누가 만들고 누가 읽는지, 그 코드가 어느 파일 몇 번째 줄에 있는지를 세 저장소에 걸쳐 한 장에 모았다.

miricanvas-web-2 (engine) miricanvas-iui aippt-prisonbreak (MORDOR)
§0

30초 요약

  • 정체페이지(=슬라이드) 한 장의 완전한 스냅샷. 캔버스 크기 + 배경 + 그 안의 모든 요소. 파일로 저장되면 sheet.json, DB 컬럼으로는 design_object_sheet_json, API로는 componentSheetJsonFileUrl.
  • 크기SHEETSIZE·TEMPLATE원본 XML의 필드 이름이지 sheetJson의 필드가 아니다. 컨버터가 SHEETSIZE.cx/cywidth/height + data.props.boundingBox로 평탄화한다.
  • nodes트리가 아니라 평평한 조회 테이블(Map<id, 노드>를 배열로 편 것). 배열 순서에는 의미가 없다.
  • rootNodeIds그리는 순서(z-order). 트리는 rootNodeIdschildNodeIds로만 복원한다. 이걸 무시하고 nodes 순서를 쓰면 배경이 글자를 덮는다 — 실제로 2만 2천 건이 그렇게 깨졌다.
  • 생산자셋뿐이다 — ① XML 컨버터, ② 엔진 직렬화(makePageCreateCommandChunk), ③ 손조립. §6
§1

이름이 네 개인 한 물건

제일 먼저 헷갈리는 지점이다. 같은 JSON 덩어리를 저장소마다 다른 타입 이름으로 부른다. 상속 관계도 아니고 import 관계도 아니다 — 각 저장소가 자기가 필요한 필드만 다시 선언했고, 구조적으로 호환(structurally assignable)되기 때문에 그냥 서로 넘겨 쓴다.

이름어디서 쓰나정의 위치
PageData 정본 엔진 framework. 서버 DESIGN/CREATE command payload의 원형 — 모든 필드가 여기서 나온다. commandCreateTypes.ts:20
PageJson 엔진 converter. PageReadResponseData['pages'][0]의 별칭 — 즉 위의 PageData & { designId }. PageNodeName.ts:24
Doc miricanvas-iui. 색상 대치·매칭·재배치 도구가 쓰는 축약형. 필요한 props만 좁게 모델링. color-mapper/types.ts:10
Sheet miricanvas-iui recolor.ts. 다른 코드베이스로 파일째 이식하려고 의존성 0으로 다시 쓴 최소 타입. recolor.ts:220
PageJson MORDOR aippt-prisonbreak. 파이프라인용 느슨한 재선언 — [key: string]: unknown 탈출구를 열어 뒀다. pipeline/common/type/pageJson.ts:314
SheetJson engine-util. 텍스트 박스 보정만 하는 변환 함수용 — { nodes }만 요구한다. adjustTextBoundingBox.ts:22

왜 "sheetJson"이라고 부르나

문서의 루트 XML 노드 이름이 SHEET였다. 미리캔버스 1.0의 저장 포맷이 sheet.xml이고, 그걸 JSON으로 바꾼 것이라 sheetJson. 타입 이름은 PageJson인데 변수 이름은 sheetJson인 코드가 많은 이유다.

§2

뼈대 — 필드 하나씩

정본(PageData)을 그대로 옮기면 이렇다. 필드가 많아 보이지만 실제로 의미를 지고 있는 건 다섯 개다.

페이지 = Doc / PageJson / PageData commandCreateTypes.ts:20
type PageData = {
  id:              string;     // pageId (uuid) — 컨버팅 때 generateUUID()로 새로 발급
  designId:        string;     // 소속 디자인. 컴포넌트 단독 sheetJson에도 실려 있다
  width:           number;     // ← SHEETSIZE.cx   (§3)
  height:          number;     // ← SHEETSIZE.cy   (§3)
  position?:       number;     // 디자인 안에서 이 페이지가 몇 번째인가
  rootNodeIds:     string[];   // ★ 그리는 순서 = z-order  (§5)
  nodes:           NodeData[]; // ★ 평평한 조회 테이블      (§4)
  data:            { props: PageProps }; // 페이지 자체의 속성
  backgroundNodeId?: string;   // 배경 노드는 rootNodeIds 밖에 따로 산다
  template?:       PageTemplateReference;
};

// data.props 안에 실제로 들어 있는 것
data.props.boundingBox   // { left:0, top:0, width:1920, height:1080 } — 캔버스 크기
data.props.shape.fill    // 배경색/배경 이미지
data.props.guidelines    // 안내선 (XML GUIDELINES에서 변환)
data.props.playback      // 페이지 재생 시간·인트로/아웃트로 애니메이션
data.props.templateReference

노드 하나

요소 = Node / NodeData / PageNode commandCreateTypes.ts:8
type NodeData = {
  id:            string;
  pageId:        string;
  rootNodeId?:   string;   // 이 노드가 속한 최상위 노드(그룹 조상)의 id
  parentNodeId:  string;   // ★ 부모 노드 id. 페이지 직속이면 문자열 'NONE' (null 아님!)
  childNodeIds:  string[]; // ★ 자식 순서 = 그룹 내부의 z-order
  position?:     number;
  backgroundNodeId?: string;
  data: {
    nodeType: string;  // 'TextItem' | 'GroupItem' | 'ShapeItem' | 'ComponentItem' | ...
    props:    NodeProps; // nodeType마다 다른 속성 덩어리
  };
};

parentNodeId문자열 'NONE'인 것은 실수하기 딱 좋은 지점이다. nullundefined도 아니다. "이 컴포넌트의 루트 노드 찾기"는 웹2 코드 어디서나 이 문자열 비교로 이뤄진다:

루트 노드 찾기 — 실제 코드 ComponentBuilder.ts:315
const rootNodeData = nodes?.find(
  (n) =>
    (n.data.nodeType === NODE_TYPE.COMPONENT_ITEM ||
     n.data.nodeType === NODE_TYPE.COMPONENT_ITEM_V2) &&
    n.parentNodeId === 'NONE'
);

nodeType — 대충 20종

엔진 쪽 정본 목록은 자동 생성 파일 (packages/engine/editor/src/node/item/NodeTypeConstants.ts, 생성기는 generateNodeTypeConstants.mjs)에 있고, 파이프라인이 실제로 마주치는 축약 목록은 MORDOR의 NodeType union(21종)에 있다. 자주 보게 되는 것만 추리면:

  • TextItem — 유일하게 props.text.textTree(문단/런 트리)를 갖는다. 내용 대치가 손대는 대상.
  • GroupItem — 자식을 묶기만 한다. 박스는 자식들의 합집합.
  • ShapeItem / ExtendableShapeItem / VectorGraphicItem — 도형·일러스트. 색은 props.colorMap.
  • PhotoItem / BitmapItem — 사진. 리소스 키는 props.shape.fill.resource.key.
  • TableItem / ChartItem — 격자형. 셀 스타일은 props.cellProps[][].
  • ComponentItem / ComponentItemV2내용 시각화 컴포넌트의 루트. 이게 붙어 있으면 그 sheetJson은 "페이지"가 아니라 "컴포넌트 한 덩어리"다.
§3

SHEETSIZE는 어디서 사라지나

SHEETSIZETEMPLATE를 sheetJson에서 찾으면 안 나온다. 둘 다 원본 XML의 노드 이름이고, 컨버터가 페이지를 만들면서 width/heightdata.props.boundingBox로 접어 넣기 때문이다.

SHEET.XML → JSON (원본 모양) "SHEET": { "SHEETSIZE": { "properties": { "cx": 1920, "cy": 1080 } } "TEMPLATE": { "properties": { "Width": 1920, "Height": 1080 } } "BACKGROUND": { "properties": { "Color": "#5bcfff" } } "GUIDELINES": { HORIZONTAL… } "properties": { "PageDuration": 5, "PagePersistentKey": "tbpe…" } } convertPage() PageConverter.ts:610 SHEETJSON (Doc / PageJson) { "width": 1920, "height": 1080, data.props.boundingBox = { 0, 0, 1920, 1080 } data.props.shape.fill.color data.props.guidelines[] data.props.playback.timing } TEMPLATE은 들어올 때 존재 확인만 하고 값은 안 읽는다 — 나갈 때 boundingBox에서 다시 만들어진다 (PageJsonToXmlConverter.ts:41)
주황은 캔버스 크기가 지나가는 경로, 파랑은 페이지 속성. SHEETSIZE.properties.cx/cy 하나가 width·height·data.props.boundingBox 세 자리에 동시에 쓰인다.
실제 매핑 코드 — pageJson 리터럴 PageConverter.ts:652–711
const pageBoundingBox = {
  left: 0, top: 0,
  width:  validatedPageXmlSchema.SHEETSIZE.properties.cx,
  height: validatedPageXmlSchema.SHEETSIZE.properties.cy,
};
// …
const pageJson: PageJson = {
  id: pageId,                // generateUUID() — XML의 PagePersistentKey와 맵으로 연결만 해 둔다
  designId,
  rootNodeIds,             // 이 시점엔 아직 [] — 요소 변환이 끝나야 채워진다 (§5)
  backgroundNodeId: backgroundNode?.id,
  data: { props: {
    boundingBox: pageBoundingBox,
    playback,
    guidelines: convertXmlGuidelinesToJson(pageGuideline, pageBoundingBox),
    shape: nodeShape,
  } },
  width:  pageBoundingBox.width,
  height: pageBoundingBox.height,
  nodes: [],                // 이 시점엔 아직 [] — assignNodesToPages()가 나중에 채운다
};

페이지인지 프리셋인지 구분하는 방법

컨버터는 SHEETSIZE·TEMPLATE·BACKGROUND전부 있으면 페이지, SHEETSIZE만 있고 나머지 노드가 하나면 텍스트/차트/표/프레임 프리셋으로 분기한다. (isPageSheetXmlSchema PageConverter.ts:40)

§4

nodes는 트리가 아니다

nodes평평한 배열이다. 그룹 안의 텍스트도, 페이지 직속 도형도, 전부 같은 층위에 나란히 들어 있다. 부모–자식 관계는 오직 parentNodeId·childNodeIds 참조로만 표현된다.

nodesMap<id, 노드>를 배열로 직렬화한 것이고, 배열의 순서에는 아무 의미가 없다. 트리를 복원하려면 rootNodeIds에서 출발해 childNodeIds를 따라 내려가야 한다.

A · NODES[] — 조회 테이블 (순서 무의미) txt2 · TextItem · parent=grp bg  · ShapeItem · parent=NONE grp  · GroupItem · parent=NONE txt1 · TextItem · parent=grp card · ShapeItem · parent=NONE icon · VectorGraphic · p=NONE 서버 DB 저장순 그대로. 정렬돼 있지 않다. B · ROOTNODEIDS[] — 그리는 순서 0  "bg" 1  "card" 2  "icon" 3  "grp" grp.childNodeIds = ["txt1", "txt2"] id로 조회 NodeBuilder.build(nodes, rootNodeIds) → 재귀로 실제 Node 트리를 만든다 C · 화면 — 아래에서 위로 칠한다 bg   (index 0 · 맨 아래) card (index 1) icon (index 2) grp  (index 3 · 맨 위) └ txt1 → txt2 Z-ORDER 배열 뒤쪽일수록 위에 그려진다. A만 보면 "bg가 두 번째"라고 착각한다 — 하지만 A의 순서는 아무 의미가 없고, 화면 순서는 오직 B가 정한다. 이 둘을 혼동한 사고가 §5.
nodes(A)는 무엇이 있는지만 말하고, rootNodeIds(B)가 어떤 순서로 그릴지를 말한다. 엔진의 NodeBuilder.build()가 이 둘로 실제 트리를 만든다.

좌표는 부모 기준 상대값이다

두 번째 함정. props.boundingBoxleft/top캔버스 원점이 아니라 부모 노드 기준이다. 절대 좌표를 알려면 루트에서부터 내려오면서 부모 위치를 누적해야 한다.

캔버스 (0,0) GroupItem boundingBox { left:100, top:48 } TextItem boundingBox { left:40, top:60 } 100 40 절대 좌표 계산 abs.left = 0 + 100 + 40 = 140 abs.top  = 0 + 48  + 60 = 108 buildDocAbsBoxMap(doc)이 rootNodeIds → childNodeIds를 DFS 하며 이 누적을 대신 해 준다.
iui의 buildDocAbsBoxMap() docLayout.ts:22가 정확히 이 누적을 한다. 겹침 판정·넘침 판정처럼 절대 좌표가 필요한 계산은 전부 이걸 거친다.
§5

rootNodeIds는 그리는 순서다

rootNodeIds는 "페이지 직속 노드의 목록"이면서 동시에 z-order 그 자체다. 배열 뒤쪽에 있을수록 위에 그려진다. 이 값이 세팅되는 자리는 세 군데뿐이다.

① 컨버팅 직후

addItemsToPage()

parentNodeId === 'NONE'인 요소만 골라 rootNodeIds에 넣는다.

PageConverter.ts:370

② 레이어 정렬

sortItemJsonByLayerPriority()

XML의 Priority 속성으로 nodesrootNodeIds같은 기준으로 함께 정렬한다.

XmlToJsonConverter.ts:514

③ 엔진에서 되받을 때

makePageCreateCommandChunk()

살아 있는 Page 객체의 getChildren() 순서를 그대로 rootNodeIds로 굳힌다.

makePageCreateCommandChunk.ts:20

실제로 터진 사고 — 배경이 글자를 덮다

MORDOR 파이프라인이 rootNodeIds한 번도 참조하지 않고 nodes 배열 순서로 컴포넌트를 조립하던 시절이 있었다. XML에서 변환된 문서는 ②가 두 배열을 같은 기준으로 정렬해 줘서 우연히 맞아떨어졌지만, CDN이 .json으로 바로 주는 레이아웃은 그 정렬을 안 거친다.

같은 페이지, 두 개의 순서 rootNodeIds 순서 (엔진이 실제로 그리는 순서) [ d60b, 5180, 78fb, 499b, … , f266, db8f ] d60b · 5180 · 78fb  — outer bg 499b · 6975 · 2f20  — inner bg 8ae6 · bbbb · 86f4 — title / desc 배경 아래, 글자 위. 정상. nodes 배열 순서 (DB 저장순, 의미 없음) [ 499b, fee7, d60b, ea80, … , 5180, 2f20 ] 499b · 6975  — inner bg f266 · db8f  — desc text 5180 · 2f20  — outer bg ← 글자 위! 배경이 본문을 덮어 글자가 안 보인다. 실측 영향: json_url 레이아웃 4,847개 / DO 32,419개 중 22,666건(78.8%)에서 z-index 위반 확인.
원인·영향·복구 계획 전문은 mordor-wiki · Design Object SheetJSON Node Order 조사에 있다 (prisonbreak에서는 docs/ submodule).

지금은 고쳐져 있다

extractComponentNodes.ts:241가 이제 flattenRootIdsThroughGroups(pageJson.rootNodeIds, …) 순서로 순회하고, rootNodeIds가 없는 레거시 문서만 nodes 순서로 폴백한다.

교훈 한 줄: sheetJson을 다루는 코드에서 doc.nodes.map(...)으로 순서를 만들면 거의 항상 버그다.

§6

누가 만드나 — 생산자 3형

sheetJson은 결국 세 가지 방법으로만 생긴다. 그리고 소비는 한 가지 방법뿐이다 — PageBuilder/NodeBuilder가 살아 있는 Page 객체 트리로 되살린다. 이 왕복이 전체 그림의 전부다.

생산자 ① XML 컨버터 sheet.xml → PageReadResponseData XmlToJsonConverter.ts:159 ② 엔진 직렬화 Page 객체 → sheetJson makePageCreateCommandChunk.ts:7 ③ 손조립 노드 배열 + 기본 페이지 껍데기 buildPageJson.ts:51 (MORDOR) sheetJson width · height rootNodeIds[] nodes[] · data.props 소비자 PageBuilder → NodeBuilder rootNodeIds 순서로 재귀 build PageBuilder.ts:15 · NodeBuilder.ts:12 → 살아 있는 Page / Node 객체 트리 NodeViewFactory DOM 렌더 (에디터 · 썸네일 · preview) 또는 composite 서버에서 이미지로 ② 다시 직렬화 — 이게 왕복의 닫힘 ①은 두 군데서 돈다 — 브라우저에서는 converter 패키지를 직접 import 하고, 배치 파이프라인에서는 processor HTTP API /process/api/p/convert/design/sheet-to-page 를 부른다. ②가 sheetJson의 진짜 정본 직렬화기다 — 대치·재배치 결과가 문서로 되돌아오는 유일한 통로.
화살표 방향이 곧 데이터 흐름이다. 오른쪽 점선이 왕복을 닫는다 — 에디터에서 요소를 옮기거나 텍스트를 대치하면, 살아 있는 Page 객체가 makePageCreateCommandChunk를 통해 다시 sheetJson이 된다.

① XML 컨버터 — 원본 포맷의 문지기

진입점은 convertSheetXmlsToPageReadResponseData(). fast-xml-parser로 XML을 파싱하고, 페이지 속성과 요소를 나눠 각각 변환한 뒤, 마지막에 assignNodesToPages()가 노드를 pageId별로 묶어 page.nodes에 꽂는다.

MORDOR는 이 패키지를 직접 쓰지 않고 processor 서버에 XML을 base64로 보내 compositeId를 받고, 완료되면 결과 JSON URL을 fetch 한다 (converter/route.ts:87). 서버 안에서 도는 코드는 결국 같은 컨버터다.

② 엔진 직렬화 — Page 객체를 다시 JSON으로

이게 가장 많이 놓치는 부분이다. 에디터 안에서 텍스트를 대치하거나 박스를 옮기면, 바뀌는 건 sheetJson이 아니라 살아 있는 Page/Node 객체다. 그 상태를 다시 문서로 굳히는 유일한 함수가 이 둘이다.

Page 객체 → sheetJson makePageCreateCommandChunk.ts:7
export const makePageCreateCommandChunk = (page, currentPageIds?) => ({
  id: page.getId(),
  data: { props: page.props },
  rootNodeIds: page.getChildren().map((c) => c.getId()),   // ← 화면 순서 그대로
  nodes: nodes.map((c) => makeNodeCreateCommandChunks(c, page.getId())).flat(),
  width:  page.props.boundingBox.width,                      // ← boundingBox가 정본
  height: page.props.boundingBox.height,
  template: page.props.templateReference,
  backgroundNodeId: backgroundNode?.getId(),
});

// 자식은 재귀로 평탄화 — 여기서 트리가 flat 배열이 된다
// makeNodeCreateCommandChunks.ts:34
parentNodeId: parentNode && parentNode !== page ? parentNode.getId() : 'NONE',

③ 손조립 — 최소 껍데기

MORDOR가 컴포넌트(Design Object) 하나를 독립된 페이지로 만들 때 쓴다. 1920×1080 흰 배경 껍데기에 노드 배열을 꽂고 rootNodeIds = [첫 노드 id]로 끝낸다. "sheetJson 최소 형태"가 궁금하면 이 파일 하나만 보면 된다.

export const buildPageJson = (nodes: PageNode[]) => ({
  id: nodes[0].pageId || uuidv4(),
  data: PAGE_JSON_DEFAULT_DATA,      // boundingBox 1920×1080 + 흰 배경 + 안내선 2개
  rootNodeIds: [nodes[0].id],       // 첫 노드는 GroupItem 이라는 약속
  nodes,
  width: 1920, height: 1080,
  position: 0,
  designId: uuidv4(),
});
§7

내용 대치 — 두 갈래 길

"markdown 요약 + 컴포넌트 → 글자가 바뀐 sheetJson"을 만드는 파이프라인이 두 개 있다. 앞부분은 완전히 같고 뒷부분만 다르다 — 웹2는 엔진을 같은 프로세스에서 돌리고, iui는 브라우저 iframe에 던져서 돌린다.

공통 앞부분은 iui의 packages/comp-sign에 있고, 웹2가 그걸 @miri-unicorn/miricanvas-iui-comp-sign 패키지로 가져다 쓴다. 즉 ThumbnailLoad 타입 하나가 두 저장소의 계약이다.

공통 — miricanvas-iui / packages/comp-sign parseSummaryTree markdown → 요약 트리 summaryTree.ts buildContentReplacement 글 ↔ 노드 배정 + 박스 보정 build-replacement.ts:109 ThumbnailLoad — 두 저장소의 계약 { sheetJson: Doc, nodeTextMap, nodeBboxMap,   siblingGroups, fontScaleRange, rlsc? } comp-match/types.ts:217 길 A · WEB-2 에디터 안 (같은 프로세스) ComponentBuilder.buildPageFromSignature() buildSignatureMappedComponentSheetJson() applySignatureContentReplacement() PageBuilder → 노드 변형 → makePageCreateCommandChunk makeComponentItem() → NodeBuilder → Page 길 B · IUI 워크벤치 (브라우저 IFRAME) postMessage('COMPONENT_THUMBNAIL', payload) preview iframe · ComponentThumbnailIframeHost buildComponentThumbnailPage → 오토핏 · 축소 LOADED { pageJson: makePageCreateCommandChunk(page) } composite API → webp / png 이미지 두 길이 같은 함수로 끝난다 — makePageCreateCommandChunk(page). 대치 결과가 문서로 굳는 지점은 하나뿐이다.
갈라지는 건 "엔진을 어디서 돌리나"뿐이다. 웹2는 에디터와 같은 JS 컨텍스트에서, iui는 unicorn-73380.preview.miricanvas.com의 iframe에서 돌린다 (CLI에서는 Playwright가 그 iframe을 헤드리스로 띄운다).

메시지 프로토콜

parent ↔ preview iframe comp-match/types.ts:210
parent → iframe   COMPONENT_THUMBNAIL_PING       // 살아 있니?
iframe → parent   COMPONENT_THUMBNAIL_READY      // 응
parent → iframe   COMPONENT_THUMBNAIL            // { sheetJson, nodeTextMap, nodeBboxMap,
                                              //   siblingGroups, fontScaleRange, rlsc? }
iframe → parent   COMPONENT_THUMBNAIL_LOADED     // { pageJson } ← 대치·재배치가 끝난 sheetJson
iframe → parent   COMPONENT_THUMBNAIL_ERROR      // { message, stack, componentStack }

LOADED가 돌려주는 pageJson노드 id는 원본과 같지만 위치·크기는 대치 결과 기준으로 갱신돼 있다. hover 강조 박스처럼 렌더 화면과 정렬돼야 하는 좌표는 원본 doc이 아니라 이걸 써야 한다. 다만 §8-①의 함정이 여기 붙어 있다.

대치 전후에 끼어드는 것들

단계하는 일코드
스타일 매핑현재 디자인의 색·폰트를 컴포넌트 노드 props에 통째로 덮어쓴다 buildMappedComponentSheetJson.ts:91
역할 폰트 주입Title/Subtitle/Description 역할별 폰트 키를 텍스트 노드에 적용 ApplyRoleFontsToTextNodes.ts
목록 스타일 정렬listStyle의 색·크기를 첫 런 값으로 단방향 전파(임시 처리) …SheetJson.ts:26
이미지 대치사진검색으로 고른 resourceKey를 shape.fill.resource에 꽂는다 replaceComponentImages.ts
박스 확장·병합글이 길어져 넘치면 텍스트 박스를 넓히고 연속 Description을 합친다 adjustTextBoundingBox.ts
루트 박스 재계산ComponentItem의 boundingBox를 자식 합집합으로 다시 맞춘다 adjustComponentBoundingBox.ts
ID 재발급sheetJson의 노드 id는 고정값이라 그대로 페이지에 붙이면 충돌한다 reassignNodeIds.ts
§8

발 걸리는 곳

01

렌더러가 돌려준 문서의 rotatedBoundingBox는 낡았다

대치·재배치가 끝나면 엔진은 boundingBox만 갱신하고 rotatedBoundingBox대치 전 값을 그대로 남긴다. 절대 좌표 계산에서 기본값(preferRotated: true)을 쓰면 루트 GroupItem에 붙은 낡은 값 때문에 문서 전체가 통째로 어긋난다 (실측: 텍스트 y가 792 대신 1057로 계산됨).

→ 렌더된 문서에서는 반드시 absBoxMap(doc) (= buildDocAbsBoxMap(doc, { preferRotated: false }))를 쓴다.

02

nodes 배열 순서로 무언가를 만들지 말 것

§5의 사고가 정확히 이것이었다. XML 경로에서는 우연히 맞아떨어져서 버그가 몇 년 숨어 있었다. 순서가 필요하면 rootNodeIdschildNodeIds.

검증 규칙도 이 축으로 걸려 있다 — validatePageJsonrootNodeIds·childNodeIds가 가리키는 id가 nodes에 실제로 있는지를 검사한다.

03

좌표는 부모 상대값이다

props.boundingBox.left를 캔버스 좌표로 착각하면 그룹 안 요소가 전부 어긋난다. §4의 누적 그림 참고. 겹침·넘침·정렬 판정은 전부 절대 박스로 해야 한다.

04

노드 id는 고정값이다

같은 컴포넌트를 한 페이지에 두 번 넣으면 id가 충돌한다. 페이지에 붙이기 직전 reassignNodeIds()로 새 id를 발급하고, contentData 안의 매핑 키도 함께 갈아 끼워야 한다 (remapContentDataMappingKeys).

05

배경 노드는 rootNodeIds 밖에 있다

페이지 배경은 backgroundNodeId로 따로 참조되고 rootNodeIds에는 안 들어간다. NodeBuilder배경 id를 루트 목록에서 걸러낸다. "모든 노드"를 순회한다고 생각하고 rootNodeIds만 돌면 배경이 빠진다.

§9

코드 지도

파일 이름을 누르면 GitHub 해당 줄로, 편집기를 누르면 로컬 VS Code로 열린다. main 배지가 붙은 파일은 현재 로컬 체크아웃 (feature/mordor)에 없어서 GitHub 링크만 있다.

타입 정의 — "sheetJson이 뭐냐"의 정답들

파일무엇편집기
engine/framework/…/commandCreateTypes.ts NodeData:8 · PageData:20 · CommandCreateDesign:35 — 정본 편집기
engine/framework/…/responseTypes.ts PageReadResponseData:6 — page.nodes가 정본이고 최상위 nodes는 deprecated 편집기
engine/converter/…/PageNodeName.ts SHEET·SHEETSIZE·TEMPLATE·BACKGROUND·GUIDELINES 노드 이름 상수, PageJson:24 편집기
iui/packages/color-mapper/src/types.ts Doc:10 · Node:21 · Data:31 — 차트 2.0·표까지 가장 넓게 모델링한 SSOT 편집기
iui/packages/color-mapper/src/recolor.ts SheetColor:40 · SheetBox:52 · SheetTextTree:59 · Sheet:220 — 의존성 0 이식용 편집기
iui/packages/comp-sign/src/node-labeler/types.ts node-labeler용 축약 Doc:1 — RotatedBoundingBoxangle까지 갖는 판 편집기
prisonbreak/…/type/pageJson.ts NodeType union(21종):277 · PageNode:305 · PageJson:314 편집기
api-kit/…/ComponentDetail.ts main componentSheetJsonFileUrl:17 · structureJsonFileUrl — 컴포넌트 저장 스키마(zod)
engine-util/…/adjustTextBoundingBox.ts main AdjustTextBoundingBox.SheetJson:22 — 변환 함수용 최소 구조

컨버터 — XML ⇄ sheetJson

파일 · 함수무엇편집기
XmlToJsonConverter.ts:159 convertSheetXmlsToPageReadResponseData() — 컨버팅 진입점. window에도 붙여 puppeteer에서 부를 수 있게 해 뒀다 편집기
XmlToJsonConverter.ts:514 sortItemJsonByLayerPriority() — XML Prioritynodes·rootNodeIds를 함께 정렬 편집기
XmlToJsonConverter.ts:663 assignNodesToPages()page.nodes를 채우는 자리 편집기
PageConverter.ts:610 convertPage()SHEETSIZE → width/height/boundingBox (:652, :695–711) 편집기
PageConverter.ts:370 addItemsToPage()rootNodeIds가 처음 채워지는 곳 편집기
PageJsonToXmlConverter.ts:10 역방향 — boundingBox에서 SHEETSIZE·TEMPLATE를 다시 만든다 편집기

엔진 — sheetJson ⇄ Page 객체

파일 · 함수무엇편집기
PageBuilder.ts:15 build() — sheetJson → Page. 배경 노드 분리, 유효하지 않은 노드 필터 편집기
NodeBuilder.ts:12 build(nodesData, rootNodeIds)flat 배열 → 트리. rootNodeIds가 없으면 nodes 순서로 폴백(:14) 편집기
makePageCreateCommandChunk.ts:7 Page → sheetJson. width/heightprops.boundingBox에서, rootNodeIdsgetChildren()에서 편집기
makeNodeCreateCommandChunks.ts:8 노드 트리 → flat 배열. parentNodeId: 'NONE'이 찍히는 자리(:34) 편집기

내용 대치 — web-2

파일 · 함수 전부 main무엇
text-visualization/ComponentBuilder.ts buildPageFromContentData():107 · buildPageFromSignature():127 · makeComponentItem():297 · buildPageFromSheetJson():367
text-visualization/buildMappedComponentSheetJson.ts 내용 대치 → 폰트 주입 → 스타일 매핑 → 이미지 대치 순서를 엮는 오케스트레이터:91
node/item/component/buildSignatureMappedSheetJson.ts parseSummaryTree(summary)applySignatureContentReplacement → 루트 박스 보정:16
node/item/component/applySignatureContentReplacement.ts PageBuilder → 노드 변형 → makePageCreateCommandChunk 왕복이 벌어지는 곳:60
…/ComponentItemV1AddCommandParamBuilder.ts buildContentMappedComponentSheetJson():27 — ContentData 경로의 대치
style_replacement/TemplateStyleMapper.ts getMappedStyles():46 — 현재 디자인 스타일을 컴포넌트에 이식
…/_components/TextVisualizationThumbnail.tsx 패널 썸네일 — componentSheetJsonComponentBuilderEditorThumbnailRenderer:221
text-visualization/EditorThumbnail.tsx Page 객체를 NodeViewFactory로 DOM 렌더:25

내용 대치 — iui

파일 · 함수무엇편집기
comp-sign/src/build-replacement.ts:109 buildContentReplacement()ThumbnailLoad 페이로드 생성. web-2도 이 함수를 import 한다 편집기
comp-sign/src/comp-match/types.ts:210 메시지 프로토콜 타입 — ThumbnailLoad:217 · ThumbnailLoaded:237 편집기
comp-sign/src/comp-match/docLayout.ts:22 buildDocAbsBoxMap() — 상대 → 절대 좌표. preferRotated 함정 주석 포함 편집기
src/comp-match/Thumbnail.tsx:389 preview iframe에 postMessage. iframe URL은 :45 편집기
scripts/comp-match-eval-render.ts renderViaPreview() — Playwright로 preview iframe을 헤드리스 구동, 렌더된 pageJson을 회수 편집기
scripts/comp-replace-render.ts 컴포넌트 1개 + markdown → 대치 → 렌더 → composite 이미지. --dump-rendered로 sheetJson 저장 편집기
src/style-mapper/render-page.ts requestComposite():8 · getCompositeResult():49 — sheetJson → 이미지 편집기
post-replace-relayout/relayout-core/metrics.ts 재배치 지표 + 좌표계 함정 문서화:15 · absBoxMap():309 편집기
post-replace-relayout/relayout-core/docFrame.ts 원안 문서를 렌더본 좌표계로 옮긴다 — 배율의 정본은 text.fontSizeScale:55 편집기

preview 렌더러 — web-2 feature/UNICORN-73380_

파일무엇
…/component-thumbnail/ComponentThumbnailIframeHost.tsx postMessage 핸들러. LOADED { pageJson: makePageCreateCommandChunk(page) }:203
…/component-thumbnail/buildComponentThumbnailPage.ts:183 sheetJson + nodeTextMap → 텍스트 대치 → 폰트/자간/행간 축소 → Page
…/component-thumbnail/ComponentThumbnailView.tsx NodeViewContextProvider renderingEnv='PREVIEW'로 실제 DOM 렌더
apps/…/pages/v2/design2/component-thumbnail.tsx iframe이 실제로 로드하는 라우트

파이프라인 — aippt-prisonbreak (MORDOR)

파일 · 함수무엇편집기
src/app/api/converter/route.ts XML → processor API 변환 배치. .json URL은 변환 없이 그대로 fetch(:139) — §5 사고의 뿌리 편집기
src/lib/component/utils/extractComponentNodes.ts 페이지 sheetJson에서 컴포넌트 노드만 잘라낸다. flatten(rootNodeIds) 순서로 순회:241 편집기
src/lib/component/utils/buildPageJson.ts 노드 배열 → 최소 sheetJson 껍데기:51 편집기
…/S3UploadValidator/utils/validatePageJson.ts BE 전달 전 골격·참조 무결성·nodeType 검증 — 불변 규칙의 실행 가능한 정의 편집기
…/body-component/lib/sheet-json-splitter.ts 페이지 sheetJson을 body / master 두 조각으로 분할:14 편집기
mordor-wiki · do-sheetjson-order-investigation.md §5 사고의 전문(원인·영향 22,666건·복구 계획). prisonbreak에서는 docs/ submodule 편집기
§10

직접 열어보기

타입 정의를 읽는 것보다 실제 문서 하나를 손에 쥐는 게 빠르다. iui 저장소에서:

miricanvas-iui — 문서 뽑기 · 그림 굽기 tsx · playwright 필요
# 1. 컴포넌트 sheetJson 원본을 파일로 (렌더 없음, 가장 빠름)
pnpm exec tsx scripts/dump-sheet-json.ts --id 6357697 --outdir out

# 2. markdown을 대치해 렌더 → 이미지 + 대치 후 sheetJson
pnpm exec tsx scripts/comp-replace-render.ts \
  --id 6357697 --text-file page.md \
  --dump-rendered out/ --dump-payload out/

# 3. 이미 있는 sheetJson 파일을 그림으로만 굽기 (브라우저 불필요)
pnpm exec tsx scripts/sheetjson-thumb.ts out/6357697_base.json --out thumb.webp

"가장 작은 sheetJson"이 보고 싶으면 aippt-prisonbreak/emptyPageJson.json — 노드 0개짜리 빈 1920×1080 페이지다. 여기에 노드를 하나씩 넣어 보면 구조가 몸에 붙는다.

지켜야 하는 불변 규칙

MORDOR가 BE로 문서를 넘기기 전에 실제로 검사하는 항목들이다. sheetJson을 만드는 코드를 새로 쓴다면 이 목록이 곧 체크리스트다.

  • id는 비어 있지 않은 문자열, width·height는 양수
  • rootNodeIds·nodes는 각각 최소 1개
  • 모든 노드는 id·parentNodeId·childNodeIds·data.nodeType·data.props.boundingBox를 갖는다
  • rootNodeIds의 모든 id가 nodes[].id에 실재한다
  • 모든 childNodeIds의 id가 nodes[].id에 실재한다
  • nodeType은 알려진 21종 안에 있다
  • TextItemprops.text를, TableItemprops.style·props.cellProps를 반드시 갖는다