measureText로 계산하는 순수 TypeScript 라이브러리. RTL·이모지·CJK·금칙처리까지 국제화 함정을 거의 다 해결했다. ⭐48.1k · 런타임 의존성 0 · TypeScript 91.2% (저장소: chenglou/pretext · MIT · @chenglou/pretext v0.0.8 · React Motion 제작자 Cheng Lou)pretext는 DOM에 실제로 텍스트를 그리지 않고도 "그렸을 때의 줄 수와 높이"를 계산하는 멀티라인 텍스트 측정·배치 라이브러리다. 브라우저의 Canvas measureText API로 글자 폭을 재고, 그 폭들을 산술 계산해서 레이아웃을 결정한다. 채팅 말풍선 크기, 가상 스크롤 행 높이, 텍스트 에디터 줄바꿈처럼 "측정이 수백 번 반복되는" 상황을 위해 만들어졌다.
지금까지의 텍스트 측정은 보이지 않는 HTML 요소를 만들어 실제로 그려보고(시착), 높이를 재고(거울 확인), 지우는(탈의) 방식이었다. 500벌이면 종일 걸린다.
pretext는 가격표 목록(글자 폭 메트릭)만 먼저 한 번 뽑아두고, 그 다음부터는 산술 계산만으로 결과를 낸다. 시착 없이, 한 번에. 핵심은 "비싼 측정을 한 번만 하고(prepare), 싼 계산을 자주 반복(layout)"하는 2단계 분리다.
제작자가 Cheng Lou — React Motion(React 애니메이션의 고전)을 만든 개발자다. 이름값에 더해, "DOM 측정은 느리다"는 웹 개발의 오래된 통증을 정면으로 푼 점이 화제가 됐다. 게다가 레포 안에 RESEARCH.md·CLAUDE.md·AGENTS.md로 "왜 이렇게 만들었는가"를 솔직하게 공개해, 라이브러리 설계 노하우 교과서로도 읽힌다.
숨은 요소를 만들어 텍스트를 넣고 → 브라우저가 그릴 때까지 기다리고 → getBoundingClientRect()로 높이를 재고 → 지운다. 한 번이면 빠르지만, 수백 번 반복하면 강제 동기 레이아웃(리플로우)이 누적돼 60FPS(프레임당 16ms)를 깬다. 화면이 끊긴다.
Canvas 2D의 measureText는 실제로 그리지 않고 폰트 엔진에 직접 물어 글자 폭을 돌려준다. DOM을 건드리지 않으니 리플로우가 없다. pretext는 이걸로 측정을 한 번 캐시한 뒤, 줄바꿈 계산은 순수 산술로 처리한다. 실제 benchmarks/chrome.json 기준 DOM interleaved 방식 43.25ms에서 layout() 0.144ms로 약 300배 차이를 보인다.
| 비교 대상 | pretext의 위치 |
|---|---|
| getBoundingClientRect() | 정확하지만 리플로우 발생. 측정이 소수면 이게 더 간단. pretext는 대량 반복 측정에서만 가치 |
| opentype.js / fontkit | 폰트 파일을 직접 파싱(무겁다). pretext는 브라우저 폰트 엔진에 위임해 가볍고(0 의존성) 시스템 폰트와 일치 |
| CSS만으로 처리 | 높이를 "미리" 알 수 없음. 가상 스크롤은 그리기 전에 높이가 필요 → pretext가 그 빈틈을 채운다 |
| 직접 measureText 짜기 | 가능하지만 RTL·이모지·CJK·금칙처리가 전부 함정. pretext는 이걸 15KB 안에 이미 풀어놓음 |
※ 단 두 언어. 빌드 도구·예제까지 거의 전부 TypeScript로 통일돼 있다.
| 항목 | 값 | 의미 |
|---|---|---|
| 패키지명 | @chenglou/pretext | npm 배포명. npm install @chenglou/pretext |
| 버전 | 0.0.8 | 아직 초기 버전 (API가 바뀔 수 있음) |
| type | module | 순수 ESM. CommonJS require 미지원 |
| dependencies | (없음) | 런타임 의존성 0 — 브라우저 내장 API에만 의존 |
| main / types | ./dist/layout.js | 기본 진입점. 타입은 ./dist/layout.d.ts |
| 서브 진입점 | ./rich-inline | 리치텍스트 인라인 플로우 헬퍼(별도 export) |
| 도구 | 역할 |
|---|---|
| TypeScript 6.0.2 | 전체 코드 타입체크 + 빌드 |
| Bun (@types/bun) | 테스트/스크립트 실행 런타임 |
| oxlint + tsgolint | Rust 기반 초고속 린터 (oxlint-tsgolint로 타입 인지 린트) |
| knip | 미사용 export/의존성 탐지 |
| marked | 문서(.md) → HTML 변환(데모 페이지 빌드) |
스크립트는 accuracy-check, benchmark-check, corpus-check, build:package, site:build 등 20개 이상 — "정확도 검증·벤치마크·말뭉치 검증"을 자동화한 점이 이 레포의 특징이다.
prepare()(측정)를 다시 하고, 창 크기가 바뀔 때마다 호출되는 layout()(줄바꿈 계산)은 산술만 하게 만든다. RESEARCH.md의 표현: "비싼 텍스트 작업은 prepare()에서 한 번, layout()은 산술만(arithmetic-only)."보통은 [{width:10, kind:'word'}, {width:5, kind:'space'}, ...]처럼 객체 배열로 짠다. 읽기엔 명확하지만 같은 종류 데이터가 메모리에 흩어져 CPU 캐시 효율이 나쁘다.
widths:[10,5,...], kinds:['word','space',...]처럼 컬럼별로 몰아넣는다. 폭만 순회할 때 다른 데이터는 건드리지 않아 CPU 캐시 적중률이 폭증한다. 게임 엔진·데이터베이스에서 쓰는 패턴이다.
layout()은 미리 계산된 숫자 배열을 읽고 누적 합만 갱신하며, 새 객체·새 문자열을 만들지 않는다. walkLineRanges()처럼 줄 문자열조차 만들지 않는 저수준 API도 제공한다.Chrome·Firefox는 macOS에서 작은 크기 이모지를 Canvas상에서 DOM보다 2~3픽셀 더 넓게 잰다. pretext는 룩업 테이블을 박아두는 대신 폰트별 보정 계수를 런타임에 감지해 캐시한다. 환경이 바뀌어도 견디는 설계다.
아랍어·미얀마어 같은 복잡한 스크립트는 복잡한 폭 모델 대신 의미 단위 전처리(구두점 클러스터 병합, 반복 기호 표시 등)로 푼다. RESEARCH.md의 결론: "알고리즘적 정교함보다 브라우저에 근거를 두는 단순함이 더 가치 있었다."
읽는 순서 추천: README.md(무엇을) → src/layout.ts(공개 API의 형태) → src/measurement.ts(폭을 어떻게 재는가) → src/bidi.ts(가장 어려운 함정) → RESEARCH.md(왜 이렇게 했는가). 함정 하나당 파일 하나로 나뉜 구조라, 관심 있는 주제만 골라 읽기 좋다.
배울 것: DOM 리플로우 없이 글자 폭을 얻는 법. ctx.measureText(str).width 한 줄이 어떻게 "그리기"를 우회하는지, 그리고 그 결과가 실제 렌더링과 왜/얼마나 다른지(서브픽셀, 폰트별 보정).
배울 것: "비싼 일은 한 번, 싼 일은 N번"이라는 보편 패턴. 어떤 일을 prepare로 밀어내고 layout은 산술만 남기는지를 보면, 자기 코드의 병목도 같은 눈으로 보게 된다.
배울 것: 객체 배열 대신 병렬 배열로 CPU 캐시를 챙기는 법, 제로 할당으로 GC 부담을 없애는 법. JS에서도 이런 저수준 최적화가 의미 있다는 실증.
배울 것: 공백으로 단어를 나누면 왜 망하는지(CJK는 공백이 없음), Intl.Segmenter로 언어별 분절을 어떻게 위임하는지, 그래핌(이모지 결합)·금칙처리·RTL이 각각 무슨 문제인지. bidi.ts는 UAX #9 알고리즘의 실전 구현 예제다.
배울 것: accuracy/ + corpora/ + CI로 "계산값이 실제 DOM 측정과 일치하는가"를 자동 검증하는 구조. 측정 라이브러리답게 "맞다는 걸 어떻게 증명하나"가 코드에 박혀 있다.
배울 것: RESEARCH.md가 "무엇을 시도했고 왜 버렸는가"를 솔직히 기록한 사례. 좋은 오픈소스가 코드뿐 아니라 맥락(why)을 어떻게 남기는지 배울 수 있다.
pretext는 무거운 라이브러리가 아니라 브라우저 내장 API에 얹히는 15KB 코드다. 따라서 RAM·CPU 같은 하드웨어 사양보다 실행 환경이 특정 API를 지원하는가가 핵심 요구사항이다.
| 요구사항 | 필요 이유 | 없으면 |
|---|---|---|
| Intl.Segmenter | 언어별 단어/글자 분절(필수) | 동작 불가 — 폴리필 필요 |
| Canvas 2D measureText | 글자 폭 측정의 근간(필수) | 동작 불가 |
| ESM 지원 번들러/런타임 | type:module, ESM 전용 배포 | CommonJS require 불가 |
| 실제 폰트 로드 | 측정 대상 폰트가 로드돼 있어야 정확 | 폴백 폰트로 잘못 측정 |
| 환경 | 지원 |
|---|---|
| 최신 브라우저 (Chrome/Edge/Firefox/Safari) | Intl.Segmenter·Canvas 모두 내장 → 그대로 동작 |
| Node.js (서버 측 계산) | Canvas 2D가 기본 없음 → node-canvas 등 폴리필 필요 |
| 구형 브라우저 | Intl.Segmenter 미지원 가능 → 폴리필 또는 미지원 |
README가 명시: 정확도를 위해서는 system-ui 폰트가 macOS에서 안전하지 않다. 구체적 폰트(예: 명시적 폰트 패밀리)를 지정하는 편이 측정-렌더 일치에 유리하다.
브라우저 콘솔에서 DOM을 건드리지 않고 글자 폭을 직접 재본다. 이게 pretext의 출발점이다.
// 브라우저 콘솔에 그대로 입력
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = '16px sans-serif';
ctx.measureText('테스트 Hello 👨👩👧👦').width;
// → 숫자 폭이 즉시 출력. DOM에는 아무 흔적도 없다.
Chrome DevTools의 Performance 탭에서 getBoundingClientRect()를 500번 부르는 코드의 타임라인을 녹화한다. 보라색 "Recalculate Style / Layout" 막대가 쌓이는 걸 직접 본다. 이게 pretext가 없애려는 비용이다.
pretext의 핵심을 100줄로 직접 구현해본다(영어 한정 단순판).
줄 수 × lineHeight로 높이 → ⑤ 실제 DOM 측정값과 오차 비교. "CJK·이모지를 넣으면 왜 깨지는가"까지 관찰하면 §4의 함정이 체감된다.npm i @chenglou/pretext 후, 1000개 채팅 메시지 가상 리스트를 만든다. 각 메시지 높이를 prepare()→layout()로 계산해 스크롤 위치를 정확히 잡고, 창 리사이즈 때 layout()만 다시 호출해 부드럽게 재정렬되는지 확인한다.
// 개념 코드 (API 형태 예시)
import { prepare, layout } from '@chenglou/pretext';
const prepared = prepare(message, { font: '14px Pretendard' });
const { height, lineCount } = layout(prepared, 360, 22);
// 리사이즈 시: 같은 prepared로 layout()만 다시 호출
아랍어("مرحبا")와 영어가 섞인 문장, 가족 이모지(👨👩👧👦), 일본어 금칙 문자(。」)를 같은 측정기에 넣어 결과가 어떻게 틀어지는지 재현한다. 그 후 레포의 RESEARCH.md를 읽고 pretext가 각 함정을 어떻게 풀었는지(bidi/Segmenter/전처리) 대조한다.
corpora/의 다국어 말뭉치를 가져다 자기 계산기에 돌려 오차율을 표로 만들어본다.| 요일 | 학습 내용 | 실습 |
|---|---|---|
| 월 | 브라우저 렌더링 파이프라인 | Style→Layout→Paint 단계 이해 |
| 화 | 리플로우(강제 동기 레이아웃) | 레이아웃 스래싱 직접 재현 |
| 수 | Canvas 2D 기본 | measureText / TextMetrics 속성 탐구 |
| 목 | getBoundingClientRect 비용 | DevTools로 측정 비용 녹화 |
| 금 | pretext README 정독 | 공개 API 형태 파악 |
| 요일 | 학습 내용 | 실습 |
|---|---|---|
| 월 | 그리디 줄바꿈(word wrap) | 간이 줄바꿈기 구현 |
| 화 | prepare/layout 2단계 분리 | src/layout.ts 읽기 |
| 수 | letter-spacing / tab-size 처리 | 옵션이 폭에 미치는 영향 실험 |
| 목 | white-space: pre-wrap vs normal | 공백·하드브레이크 처리 비교 |
| 금 | soft hyphen / keep-all | 줄바꿈 경계 옵션 실습 |
| 요일 | 학습 내용 | 실습 |
|---|---|---|
| 월 | Intl.Segmenter | 언어별 word/grapheme 분절 실습 |
| 화 | 그래핌 클러스터(이모지 결합) | 👨👩👧👦가 1글자임을 검증 |
| 수 | CJK 금칙처리(Kinsoku) | 。」가 줄 시작에 못 오게 처리 |
| 목 | 양방향 텍스트(UAX #9) | src/bidi.ts 읽기 + RTL 혼용 실험 |
| 금 | 아랍어/미얀마어 전처리 | 전처리 vs 런타임 보정 비교 |
| 요일 | 학습 내용 | 실습 |
|---|---|---|
| 월 | 데이터 지향 설계(SoA) | 객체배열 vs 병렬배열 벤치 |
| 화 | 제로 할당 / GC 압박 | 핫패스에서 객체 생성 제거 |
| 수 | 벤치마크 작성 | benchmarks/ 구조 분석 |
| 목 | 정확도 검증 자동화 | accuracy/ + corpora/ 읽기 |
| 금 | 종합: 가상 스크롤 통합 | pretext로 실제 리스트 구현 |
| 키워드 | 설명 |
|---|---|
| measureText | Canvas 2D 메서드. 실제로 그리지 않고 현재 폰트로 그렸을 때의 글자 폭을 돌려준다. pretext의 측정 근간 |
| Reflow / Layout | DOM 변경 시 영향받는 요소들의 위치·크기를 다시 계산하는 과정. 반복되면 비싸다 — pretext가 피하려는 비용 |
| prepare() | 비싼 측정을 한 번만 수행해 메트릭을 캐시하는 1단계 함수 |
| layout() | 캐시된 메트릭으로 maxWidth·lineHeight를 받아 줄 수/높이를 산술 계산하는 2단계 함수 |
| prepareWithSegments() | 수동 줄 배치를 위해 더 풍부한 세그먼트 구조를 반환하는 변형 |
| walkLineRanges() | 줄 문자열을 만들지 않고 줄 범위만 훑는 저수준 워커(제로 할당) |
| rich-inline | 서로 다른 스타일의 인라인 조각을 한 흐름으로 배치하는 리치텍스트 헬퍼(별도 export) |
| Intl.Segmenter | 언어별 규칙으로 텍스트를 단어/문장/글자로 분절하는 표준 API. CJK·태국어 등을 올바르게 자른다 |
| Grapheme | 사람 눈에 한 글자로 보이는 단위. 가족 이모지처럼 여러 코드포인트가 합쳐져도 1 그래핌 |
| 금칙처리(Kinsoku) | CJK에서 줄 시작/끝에 오면 안 되는 문자(。」 등)를 관리하는 줄바꿈 규칙 |
| Bidi (UAX #9) | LTR/RTL이 섞인 양방향 텍스트의 배치 순서를 정하는 유니코드 표준 알고리즘. bidi.ts가 구현 |
| SoA (병렬 배열) | Structure of Arrays. 컬럼별로 같은 타입을 몰아넣어 CPU 캐시 적중률을 높이는 데이터 배치 |
| 제로 할당 핫패스 | 자주 도는 코드에서 새 객체를 만들지 않아 GC 비용을 없애는 기법 |
| pre-wrap / keep-all | 공백·줄바꿈 보존(pre-wrap), 단어 안 끊기(keep-all) 등 CSS와 동일한 줄바꿈 옵션 |
| letter-spacing | 글자 사이 간격(픽셀 숫자). pretext가 폭 계산에 반영하는 옵션 |
| 폰트 보정 계수 | macOS 이모지 폭 과대측정 등 환경별 오차를 런타임에 감지해 캐시하는 값 |