문서/소개

GitHub에서 원문 보기

Introduction

semantic-wrap이란?

학습된 모델과 실제 렌더링 결과를 바탕으로 줄바꿈 위치를 선택하는 JavaScript 라이브러리입니다. 더 적합한 결과가 있을 때만 해당 위치에 <br>을 삽입합니다.

사용하는 모델과 선택 방식에 따라 줄바꿈 기준을 바꿀 수 있습니다. 한국어 문장을 읽을 때 자연스럽다고 느끼는 줄바꿈을 브라우저에서도 재현할 수 없을까 하는 고민에서 출발했습니다. Core는 언어에 종속되지 않으며 영어와 한국어 제목용 프리셋을 제공합니다.

ESM onlyReact 19+Node.js 22+

01

결과 예시

브라우저가 폭만 보고 나눈 결과와 모델이 의미 경계를 고려한 결과를 나란히 비교합니다.

브라우저와 semantic-wrap 결과 예시
브라우저 기본 줄바꿈semantic-wrap
디자인 시스템을 도입하기
전에 반드시 확인해야 할 기준
디자인 시스템을 도입하기 전에
반드시 확인해야 할 기준
모바일 환경에서 읽기
좋은 제목을 만드는 방법
모바일 환경에서
읽기 좋은 제목을 만드는 방법
효율적인 회의를 만들기
위해 버려야 할 습관
효율적인 회의를 만들기 위해
버려야 할 습관
사용자를 이해하고, 더
나은 해결책을 만드는 방법
사용자를 이해하고,
더 나은 해결책을 만드는 방법

02

빠르게 시작하기

설치

React에서 한국어 모델을 사용하려면 Core, React 연결 패키지, 한국어 프리셋을 함께 설치합니다.

Terminal
npm install @semantic-wrap/core @semantic-wrap/react @semantic-wrap/ko react react-dom

@semantic-wrap/react는 React와 React DOM 19 이상을 지원합니다. Core나 모델만 사용하는 환경에는 React가 필요하지 않으며 세 패키지는 모두 ESM 전용입니다.

React에서 사용하기

Title.tsx
import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";

export function Title({ children }: { children: string }) {
  return (
    <SemanticWrap model={koTitleModel}>
      <h1 className="title">{children}</h1>
    </SemanticWrap>
  );
}

SemanticWrap은 별도 엘리먼트를 추가하지 않습니다. 최초 표시(initial)와 리사이즈 처리(resize)를 독립적으로 선택합니다. 기본값 resolved + immediate는 최초 계산 후 표시하고 너비 변경에 즉시 반응합니다. native + settled는 원문부터 표시하고 자동 분할 계산하며, 리사이즈 중에는 너비가 안정된 뒤 최신 결과를 적용합니다. 네 조합의 최종 정확도는 같습니다.

03

동작 방식

  1. 01
    예측과 통합

    모델의 예측을 줄바꿈 경계 후보로 통합합니다.

  2. 02
    후보 계산

    실제 글꼴과 너비를 기준으로 가능한 layout 후보들을 계산합니다.

  3. 03
    브라우저 검증

    정상적인 브라우저 layout을 바꾸려면 모델 비용이 더 낮아야 합니다.

  4. 04
    선택과 적용

    시각적 균형까지 비교해 선택된 위치에만 <br>을 삽입합니다.

엘리먼트 크기가 바뀌거나 웹 폰트 로딩이 끝나면 다시 측정하므로 반응형 레이아웃에서도 같은 기준을 유지합니다.

04

패키지 구성

semantic-wrap 패키지와 역할
패키지역할
@semantic-wrap/core줄바꿈 후보를 만들고 그중 하나를 선택합니다.
@semantic-wrap/react화면을 측정하고 선택된 줄바꿈을 React에 적용합니다.
@semantic-wrap/en영어 제목을 위해 학습된 실험적 모델을 제공합니다.
@semantic-wrap/ko한국어 제목을 위해 학습된 모델을 제공합니다.

Core 01

selectLineBreaks

@semantic-wrap/core는 모델 예측부터 최종 layout 선택까지 한 번에 실행합니다. DOM이나 React에 의존하지 않으므로 문자열 너비를 측정할 수 있는 환경이라면 어디서든 사용할 수 있습니다.

line-breaks.ts
import { selectLineBreaks } from "@semantic-wrap/core";
import { koTitleModel } from "@semantic-wrap/ko";

const canvas = document.createElement("canvas");
const canvasContext = canvas.getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const result = selectLineBreaks({
  text: "더 나은 사용자 경험을 만드는 방법",
  model: koTitleModel,
  maxWidth: 320,
  measureText: (text) => canvasContext.measureText(text).width,
});

console.log(result.lines);
// ["더 나은 사용자 경험을", "만드는 방법"]

입력

selectLineBreaks input
필드타입설명
textstring줄바꿈을 적용할 원문
modelPhraseModel경계와 우선순위를 예측할 모델
maxWidthnumber한 줄에 사용할 수 있는 최대 너비
measureText(text: string) => number실제 글꼴로 측정한 문자열 너비를 반환하는 함수

Core는 특정 렌더링 환경에 의존하지 않으므로 measureText를 직접 받습니다. 브라우저에서는 Canvas로 만들 수 있고 React 패키지는 렌더링된 엘리먼트에서 자동으로 만듭니다.

옵션

selectLineBreaks options
필드필수기본값설명
nativeLayout아니요없음비교할 기존 줄바꿈. 마지막 줄을 제외한 UTF-16 offset을 오름차순으로 전달
strategy아니요기본 strategy후보 통합, 계산, 최종 선택 규칙
diagnostics아니요false단계별 중간 결과 포함 여부
initial아니요resolved최초 동기 계산. native는 표시 기회 후 자동 분할 계산
resize아니요immediate즉시 업데이트 또는 settled 안정 후 적용

nativeLayout을 전달하면 계산된 후보와 함께 평가하며 생략하면 계산된 후보 안에서만 선택합니다. React API는 브라우저가 실제로 나눈 줄을 자동으로 전달합니다. 기본 selector는 native가 overflow일 때 너비 안에 들어오는 계산 결과를 허용하고, 그 외에는 줄 수가 같고 modelCost가 더 낮은 후보만 native를 대체할 수 있습니다.

출력: LineBreakSelection

LineBreakSelection 출력
필드타입설명
textstring입력받은 원문
linesstring[]선택된 위치를 기준으로 나눈 문자열 배열
breaksnumber[]마지막 줄을 제외한 각 줄 끝의 UTF-16 offset
widthsnumber[]각 줄을 측정한 너비
selectedCandidatesBreakCandidate[]선택된 offset의 후보와 모델 정보
appliedboolean계산된 줄바꿈을 적용해야 하는지 여부
reasonstring최종 layout을 선택한 이유
overflowboolean선택된 줄이 maxWidth를 넘는지 여부
diagnosticsLineBreakDiagnosticsdiagnostics를 요청했을 때의 중간 결과

Core 02

createLineBreakPlan

같은 원문, 모델, strategy를 여러 너비에서 반복 측정한다면 lazy plan을 사용합니다.

line-break-plan.ts
const plan = createLineBreakPlan({ text, model, strategy });

plan.predict();
plan.aggregate();
plan.calculate({ maxWidth, measureText });
plan.select({ maxWidth, measureText, nativeLayout });

뒤 단계를 호출하면 필요한 앞 단계를 자동 실행합니다. 예측과 집계는 immutable snapshot으로 캐시하고 계산과 선택은 measurement마다 실행합니다.

Core 03

커스텀 모델

PhraseModel의 각 level에는 의미 경계를 반환하는 동기식 predictor를 넣습니다. 여러 level의 예측도 함께 집계할 수 있습니다.

PhraseModel 필드
필드필수기본값설명
levelspredictor와 penalty를 담은 하나 이상의 단계
fallbackPenalty어떤 단계도 찾지 않은 일반 경계의 비용
boundaryMode아니요spaces공백 또는 Unicode grapheme 경계를 사용하며 인접 공백은 하나로 통합

각 level의 penalty는 해당 predictor가 예측한 경계의 비용입니다. 낮을수록 우선하며 여러 level이 같은 경계를 예측하면 기본 aggregate 단계는 가장 낮은 값을 사용합니다.

colon-model.ts
import {
  createBudouxPredictor,
  definePhraseModel,
  selectLineBreaks,
} from "@semantic-wrap/core";

const canvasContext = document.createElement("canvas").getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const colonTitleModel = definePhraseModel({
  boundaryMode: "spaces",
  levels: [
    {
      name: "after-colon",
      predictor: createBudouxPredictor({ UW3: { ":": 100 } }),
      penalty: 0,
    },
  ],
  fallbackPenalty: 1,
});

const result = selectLineBreaks({
  text: "서비스 업데이트: 새로운 기능을 사용하는 방법",
  model: colonTitleModel,
  maxWidth: 400,
  measureText: (value) => canvasContext.measureText(value).width,
});

console.log(result.lines);
// ["서비스 업데이트:", "새로운 기능을 사용하는 방법"]

definePhraseModel은 모델 설정을 검증하고 동결합니다. createBudouxPredictor는 BudouX 가중치를 공통 predictor 계약으로 변환합니다.

직접 predictor 작성하기

BoundaryPredictor는 문자열 내부의 UTF-16 source offset을 오름차순으로 반환합니다. 반환된 예측은 boundaryMode가 허용하는 실제 줄바꿈 위치로 제한됩니다.

editorial-model.ts
import { definePhraseModel } from "@semantic-wrap/core";

const editorialModel = definePhraseModel({
  boundaryMode: "spaces",
  levels: [
    {
      name: "after-colon",
      predictor: {
        predict: (text) =>
          [...text.matchAll(/:\s/gu)].map((match) => match.index + 1),
      },
      penalty: 0,
    },
  ],
  fallbackPenalty: 1,
});

UW3는 후보 바로 앞 한 글자를 나타내는 BudouX feature입니다. 예시의 100은 확률이 아니라 경계를 판정할 때 사용하는 가중치입니다. 실제 서비스에서는 충분한 데이터로 학습하고 검증한 모델을 사용하세요.

Core 04

Strategy

기본 strategy는 세 단계를 순서대로 실행하며 바꾸고 싶은 단계만 교체할 수 있습니다.

Strategy 단계
단계입력기본 규칙개입할 수 있는 부분
aggregate모델별 원본 예측같은 경계의 가장 낮은 penalty모델 합의 조건과 제품별 가중치
calculate후보와 렌더링 너비최소 줄 수의 비지배 layout 후보줄 수 제한, 금지 경계, 탐욕적 계산
select계산 후보와 nativeLayout모델 비용과 시각적 균형 비교제품별 점수와 적용 조건
strategy.ts
import {
  balance,
  consensus,
  createLineBreakStrategy,
  greedy,
} from "@semantic-wrap/core";

const consensusStrategy = createLineBreakStrategy({
  aggregate: consensus({ minimumModels: 2 }),
  select: balance({ tolerance: 0.12 }),
});

const greedyStrategy = createLineBreakStrategy({
  calculate: greedy(),
});

lowestPenalty()는 각 경계에서 가장 낮은 비용을 사용합니다. optimalLayouts()는 최소 줄 수에서 균형 점수와 모델 비용이 서로 지배하지 않는 후보들을 반환하고, greedy()는 현재 줄에 들어가는 후보 중 비용이 가장 낮은 경계를 차례로 선택합니다.

balance()는 overflow가 없고 줄 수가 같으며 modelCost가 native보다 낮은 후보만 변경 대상으로 인정합니다. tolerance 기본값은 0.12이며 값이 클수록 최상의 균형에서 더 먼 layout도 허용합니다. native가 overflow라면 모델 비용과 관계없이 fitting calculated layout을 선택할 수 있습니다. nativeLayout이 없으면 계산 후보 안에서 선택합니다.

계산 단계 교체하기

다음 예시는 제목을 두 줄로 나누되 마지막 줄에 한 어절만 남는 후보를 제외합니다.

two-line-title.ts
import {
  createLineBreakStrategy,
  selectLineBreaks,
  type LineBreakCalculator,
} from "@semantic-wrap/core";
import { koTitleModel } from "@semantic-wrap/ko";

const canvasContext = document.createElement("canvas").getContext("2d")!;
canvasContext.font = "700 28px system-ui";

const twoLineTitleCalculator: LineBreakCalculator = ({
  text,
  candidates,
  maxWidth,
  measureText,
}) => {
  let best: { offset: number; score: number } | undefined;

  for (const candidate of candidates) {
    const firstLine = text.slice(0, candidate.offset).trimEnd();
    const lastLine = text.slice(candidate.offset).trimStart();
    const firstWidth = measureText(firstLine);
    const lastWidth = measureText(lastLine);

    if (firstWidth > maxWidth || lastWidth > maxWidth) continue;
    if (lastLine.split(/\s+/u).length < 2) continue;

    const imbalance = Math.abs(firstWidth - lastWidth) / maxWidth;
    const score = candidate.penalty + imbalance;
    if (!best || score < best.score) {
      best = { offset: candidate.offset, score };
    }
  }

  return [{ breaks: best ? [best.offset] : [] }];
};

const twoLineTitleStrategy = createLineBreakStrategy({
  calculate: twoLineTitleCalculator,
});

const input = {
  text: "좋은 사용자 경험을 만들기 위해 놓치지 말아야 할 기준",
  model: koTitleModel,
  maxWidth: 360,
  measureText: (value: string) => canvasContext.measureText(value).width,
};

console.log(selectLineBreaks(input).lines);
// ["좋은 사용자 경험을 만들기", "위해 놓치지 말아야 할 기준"]

console.log(selectLineBreaks(input, { strategy: twoLineTitleStrategy }).lines);
// ["좋은 사용자 경험을 만들기 위해", "놓치지 말아야 할 기준"]

Core 05

Diagnostics

후보 통합 규칙을 조정하거나 결과를 분석할 때 diagnostics: true를 사용합니다.

diagnostics.ts
const result = selectLineBreaks(
  {
    text: "더 나은 사용자",
    model: koTitleModel,
    maxWidth: 320,
    measureText: (text) => canvasContext.measureText(text).width,
  },
  { diagnostics: true },
);

console.log(result.diagnostics.predictions);
console.log(result.diagnostics.candidates);
Diagnostics 필드
필드설명
predictions각 model level이 예측한 원본 경계. 같은 offset이 여러 번 나타날 수 있음
candidatesaggregate 단계가 통합한 최종 후보 목록
calculatedLayoutslineCount, balanceScore, modelCost, overflow를 포함한 layout 후보
nativeLayout전달된 기존 줄바꿈을 측정한 layout
selectionselect 단계가 반환한 출처, 인덱스, 이유

기본 balance()는 모델 비용이 개선되지 않아 native를 유지할 때 native-no-model-improvement를 반환합니다. 다른 기본 reason은 native-selectedcalculated-selected이며 custom selector는 별도 reason을 반환할 수 있습니다.

React 01

<SemanticWrap />

React 패키지는 렌더링된 글꼴과 너비를 측정하고 Core가 선택한 줄바꿈을 적용합니다. 대부분은 plain-text 엘리먼트에 결과를 바로 적용하는 이 컴포넌트로 시작하면 됩니다.

SemanticWrap props
Prop필수기본값설명
childrenref를 실제 HTMLElement로 전달하는 하나의 plain-text React 엘리먼트
model줄바꿈 후보를 만드는 모델
strategy아니요기본 strategy후보 통합, 계산, 선택 규칙
initial아니요resolved계산 후 표시. native는 원문 표시 후 자동 분할 계산
resize아니요immediate즉시 적용. settled는 약 100ms 안정 후 완료된 결과 적용
mode아니요Deprecated. precise/progressive 호환용, 새 옵션과 혼용 불가
ref아니요자식과 함께 사용할 HTMLElement ref
scheduling.tsx
<SemanticWrap initial="native" resize="settled" model={koTitleModel}>
  <h1>{title}</h1>
</SemanticWrap>

모든 조합은 보이지 않는 DOM copy에서 측정합니다. native는 리사이즈 없이 자동 계산하고, settled는 대기 중 원문을 보여주며 약 100ms 동안 너비가 안정되고 계산이 완료되면 결과를 적용합니다. 100ms 내 완료를 보장하지는 않습니다. 기존 mode는 deprecated이며 precise는 resolved + immediate, progressive는 첫 리사이즈부터 계산하는 호환 동작을 유지합니다.

같은 텍스트와 측정 조건에서 모델·전략 참조만 바뀌면 기존 표시를 유지하며 재검증하고, 결과가 달라질 때만 갱신합니다. 참조를 안정적으로 유지하면 중복 계산을 줄일 수 있지만 정상 동작의 필수 조건은 아닙니다.

React 02

Chakra UI

Chakra UI처럼 실제 HTMLElement로 ref를 전달하는 컴포넌트도 같은 방식으로 사용할 수 있습니다.

ChakraTitle.tsx
import { Text } from "@chakra-ui/react";
import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";

<SemanticWrap model={koTitleModel}>
  <Text textStyle="heading2">{title}</Text>
</SemanticWrap>

React 03

Tailwind CSS

Tailwind CSS로 스타일을 적용한 plain-text 엘리먼트의 className도 그대로 유지됩니다.

TailwindTitle.tsx
import { createLineBreakStrategy, greedy } from "@semantic-wrap/core";
import { koTitleModel } from "@semantic-wrap/ko";
import { SemanticWrap } from "@semantic-wrap/react";

const greedyStrategy = createLineBreakStrategy({
  calculate: greedy(),
});

<SemanticWrap model={koTitleModel} strategy={greedyStrategy}>
  <h2 className="text-3xl font-bold leading-tight">{title}</h2>
</SemanticWrap>

React 04

useSemanticWrap

선택된 줄을 직접 렌더링하거나 diagnostics를 확인할 때 사용하는 저수준 Hook입니다. 측정에는 대상 엘리먼트의 계산된 텍스트 스타일을 사용합니다. 내부 markup이 서로 다른 타이포그래피를 쓴다면 그 스타일을 반영한 measureText와 Core를 사용하세요.

useSemanticWrap 옵션
옵션필수기본값설명
text측정하고 나눌 원문
model줄바꿈 후보를 만드는 모델
strategy아니요기본 strategy후보 통합, 계산, 선택 규칙
diagnostics아니요false단계별 중간 결과 포함 여부
BreakPreview.tsx
import { koTitleModel } from "@semantic-wrap/ko";
import { useSemanticWrap } from "@semantic-wrap/react";

export function BreakPreview({ title }: { title: string }) {
  const { ref, selection } = useSemanticWrap({
    text: title,
    model: koTitleModel,
  });
  const preview = selection ? selection.lines.join(" / ") : title;

  return <h1 ref={ref}>{preview}</h1>;
}

출력: UseSemanticWrapResult

useSemanticWrap 반환값
필드타입설명
ref(HTMLElement | null) => void측정할 엘리먼트에 연결하는 callback ref
selectionLineBreakSelection | null최초 측정 전·텍스트나 측정 조건 변경에 따른 대기 중 null. 참조만 변경된 재검증에서는 기존 결과 유지
diagnosticsLineBreakDiagnostics | null진단을 요청하고 측정한 경우의 결과

Hook은 markup이나 CSS를 직접 바꾸지 않습니다. 기존 CSS는 비교 대상인 브라우저 줄바꿈에 반영되고 모델 결과가 선택되면 <br>로 적용됩니다.

Models

한국어와 영어 프리셋

한국어 제목에는 koTitleModel, 영어 제목에는 enTitleModel을 사용합니다. 두 모델 모두 공백 경계만 사용하므로 어절 내부에 임의의 후보를 만들지 않습니다.

models.ts
import { koTitleModel } from "@semantic-wrap/ko";
import { enTitleModel } from "@semantic-wrap/en";

Project 01

개발

Terminal
bun install
bun run check

bun run check는 타입 검사, 단위 테스트, 빌드, Chromium·Firefox·WebKit 브라우저 테스트와 npm 패키지 구성을 차례로 확인합니다.

Project 02

라이선스

Apache-2.0. @semantic-wrap/core에는 Google의 BudouX Parser를 수정한 dependency-free model inference가 포함되어 있습니다. 자세한 내용은 NOTICE를 참고하세요.