📐

Pretext — DOM 없이 텍스트 높이를 측정하는 순수 JS 레이아웃 라이브러리

Canvas measureText()로 글자 너비를 가져오고, 이후 줄 계산은 순수 산술 연산만 — 레이아웃 리플로우 0

기존 방식의 문제: 레이아웃 리플로우

브라우저에서 텍스트 높이를 알아내는 전통적 방법:

// 방법 1: getBoundingClientRect
const rect = element.getBoundingClientRect();
const height = rect.height;

// 방법 2: offsetHeight
const height = element.offsetHeight;

두 방법 모두 레이아웃 리플로우(layout reflow)를 강제한다. 브라우저가 "이 요소의 크기가 얼마인지"를 계산하려면 전체 레이아웃 트리를 다시 계산해야 한다. DOM 요소를 화면에 배치하고, 크기를 계산하고, 다시 그리는 과정.

이게 한두 번이면 괜찮은데, 가상 스크롤 리스트에서 500~1,000개 항목의 높이를 미리 계산해야 한다면? 매번 리플로우가 발생하면 프레임이 뚝뚝 떨어진다.


설치

npm install @chenglou/pretext

import { prepare, layout } from '@chenglou/pretext'
// 리치 텍스트(인라인 스타일 혼합)가 필요하면:
import { ... } from '@chenglou/pretext/rich-inline'


Pretext의 접근: Canvas measureText()

Pretext는 DOM을 완전히 우회한다.

핵심 아이디어:
1. Canvas.measureText()로 각 글자의 정확한 너비를 폰트 엔진에서 직접 가져온다
2. 글자 너비를 캐싱한다
3. 이후 줄 바꿈 계산은 순수 산술 연산 — 글자 너비를 하나씩 더하면서 컨테이너 너비를 초과하면 줄을 바꾼다
4. DOM에 전혀 접근하지 않으므로 리플로우가 0

const prepared = prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')
const { height, lineCount } = layout(prepared, textWidth, 20)

prepare()가 Canvas로 글자 너비를 측정하는 단계 (무거움), layout()이 산술 연산으로 높이를 계산하는 단계 (매우 가벼움). prepare는 한 번만 하면 되고, layout은 컨테이너 크기가 바뀔 때마다 호출해도 0.09ms.


성능 비교

500개 텍스트 배치 기준:

단계 시간 설명
prepare() ~19ms Canvas measureText()로 글자 너비 측정 + 캐싱 (1회만)
layout() ~0.09ms 캐싱된 너비값으로 줄 계산 (산술 연산만)
DOM 기반 측정 수백ms getBoundingClientRect × 500개

layout()이 0.09ms라는 건 10,000번 호출해도 1ms 미만이라는 뜻. 가상 스크롤에서 스크롤할 때마다 높이를 재계산해도 성능 문제가 없다.


두 가지 사용 방식

1. 높이만 필요한 경우: prepare() + layout()

const prepared = prepare(text, '16px Inter')
const { height, lineCount } = layout(prepared, containerWidth, lineHeight)

쓸 수 있는 곳:

  • 가상화 리스트: 각 항목의 높이를 미리 계산해서 스크롤 위치를 정확히 유지

  • AI 생성 텍스트 오버플로 감지: 버튼·카드 안에 텍스트가 들어가는지 미리 확인

  • 반응형 레이아웃: 컨테이너 크기가 바뀔 때마다 텍스트 높이를 빠르게 재계산

2. 줄 단위 제어: layoutWithLines() / walkLineRanges() / layoutNextLine()

const { lines } = layoutWithLines(prepared, containerWidth, lineHeight)
lines.forEach(line => {
  // 각 줄의 텍스트·너비·높이를 직접 사용
  ctx.fillText(line.text, x, y)
  y += lineHeight
})

쓸 수 있는 곳:

  • Canvas/WebGL 렌더링: DOM 없이 직접 텍스트를 그릴 때

  • SVG 텍스트 배치: SVG에서 <text> 요소의 줄 바꿈 처리

  • 이미지 옆 텍스트 흐름: 줄마다 너비가 달라지는 레이아웃 (CSS float 같은 것을 JS로)

  • 서버사이드 렌더링: Node.js에서 Canvas 모듈로 텍스트 레이아웃 계산


다국어·복잡 문자 지원

Pretext가 인상적인 부분: 이모지, 한중일(CJK), 아랍어(RTL)까지 지원한다.

// 다국어 + 이모지 혼합
prepare('AGI 春天到了. بدأت الرحلة 🚀', '16px Inter')

  • 이모지: Grapheme Cluster 단위로 올바르게 분리 (🏳️‍🌈 같은 복합 이모지도)

  • CJK: 한중일 문자의 너비를 정확히 계산 (전각 처리)

  • 아랍어/히브리어: RTL(Right-to-Left) 텍스트의 양방향(BiDi) 처리


아키텍처 핵심

[입력: 텍스트 + 폰트 스펙]
        ↓
[prepare()]
  Canvas.measureText() → 각 글자 너비 측정
  Grapheme segmentation → 복합 문자(이모지 등) 올바르게 분리
  결과: PreparedText (글자별 너비 배열 + 메타데이터)
        ↓ (캐싱)
[layout() / layoutWithLines()]
  PreparedText + 컨테이너 너비 + 줄 높이
  → 순수 산술 연산: 너비를 누적하다가 초과하면 줄 바꿈
  → DOM 접근 0, 리플로우 0
  결과: { height, lineCount } 또는 { lines[] }

핵심 트릭: Canvas.measureText()는 DOM 레이아웃을 트리거하지 않는다. Canvas 2D 컨텍스트의 폰트 엔진에서 오프스크린으로 글자 크기를 계산하기 때문. 이게 전체 아이디어의 근간.


주의할 점 (커뮤니티 의견)

Canvas measureText()의 정확성 한계

Canvas measureText()가 반환하는 글자 너비와 실제 DOM에서 렌더링했을 때의 너비는 완벽히 일치하지 않을 수 있다.

차이가 나는 경우:

  • 폰트 힌팅(hinting): DOM의 텍스트 렌더러와 Canvas의 텍스트 렌더러가 서브픽셀 수준에서 다를 수 있음

  • letter-spacing, word-spacing: CSS에서 적용한 글자 간격이 Canvas에는 반영 안 됨

  • font-feature-settings: OpenType 합자(ligature) 등이 Canvas에서 다르게 처리될 수 있음

  • 줄 높이(line-height): CSS의 line-height와 Canvas의 textBaseline이 다른 기준

결론: 100% 픽셀 퍼펙트가 아닌, "충분히 정확한(good enough)" 측정이라는 점을 이해하고 써야 한다. 가상 스크롤처럼 몇 픽셀 차이가 크게 문제되지 않는 용도에는 완벽. CSS의 복잡한 타이포그래피 속성에 정밀하게 맞춰야 하는 경우에는 오차가 날 수 있다.


만든 사람: chenglou

Claude Code에서 이름이 좀 알려진 인물. React의 초기 기여자이자, Relay(Facebook의 GraphQL 클라이언트), Reason(OCaml → JavaScript 컴파일러)의 핵심 개발자. 현재 Anthropic에서 근무하며, 이전에도 프로그래밍 언어·컴파일러 쪽 작업을 많이 했다.

Pretext의 코드 품질이 높은 이유: 컴파일러를 만들던 사람이 텍스트 레이아웃이라는 "파싱 + 최적화" 문제에 접근한 결과.

핵심 포인트

1

prepare(text, font): Canvas measureText()로 각 글자 너비를 측정 + 캐싱 (1회만)

2

layout(prepared, width, lineHeight): 캐싱된 너비로 줄 계산 — 순수 산술, DOM 접근 0, 리플로우 0

3

성능: prepare ~19ms(500개), layout ~0.09ms(500개) — 10,000번 호출해도 1ms 미만

4

다국어 지원: 이모지(Grapheme Cluster), CJK(전각), 아랍어(RTL/BiDi) 처리

5

주의: Canvas measureText() ≠ DOM 렌더링 결과. letter-spacing, 합자 등에서 오차 가능. "충분히 정확한" 측정

사용 사례

가상 스크롤 리스트에서 항목 높이 미리 계산 AI 생성 텍스트가 버튼·카드 안에 들어가는지 오버플로 감지 Canvas/WebGL/SVG에서 DOM 없이 직접 텍스트 렌더링