영어를 추가하는 일은 번역문을 붙이는 것으로 끝나지 않았다. 글꼴과 본문 폭, 줄 수 제한과 내려받는 자료를 함께 살펴야 했다. 실제 영어 파일과 생성 코드를 읽고 이 중 줄 계획을 작은 재사용 모듈로 분리했다.
번역문이 길어진 이유부터 구별한다
한국어 문장을 영어로 옮기면 단어의 길이와 띄어쓰기 위치가 달라진다. 원래 카드의 높이와 제목 줄 수를 그대로 고정하면 정상적인 설명도 잘려 보일 수 있다. 나는 두 언어의 뜻을 맞추는 일과 같은 폭 안에서 읽히게 하는 일을 따로 확인한다. 한쪽 문장을 억지로 짧게 만드는 것이 첫 해결책이 되지는 않는다.
실제 작업 자료에는 영어 안내 파일을 만드는 코드와 내려받을 PDF가 남아 있었다. 이번에는 기존 두 파일의 총 열두 페이지를 렌더링해 100dpi 미리보기로 읽었다. 눈에 띄는 겹침과 페이지 잘림은 보이지 않았다. 이 관찰을 모든 화면과 인쇄 환경의 검증으로 넓히지는 않는다. 확인한 파일과 확인 방법을 먼저 한정했다.
글자 수보다 글꼴과 폭을 먼저 정한다
같은 열 글자라도 실제 폭은 글꼴과 굵기에 따라 달라진다. 영어의 짧은 글자와 넓은 글자가 섞이고 한글과 숫자가 함께 놓이면 글자 수 기준은 더 거칠어진다. 원본 PDF 코드도 선택한 글꼴로 문장의 폭을 계산했다. 이 원리를 새 모듈에서는 측정 함수를 외부에서 받아 쓰도록 분리했다.
웹에서는 실제 적용한 글꼴이 준비된 뒤 측정해야 하고 PDF에서는 포함할 글꼴과 같은 조건으로 폭을 구해야 한다. 예제의 Canvas 측정은 현재 브라우저의 system-ui에 한정된다. 그것을 다른 PDF 글꼴의 정확한 결과로 사용하지 않는다. 측정 도구가 달라져도 본문 폭과 줄 높이, 측정의 단위는 서로 맞아야 한다.
긴 단어와 한 글자 넘침을 숨기지 않는다
띄어쓰기 단위로 줄을 나누면 평범한 영어 문장은 자연스럽게 읽힌다. 하지만 한 단어가 본문 폭보다 길면 별도의 처리가 필요하다. 새 모듈은 앞에 다른 단어가 있는 경우에도 긴 단어를 다시 검사한다. 줄을 비운 뒤 글자 묶음 단위로 나누므로 긴 주소나 식별자가 조용히 사라지지 않는다.
문자 하나처럼 보이는 묶음도 내부적으로 여러 코드로 이루어질 수 있다. Intl.Segmenter의 grapheme 단위를 사용해 결합 문자와 이모지 묶음이 가능한 한 함께 남도록 했다. 묶음 하나가 폭보다 넓으면 줄을 만들고 overflow 표시를 남긴다. 억지로 쪼개거나 축소해 성공한 것처럼 보고하지 않는다. 언어별 금칙 처리와 하이픈 규칙은 별도 기능이다.
줄을 줄이는 대신 다음 공간을 결정한다
정해 둔 줄 수만 남기고 나머지를 버리면 화면은 깔끔해져도 설명의 끝이 사라진다. 원본의 줄 수 제한과 공간 계산을 읽으면서 새 예제는 전체 줄과 필요한 높이를 돌려주는 편이 낫다고 판단했다. 호출하는 화면이 더 높은 구간을 만들지, 다음 페이지를 추가할지, 더 보기로 연결할지 결정하도록 했다.
이 모듈은 단어 사이 연속 공백을 정리하며 명시적인 줄바꿈은 유지한다. 문장을 줄이지 않고 계획한 높이를 반환하지만 페이지를 자동으로 나누지는 않는다. PDF의 남은 공간과 페이지 제목, 다음 구간의 여백은 실제 생성기가 처리해야 한다. 웹에서도 측정한 줄 목록과 CSS가 그리는 실제 줄바꿈의 일치는 따로 확인할 사항이다.
코드와 구현 살펴보기
측정 함수를 받는 줄 계획 모듈
export function planMeasuredLines(text,{measure,width,locale='en',mode='words',lineHeight=1}){
if(typeof text!=='string'||text.length>20000||typeof measure!=='function'||!Number.isFinite(width)||width<=0||!Number.isFinite(lineHeight)||lineHeight<=0||!['words','graphemes'].includes(mode))throw new TypeError('Text, finite measures and a supported mode required');
const segmenter=new Intl.Segmenter(locale,{granularity:'grapheme'});
const graphemes=value=>Array.from(segmenter.segment(value),x=>x.segment);
const measured=value=>{const n=measure(value);if(!Number.isFinite(n)||n<0)throw new TypeError('Measurement must be a nonnegative finite number');return n;};
const lines=[];
const push=value=>{const valueWidth=measured(value);lines.push({text:value,width:valueWidth,overflow:valueWidth>width});};
for(const paragraph of text.split(/\r\n|\r|\n/)){
if(!paragraph.trim()){push('');continue;}
const tokens=mode==='words'?paragraph.trim().split(/\s+/u):graphemes(paragraph),separator=mode==='words'?' ':'';let line='';
for(const token of tokens){
const candidate=line?line+separator+token:token;
if(measured(candidate)<=width){line=candidate;continue;}
if(line){push(line);line='';}
if(measured(token)<=width){line=token;continue;}
// Oversized words are split even when they follow another word.
for(const unit of graphemes(token)){
if(line&&measured(line+unit)>width){push(line);line='';}
line+=unit;
if(measured(line)>width){push(line);line='';}
}
}
if(line)push(line);
}
return {lines,height:lines.length*lineHeight,overflowLines:lines.filter(x=>x.overflow).length,scope:'Whitespace is normalized in word mode; explicit paragraphs and grapheme clusters retained. No language-specific hyphenation, punctuation rules or pagination.'};
}
본문과 내려받는 파일을 함께 확인한다
언어 버튼이 바뀌어도 다운로드가 이전 언어 자료로 이어지면 독자는 어디서 잘못됐는지 알기 어렵다. 제목과 설명, 파일 이름과 실제 파일 언어를 함께 비교해야 한다. 이번 원고에는 고객 이름이 들어 있는 원래 PDF를 옮기지 않았다. 파일 검토에서 얻은 줄 계획 방법을 익명 예제로 새로 만든 것이다.
새 모듈은 유니코드 묶음과 긴 단어, 공백, 측정 오류 등 여덟 가지 로컬 검사를 통과했다. 아래 예제의 코드와 공개 응답은 검사하지만 기존 Studio 작업을 보존하는 조건에서 새 브라우저 화면은 검수하지 않았다. 실제로 적용할 때는 선택한 글꼴, 좁은 화면, 가장 긴 제목과 내려받는 파일까지 이어서 확인한다.