Introduction
semantic-wrap이란?
학습된 모델과 실제 렌더링 결과를 바탕으로 줄바꿈 위치를 선택하는 JavaScript 라이브러리입니다. 더 적합한 결과가 있을 때만 해당 위치에 <br>을 삽입합니다.
사용하는 모델과 선택 방식에 따라 줄바꿈 기준을 바꿀 수 있습니다. 한국어 문장을 읽을 때 자연스럽다고 느끼는 줄바꿈을 브라우저에서도 재현할 수 없을까 하는 고민에서 출발했습니다. Core는 언어에 종속되지 않으며 영어와 한국어 제목용 프리셋을 제공합니다.
01
결과 예시
브라우저가 폭만 보고 나눈 결과와 모델이 의미 경계를 고려한 결과를 나란히 비교합니다.
| 브라우저 기본 줄바꿈 | semantic-wrap |
|---|---|
| 디자인 시스템을 도입하기 전에 반드시 확인해야 할 기준 | 디자인 시스템을 도입하기 전에 반드시 확인해야 할 기준 |
| 모바일 환경에서 읽기 좋은 제목을 만드는 방법 | 모바일 환경에서 읽기 좋은 제목을 만드는 방법 |
| 효율적인 회의를 만들기 위해 버려야 할 습관 | 효율적인 회의를 만들기 위해 버려야 할 습관 |
| 사용자를 이해하고, 더 나은 해결책을 만드는 방법 | 사용자를 이해하고, 더 나은 해결책을 만드는 방법 |
02
빠르게 시작하기
설치
React에서 한국어 모델을 사용하려면 Core, React 연결 패키지, 한국어 프리셋을 함께 설치합니다.
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에서 사용하기
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
동작 방식
- 01예측과 통합
모델의 예측을 줄바꿈 경계 후보로 통합합니다.
- 02후보 계산
실제 글꼴과 너비를 기준으로 가능한 layout 후보들을 계산합니다.
- 03브라우저 검증
정상적인 브라우저 layout을 바꾸려면 모델 비용이 더 낮아야 합니다.
- 04선택과 적용
시각적 균형까지 비교해 선택된 위치에만
<br>을 삽입합니다.
엘리먼트 크기가 바뀌거나 웹 폰트 로딩이 끝나면 다시 측정하므로 반응형 레이아웃에서도 같은 기준을 유지합니다.
04
패키지 구성
| 패키지 | 역할 |
|---|---|
@semantic-wrap/core | 줄바꿈 후보를 만들고 그중 하나를 선택합니다. |
@semantic-wrap/react | 화면을 측정하고 선택된 줄바꿈을 React에 적용합니다. |
@semantic-wrap/en | 영어 제목을 위해 학습된 실험적 모델을 제공합니다. |
@semantic-wrap/ko | 한국어 제목을 위해 학습된 모델을 제공합니다. |
Core 01
selectLineBreaks
@semantic-wrap/core는 모델 예측부터 최종 layout 선택까지 한 번에 실행합니다. DOM이나 React에 의존하지 않으므로 문자열 너비를 측정할 수 있는 환경이라면 어디서든 사용할 수 있습니다.
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);
// ["더 나은 사용자 경험을", "만드는 방법"]입력
| 필드 | 타입 | 설명 |
|---|---|---|
text | string | 줄바꿈을 적용할 원문 |
model | PhraseModel | 경계와 우선순위를 예측할 모델 |
maxWidth | number | 한 줄에 사용할 수 있는 최대 너비 |
measureText | (text: string) => number | 실제 글꼴로 측정한 문자열 너비를 반환하는 함수 |
Core는 특정 렌더링 환경에 의존하지 않으므로 measureText를 직접 받습니다. 브라우저에서는 Canvas로 만들 수 있고 React 패키지는 렌더링된 엘리먼트에서 자동으로 만듭니다.
옵션
| 필드 | 필수 | 기본값 | 설명 |
|---|---|---|---|
nativeLayout | 아니요 | 없음 | 비교할 기존 줄바꿈. 마지막 줄을 제외한 UTF-16 offset을 오름차순으로 전달 |
strategy | 아니요 | 기본 strategy | 후보 통합, 계산, 최종 선택 규칙 |
diagnostics | 아니요 | false | 단계별 중간 결과 포함 여부 |
initial | 아니요 | resolved | 최초 동기 계산. native는 표시 기회 후 자동 분할 계산 |
resize | 아니요 | immediate | 즉시 업데이트 또는 settled 안정 후 적용 |
nativeLayout을 전달하면 계산된 후보와 함께 평가하며 생략하면 계산된 후보 안에서만 선택합니다. React API는 브라우저가 실제로 나눈 줄을 자동으로 전달합니다. 기본 selector는 native가 overflow일 때 너비 안에 들어오는 계산 결과를 허용하고, 그 외에는 줄 수가 같고 modelCost가 더 낮은 후보만 native를 대체할 수 있습니다.
출력: LineBreakSelection
| 필드 | 타입 | 설명 |
|---|---|---|
text | string | 입력받은 원문 |
lines | string[] | 선택된 위치를 기준으로 나눈 문자열 배열 |
breaks | number[] | 마지막 줄을 제외한 각 줄 끝의 UTF-16 offset |
widths | number[] | 각 줄을 측정한 너비 |
selectedCandidates | BreakCandidate[] | 선택된 offset의 후보와 모델 정보 |
applied | boolean | 계산된 줄바꿈을 적용해야 하는지 여부 |
reason | string | 최종 layout을 선택한 이유 |
overflow | boolean | 선택된 줄이 maxWidth를 넘는지 여부 |
diagnostics | LineBreakDiagnostics | diagnostics를 요청했을 때의 중간 결과 |
Core 02
createLineBreakPlan
같은 원문, 모델, strategy를 여러 너비에서 반복 측정한다면 lazy plan을 사용합니다.
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의 예측도 함께 집계할 수 있습니다.
| 필드 | 필수 | 기본값 | 설명 |
|---|---|---|---|
levels | 예 | — | predictor와 penalty를 담은 하나 이상의 단계 |
fallbackPenalty | 예 | — | 어떤 단계도 찾지 않은 일반 경계의 비용 |
boundaryMode | 아니요 | spaces | 공백 또는 Unicode grapheme 경계를 사용하며 인접 공백은 하나로 통합 |
각 level의 penalty는 해당 predictor가 예측한 경계의 비용입니다. 낮을수록 우선하며 여러 level이 같은 경계를 예측하면 기본 aggregate 단계는 가장 낮은 값을 사용합니다.
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가 허용하는 실제 줄바꿈 위치로 제한됩니다.
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는 세 단계를 순서대로 실행하며 바꾸고 싶은 단계만 교체할 수 있습니다.
| 단계 | 입력 | 기본 규칙 | 개입할 수 있는 부분 |
|---|---|---|---|
aggregate | 모델별 원본 예측 | 같은 경계의 가장 낮은 penalty | 모델 합의 조건과 제품별 가중치 |
calculate | 후보와 렌더링 너비 | 최소 줄 수의 비지배 layout 후보 | 줄 수 제한, 금지 경계, 탐욕적 계산 |
select | 계산 후보와 nativeLayout | 모델 비용과 시각적 균형 비교 | 제품별 점수와 적용 조건 |
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이 없으면 계산 후보 안에서 선택합니다.
계산 단계 교체하기
다음 예시는 제목을 두 줄로 나누되 마지막 줄에 한 어절만 남는 후보를 제외합니다.
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를 사용합니다.
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);| 필드 | 설명 |
|---|---|
predictions | 각 model level이 예측한 원본 경계. 같은 offset이 여러 번 나타날 수 있음 |
candidates | aggregate 단계가 통합한 최종 후보 목록 |
calculatedLayouts | lineCount, balanceScore, modelCost, overflow를 포함한 layout 후보 |
nativeLayout | 전달된 기존 줄바꿈을 측정한 layout |
selection | select 단계가 반환한 출처, 인덱스, 이유 |
기본 balance()는 모델 비용이 개선되지 않아 native를 유지할 때 native-no-model-improvement를 반환합니다. 다른 기본 reason은 native-selected와 calculated-selected이며 custom selector는 별도 reason을 반환할 수 있습니다.
React 01
<SemanticWrap />
React 패키지는 렌더링된 글꼴과 너비를 측정하고 Core가 선택한 줄바꿈을 적용합니다. 대부분은 plain-text 엘리먼트에 결과를 바로 적용하는 이 컴포넌트로 시작하면 됩니다.
| Prop | 필수 | 기본값 | 설명 |
|---|---|---|---|
children | 예 | — | ref를 실제 HTMLElement로 전달하는 하나의 plain-text React 엘리먼트 |
model | 예 | — | 줄바꿈 후보를 만드는 모델 |
strategy | 아니요 | 기본 strategy | 후보 통합, 계산, 선택 규칙 |
initial | 아니요 | resolved | 계산 후 표시. native는 원문 표시 후 자동 분할 계산 |
resize | 아니요 | immediate | 즉시 적용. settled는 약 100ms 안정 후 완료된 결과 적용 |
mode | 아니요 | — | Deprecated. precise/progressive 호환용, 새 옵션과 혼용 불가 |
ref | 아니요 | — | 자식과 함께 사용할 HTMLElement ref |
<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를 전달하는 컴포넌트도 같은 방식으로 사용할 수 있습니다.
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도 그대로 유지됩니다.
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를 사용하세요.
| 옵션 | 필수 | 기본값 | 설명 |
|---|---|---|---|
text | 예 | — | 측정하고 나눌 원문 |
model | 예 | — | 줄바꿈 후보를 만드는 모델 |
strategy | 아니요 | 기본 strategy | 후보 통합, 계산, 선택 규칙 |
diagnostics | 아니요 | false | 단계별 중간 결과 포함 여부 |
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
| 필드 | 타입 | 설명 |
|---|---|---|
ref | (HTMLElement | null) => void | 측정할 엘리먼트에 연결하는 callback ref |
selection | LineBreakSelection | null | 최초 측정 전·텍스트나 측정 조건 변경에 따른 대기 중 null. 참조만 변경된 재검증에서는 기존 결과 유지 |
diagnostics | LineBreakDiagnostics | null | 진단을 요청하고 측정한 경우의 결과 |
Hook은 markup이나 CSS를 직접 바꾸지 않습니다. 기존 CSS는 비교 대상인 브라우저 줄바꿈에 반영되고 모델 결과가 선택되면 <br>로 적용됩니다.
Models
한국어와 영어 프리셋
한국어 제목에는 koTitleModel, 영어 제목에는 enTitleModel을 사용합니다. 두 모델 모두 공백 경계만 사용하므로 어절 내부에 임의의 후보를 만들지 않습니다.
import { koTitleModel } from "@semantic-wrap/ko";
import { enTitleModel } from "@semantic-wrap/en";Project 01
개발
bun install
bun run checkbun run check는 타입 검사, 단위 테스트, 빌드, Chromium·Firefox·WebKit 브라우저 테스트와 npm 패키지 구성을 차례로 확인합니다.
Project 02
라이선스
Apache-2.0. @semantic-wrap/core에는 Google의 BudouX Parser를 수정한 dependency-free model inference가 포함되어 있습니다. 자세한 내용은 NOTICE를 참고하세요.
