재배치 뷰어의 그림 네 장은 남의 서버가 그려 주던 것이었다. 그 배포가 삭제되면서 화면이 통째로 죽었다. 미리캔버스 엔진 렌더러를 저장소 안으로 들여 같은 화면을 스스로 그리게 만든 과정 — 무엇이 어디에 있었고, 무엇에 걸렸고, 무엇이 아직 남았는지.
unicorn-73380 preview 서버의 iframe 이 그렸다. 그 배포가 삭제됐고, 살아 있을 때도 매일 20:00 에 잠들어 사람이 깨워야 했다.miricanvas-editor-2 에 있다.PageRootView 로 페이지 한 장을 그리는 iframe 전용 페이지를 만들었다. 프로토콜은 예전 그대로라 뷰어는 주소만 바뀌었다.paper-esm 의 CJS 사본 · Page 객체를 되찾는 길 · 렌더가 텍스트 박스를 안 고친다는 사실(재검증이 조용히 무력해질 뻔했다).nodeTextMap)만은 못 옮겼다 — editor 패키지가 공개 엔트리로 안 내보낸다. 케이스를 굽는 배치는 그래서 아직 preview 를 쓴다.
/post-replace-relayout 은 케이스 하나를 네 장(원안 · 대치 직후 ·
재배치 결과 · 정답) 나란히 놓고 견주는 화면이다. 그 네 장은 합성 스냅샷(*.webp)이
아니라 엔진이 그 자리에서 그린 화면이다 — 재배치 결과(After)는 커밋된
스냅샷이 아예 없어서, 라이브로 그리지 않으면 볼 방법이 없다.
문제는 그리는 쪽이 우리 것이 아니었다는 데 있다. design-2 의 preview 배포
한 곳에 iframe 을 걸고 sheetJson 을 postMessage 로 던지면, 그쪽이 그려서
돌려주는 구조였다. 그 배포가 삭제되자 화면 전체가 「preview 서버 응답 없음」이 됐다.
여기서 가장 헷갈리는 대목. iui 저장소는 이미 엔진을 쓰고 있었다.
src/text-tight-box/compute.ts 가 글자 영역(tight bounding box)을 재려고
엔진 세 패키지를 import 한다. 그런데도 그림은 남의 서버가 그렸다. 왜?
엔진이 층으로 갈려 있기 때문이다. 있던 것은 「재는 부품」이고, 「그리는 뷰」는 한 층 위에 있다.
compute.ts) · 문서 만들기(PageBuilder) ·
직렬화(makePageCreateCommandChunk). DOM 은 필요하지만 뷰는 필요 없다.PageRootView 인가
editor-2 가 공개 엔트리로 내보내는 컴포넌트 중 「pageJson 한 장을 그리는 것이
존재 이유」인 것이 이것뿐이다. web-2 원본 주석에 "합성기에서 사용하는
페이지루트" 라고 적혀 있다 — 합성 서버가 썸네일을 구울 때 쓰던 바로 그 컴포넌트다.
뷰 등록 · paper 초기화 · API 클라이언트 초기화 · 폰트를 포함한 리소스 선로딩을
전부 안에서 마친 뒤에 그린다. 우리가 줄 것은 pageData 와
apiClientConfig 둘뿐이다.
iframe 전용 페이지 하나다. vite 의 두 번째 엔트리로 잡아 두어서 앱 번들과 섞이지 않는다 — 앱 라우트는 이 코드를 import 하지 않고, 브라우저가 iframe 을 열 때만 엔진이 로드된다.
| 파일 | 하는 일 |
|---|---|
| doc-render/index.html | 엔트리 HTML. iframe 의 src 가 이 파일이다 |
| doc-render/main.tsx | createRoot. StrictMode 를 안 쓴다 — 엔진이 전역 싱글턴이라 effect 이중 실행이 초기화를 두 번 태운다 |
| doc-render/DocRenderHost.tsx | 프로토콜(PING/READY/LOADED) + PageRootView 로 렌더 + 글자 영역 실측 회신 |
| doc-render/theme.ts | styled-components 테마. 엔진 안쪽이 theme.token.* 을 읽어서 없으면 그 자리에서 터진다 |
| doc-render/paperEsmShim.ts | paper-esm 을 진짜 ESM 으로 감싸는 겉옷 — 없으면 아무것도 안 그려진다(§6) |
IFRAME_URL), 「서버 실행」 링크를 걷어내고, 부팅 대기 예산을 늘렸다(엔진을 iframe 마다 새로 띄우므로).pageJson 대신 tightBoxes 에서 읽는다(§6).paper-esm alias 두 줄 + optimizeDeps.include.
프로토콜은 예전 것을 그대로 뒀다. 삭제된 preview 페이지의 원본
(web-2 브랜치 feature/UNICORN-73380_)을 읽어 규격을 그대로 옮겼기 때문에,
뷰어 쪽은 상수 두 줄(주소·오리진) 말고는 손댈 것이 없었다.
nodeTextMap 을 비워 보낸다는 것이 중요하다. 뷰어가 보내는
문서는 이미 대치가 끝난 것이라 넣을 글이 없다. 그리고 이 호스트는 애초에 내용 대치를
하지 않는다(§8).
// 1) 메시지를 받아 문서를 state 에 담는다 if (data.type === 'COMPONENT_THUMBNAIL_PING') postToParent({ type: 'COMPONENT_THUMBNAIL_READY' }); if (data.type === 'COMPONENT_THUMBNAIL') setDoc(load.sheetJson); // 2) 그리는 것은 이 한 덩어리가 전부다 — 뷰 등록·paper·리소스 선로딩은 안에서 끝난다 <ThemeProvider theme={DS_THEME}> <PageRootView pageData={doc} apiClientConfig={{ baseURL: 'https://api-staging.miricanvas.com', domain: '', language: 'ko', withCredentials: true, isApp: false }} zoomScale={Math.min(size.width / doc.width, size.height / doc.height)} watermarkPolicy='NONE' isViewModelEnabledArea // ← Page 객체를 되찾으려고 켠다 (§6-02) onViewLoaded={onViewLoaded} /> </ThemeProvider> // 3) 다 그리면 두 가지를 돌려준다 postToParent({ type: 'COMPONENT_THUMBNAIL_LOADED', pageJson: makePageCreateCommandChunk(page), // 엔진이 확정한 문서 tightBoxes: tightBoxesOf(doc), // 글자 영역 실측 (§6-03) });
tightBoxesOf 는 새로 만든 계산기가 아니다. 이 저장소가 이미
iui.text_tight_boxes 를 구울 때 쓰는 그 함수
(src/text-tight-box/compute.ts 의 computeTightBoxesForDoc)를
그대로 부른다 — 좌표 규약(프레임 원점 기준)까지 같아서 변환이 없다.
build: { rollupOptions: { input: {
main: 'index.html',
docRender: 'src/post-replace-relayout/doc-render/index.html', // 출력 경로 = 소스 경로 → dev/build URL 이 같다
}}},
resolve: { alias: [
{ find: /^@miri-unicorn\/paper-esm$/, replacement: '…/doc-render/paperEsmShim.ts' }, // §6-01
{ find: /^paper-esm-cjs$/, replacement: '…/paper-esm/dist/paper-esm.js' },
]},
optimizeDeps: { include: ['@miri-unicorn/miricanvas-editor-2/page-root'] } // 재최적화로 iframe 이 리로드되지 않게
「패키지 받아서 컴포넌트 하나 붙이면 끝」이 아니었다. 실제로 막힌 곳은 셋이고, 셋 다 증상만 보고는 원인을 알 수 없는 종류였다.
paper-esm 은 이름과 달리 CJS 다
dist/paper-esm.js 에는 export 가 하나도 없다
(module.exports = paper). 그래서 번들러가 CJS→ESM 변환을 끼우는데, 그 변환은
원본의 속성을 getter 로만 복사한 사본을 만든다.
하필 paper.js 의 PaperScope 는 프로토타입에 자기 자신을 가리키는
paper 를 달고 있다. 그래서 엔진이 하는
const { paper } = await import('@miri-unicorn/paper-esm') 이
원본이 아니라 사본을 집어 온다. 그 사본은 project 가 getter 뿐이라
pathfinder 초기화의 paperInstance.setup(...) 이 이렇게 터진다.
[pathfinder >>hahaha>>] SVG 처리 최적화 시스템 초기화 실패: Cannot set property project of [object Object] which has only a getter
그러면 initializePaperManager() 가 거절되고 isPaperInitialized 가
영영 false 로 남는다 — 화면은 하얗고 LOADED 도 안 온다. web-2 는 번들러가
달라서 드러나지 않던 문제다.
처방 — default 임포트는 사본이 아니라 원본을 준다. 그것을
paper 라는 이름으로 다시 내보내는 겉옷을 두고
(paperEsmShim.ts), resolve.alias 로 갈아끼운다.
Page 객체를 되찾을 수 없다
회신할 pageJson 을 만들려면 렌더가 끝난 Page 객체가 필요한데,
PageRootView 는 그것을 자기 안에서 만든다. 되찾는 길을 둘 재 봤다.
| 길 | 결과 |
|---|---|
| resolveNodeManager().getNodeById(id) | 페이지도 자식 노드도 전부 undefined — 이 등록부는 디자인(문서 세션)을 거쳐 만든 노드만 담는다 |
| resolveViewModelManager() .getMountedPageViewModels() | 페이지 뷰모델 1개, node 가 우리가 보낸 doc 의 id 를 그대로 갖고 있다 채택 |
처방 — isViewModelEnabledArea 를 켠다(기본 꺼짐). 그 스위치가 뷰모델
등록을 켜고, 등록된 뷰모델이 노드를 들고 있다. 그림 자체는 달라지지 않는다.
재검증(「렌더 재검증」 토글)은 「폭을 열었더니 세 줄이 두 줄이 됐나」를 묻는다.
예전 설계는 그 답을 LOADED.pageJson 의 박스 높이에서 읽었다.
그런데 실제로 재 보니 렌더를 거쳐도 텍스트 노드의 boundingBox 는
문서에 적힌 그대로 돌아온다.
확인 방법은 단순하다 — 텍스트 하나의 폭을 60% 로 줄여 넣고 돌려받은 문서를 비교했다.
| 보낸 문서 | 박스 폭 | pageJson 박스 높이 | 글자 영역(tight) 높이 |
|---|---|---|---|
| 원래대로 | 548.4 | 100.77 | 80.17 |
| 폭만 60% 로 | 329.0 | 100.77 ← 그대로 | 122.16 ← +41.99 |
오른쪽 +41.99px 는 fontSize 29.33 × fontSizeScale 1.4314 ≈ 42.0 —
정확히 한 줄이다. 왼쪽은 한 자리도 안 움직였다.
pageJson 으로 재검증하면 언제나 「예측대로」라고 답한다.
틀렸다고 말해 주는 일이 영영 없으므로 조용히 무력해지는 종류의 고장이다.
처방 — 글자 영역을 직접 잰다. 이 저장소가 이미 tight box 를 구울 때
쓰는 엔진 계산기를 그 자리에서 부르고, 결과를 LOADED.tightBoxes 로 실어 준다.
덤으로 CLI 포트와 같은 자가 됐다 — 예전에는 뷰어가 박스 높이를, CLI 가
glyph extent 를 써서 둘이 갈렸다.
아래는 /post-replace-relayout 이 케이스 하나를 그리는 모양이다(케이스
4223631). 네 장 전부 로컬 렌더 호스트가 그린 것이고, 점선은
뷰어가 그 위에 얹는 판정 박스다.
같은 케이스를 구워 둔 값(헤드리스 크롬 배치)과 브라우저에서 실시간으로 잰 값으로 비교했다. 텍스트 세 노드 전부 폭은 소수점까지 일치하고 높이만 1px 미만 차이다(같은 문서 안 전역 스타일 차이로 보인다). 재검증이 묻는 것은 줄 하나 몫(≈42px)의 차분이라 이 정도 차이는 판정을 못 바꾼다.
| 노드 | 구워 둔 값 (tight/rendered.json) | 렌더 호스트 실측 | 차이 |
|---|---|---|---|
| a725d14c | 507.293 × 79.512 | 507.2 × 80.17 | +0.66 |
| 36d62677 | 273.040 × 37.324 | 273.0 × 37.99 | +0.67 |
| 9c29a74f | 446.120 × 121.446 | 446.1 × 122.46 | +1.01 |
리소스(폰트·이미지·도형)는 api-staging.miricanvas.com 에서 받는다. 브라우저에서
직접 부를 수 있는지가 관건이었는데, local.miricanvas.com:3000 오리진에 대해
비로그인 200 + CORS 허용인 것을 확인했다.
| 엔드포인트 | 무엇 | 결과 |
|---|---|---|
| /process/api/p/font | 폰트 메타 → 파일 URL | 200 · ACAO 허용 |
| /designresource/api/p/designresources/resources | 이미지·도형 리소스 | 200 · ACAO 허용 |
| file-staging.miricanvas.com | 리소스 파일 본체 | ACAO: * |
⚠ localhost 로는 안 된다 — 오리진이 허용 목록에 없다. 반드시 local.miricanvas.com:3000 으로 연다.
nodeTextMap) — 마크다운을 텍스트 노드에 흘려 넣는 코드는
editor 패키지의 비공개 모듈이라 못 부른다.
케이스를 굽는 배치(batch/addTestCase.ts ·
batch/importCase.ts)는 「원본 sheetJson + 대치할 글」을 렌더러에 태워
rendered.json 을 만든다. 그 대치가 이 호스트에 없어서 배치는 아직
preview 페이지를 몬다. 뷰어는 완전히 독립했고, 코퍼스는 이미 구워져 있으니
보는 데는 지장이 없다.
pnpm dev — dev 서버 하나면 된다. 깨울 서버도, 기다릴 서버도 없다.https://local.miricanvas.com:3000/post-replace-relayout 을 연다. (localhost 는 리소스 API 가 막는다)/src/post-replace-relayout/doc-render/index.html 을 연다 — waiting: sheetJson 대기 가 뜨면 정상이다.