
무료 포토샵 개발기
글자를 편집으로 남기다
무료 포토샵을 만드는 포토샷 개발기입니다. 22편의 AI 원본에 SOFT와 STUDY 두 줄을 얹었습니다. 글자 그림만 보존하는 대신 문자열·부분 스타일·자간·Warp 설정을 다시 편집할 수 있게 남기는 과정을 살펴봅니다.
AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.
출처에서 시작하다.
한 레이어 안에서도 글자마다 다를 수 있다
text-runs.ts의 TextRun은 시작·끝 위치와 스타일을 저장합니다. 전체 레이어의 글꼴·크기·색을 기본값으로 쓰되 특정 범위에 굵기나 크기 등을 덮어쓸 수 있습니다. 이번 예시는 첫 네 글자 SOFT만 굵게 만든 한 개의 문자 레이어입니다.
범위는 서로 겹치지 않도록 검증되고, 스타일 적용 때 경계를 나눈 뒤 같은 스타일의 인접 구간을 합칩니다. 문자를 추가하거나 지울 때에는 editTextRuns가 변경 구간 앞뒤의 범위를 조정합니다. 저장된 인덱스를 고정해 두기만 하면 편집 후 다른 글자에 스타일이 붙을 수 있습니다.
경로 문자는 곡선 위의 거리로 배치한다
text-path.ts는 곡선을 세분화하며 누적 길이를 구합니다. 단순히 베지어 매개변수 t를 같은 간격으로 나누면 곡선 위의 실제 간격이 같지 않기 때문입니다. 각 글자의 폭과 자간을 이용해 위치를 구하고 그 지점의 접선 각도로 회전합니다.
시작 오프셋·끝 범위·뒤집기와 최소 두 앵커 등의 조건도 별도로 검사합니다. 경로 문자와 Warp는 같은 기능이 아닙니다. 이번 화면에는 경로 문자를 새로 만들지 않았고, 다음 절의 Arc Warp를 적용했습니다.
export type CharacterStyle={openType?:OpenType;fontSize?:number;fontFamily?:string;color?:string;bold?:boolean;italic?:boolean;underline?:boolean;strike?:boolean;tracking?:number;baseline?:number};
export type TextRun={start:number;end:number;style:CharacterStyle};
export function validateTextRuns(runs:TextRun[],text:string){if(!Array.isArray(runs)||runs.length>2048)throw Error('Use up to 2048 text style ranges.');let end=0;for(const r of runs){if(!Number.isInteger(r.start)||!Number.isInteger(r.end)||r.start<end||r.end<=r.start||r.end>text.length||!r.style)throw Error('Invalid text style range.');end=r.end;const s=r.style;if(s.openType)validateOpenType(s.openType);if(s.fontFamily!==undefined&&(typeof s.fontFamily!=='string'||s.fontFamily.length>200)||s.color!==undefined&&!/^#[0-9a-f]{6}$/i.test(s.color))throw Error('Invalid character style.');for(const [k,min,max] of [['fontSize',1,1000],['tracking',-1000,10000],['baseline',-1000,1000]] as const)if(s[k]!==undefined&&(!Number.isFinite(s[k])||s[k]!<min||s[k]!>max))throw Error('Invalid character metric.');for(const k of ['bold','italic','underline','strike'] as const)if(s[k]!==undefined&&typeof s[k]!=='boolean')throw Error('Invalid character style.');}}
export function characterStyle(l:Layer,index:number):Required<CharacterStyle>{const t=l.typography,r=l.textRuns?.find(r=>index>=r.start&&index<r.end)?.style;return {openType:t?.openType??{},fontSize:l.fontSize,fontFamily:l.fontFamily??'Arial',color:l.color,bold:t?.bold??(l.textStyle?.fontWeight??600)>=600,italic:t?.italic??false,underline:t?.underline??false,strike:t?.strike??false,tracking:t?.tracking??0,baseline:t?.baseline??0,...r};}
export function applyTextStyle(text:string,runs:TextRun[]|undefined,start:number,end:number,style:CharacterStyle|null):TextRun[]{start=Math.max(0,Math.min(text.length,start));end=Math.max(start,Math.min(text.length,end));if(start===end)return runs??[];const boundaries=[...new Set([0,text.length,start,end,...(runs??[]).flatMap(r=>[r.start,r.end])])].sort((a,b)=>a-b),out:TextRun[]=[];for(let i=1;i<boundaries.length;i++){const a=boundaries[i-1],b=boundaries[i],old=runs?.find(r=>r.start<=a&&r.end>=b)?.style,inside=a>=start&&b<=end,patch=inside?(style?{...old,...style}:undefined):old;if(!patch||!Object.keys(patch).length)continue;const last=out.at(-1);if(last?.end===a&&JSON.stringify(last.style)===JSON.stringify(patch))last.end=b;else out.push({start:a,end:b,style:{...patch}});}validateTextRuns(out,text);return out;}
export function editTextRuns(before:string,after:string,runs:TextRun[]|undefined){if(!runs?.length)return undefined;let start=0;while(start<before.length&&start<after.length&&before[start]===after[start])start++;let tail=0;while(tail<before.length-start&&tail<after.length-start&&before[before.length-1-tail]===after[after.length-1-tail])tail++;const oldEnd=before.length-tail,newEnd=after.length-tail,delta=newEnd-oldEnd,out:TextRun[]=[];for(const r of runs){const a=r.start<=start?r.start:r.start>=oldEnd?r.start+delta:start,b=r.end<=start?r.end:r.end>=oldEnd?r.end+delta:newEnd;if(b>a&&a<after.length)out.push({start:a,end:Math.min(b,after.length),style:r.style});}let result:TextRun[]=[];for(const r of out)result=applyTextStyle(after,result,r.start,r.end,r.style);return result.length?result:undefined;}계산의
안쪽으로.
OpenType 설정을 켜도 모든 폰트가 변하지는 않는다
opentype.ts는 커닝·합자·분수·숫자 변형 등의 값을 검사하고 font-feature-settings로 연결합니다. 선택한 폰트가 해당 글리프를 제공해야 변화가 나타납니다. SOFT / STUDY에서 임의 합자 설정을 켰다는 사실만으로 합자 모양까지 시험했다고 할 수 없습니다.
PSD로 매핑하는 속성과 브라우저 표현 속성도 구분합니다. 예를 들어 읽은 변환 코드의 fontCaps 매핑은 small-caps와 all-small-caps를 같은 값으로 보냅니다. 체크박스가 있다는 이유로 모든 속성이 외부 형식에서 일대일로 유지된다고 설명하면 안 됩니다.
부분 굵기·자간·Arc를 실제로 저장했다
2026년 10월 22일 같은 1536×1024 AI 원본에 80px 두 줄 문자를 만들었습니다. 첫 네 글자만 굵게 하고 Tracking 50, Discretionary Ligatures 켜짐을 설정했습니다. Arc Warp는 가로 방향 Bend 15, 가로·세로 왜곡 0으로 적용했습니다.
text-warp.ts는 Warp 설정을 별도 객체로 보관하고 수치 범위를 검사합니다. 화면에서는 변형된 글자가 보이지만 문자열을 픽셀로 치환해 버린 것은 아닙니다. 시연 색상은 편집기에 설정되어 있던 연두색이며, 인물과 문자 레이어는 분리했습니다.
코드를 읽다.
export type TextPath={path:PathShape;offset:number;end?:number;flip:boolean};
export function validateTextPath(t:TextPath){if(!t||!Number.isFinite(t.offset)||Math.abs(t.offset)>100000||typeof t.flip!=='boolean')throw Error('Invalid text path.');if(t.end!==undefined&&(!Number.isFinite(t.end)||t.end<0||t.end>100000))throw Error('Invalid text path end.');validatePaths([{id:'type',name:'Type',paths:[t.path]}]);if(t.path.knots.length<2)throw Error('Text needs at least two path anchors.');}
/** Adaptive subdivision preserves arc length on curved paths, rather than spacing by parameter t. */
export function flattenTextPath(path:PathShape){const points:Point[]=[],knots=path.knots;let length=0;const append=(p:Point)=>{if(points.length)length+=Math.hypot(p.x-points.at(-1)!.x,p.y-points.at(-1)!.y);points.push(p);distances.push(length);},distances:number[]=[];const pt=(a:number[],i:number)=>({x:a[i],y:a[i+1]}),mid=(a:Point,b:Point)=>({x:(a.x+b.x)/2,y:(a.y+b.y)/2});
const subdivide=(a:Point,b:Point,c:Point,d:Point,depth:number)=>{const chord=Math.hypot(d.x-a.x,d.y-a.y),polygon=Math.hypot(b.x-a.x,b.y-a.y)+Math.hypot(c.x-b.x,c.y-b.y)+Math.hypot(d.x-c.x,d.y-c.y);if(depth>=12||polygon-chord<.025){append(d);return;}const ab=mid(a,b),bc=mid(b,c),cd=mid(c,d),abc=mid(ab,bc),bcd=mid(bc,cd),center=mid(abc,bcd);subdivide(a,ab,abc,center,depth+1);subdivide(center,bcd,cd,d,depth+1);};
if(knots.length){append(pt(knots[0].points,2));for(let i=1;i<knots.length+(path.open?0:1);i++){const a=knots[(i-1)%knots.length].points,b=knots[i%knots.length].points;subdivide(pt(a,2),pt(a,4),pt(b,0),pt(b,2),0);}}
const at=(distance:number)=>{if(!length||distance<0||distance>length)return;let lo=1,hi=distances.length-1;while(lo<hi){const m=(lo+hi)>>1;if(distances[m]<distance)lo=m+1;else hi=m;}const a=points[lo-1],b=points[lo],span=distances[lo]-distances[lo-1],t=span?(distance-distances[lo-1])/span:0;return {x:a.x+(b.x-a.x)*t,y:a.y+(b.y-a.y)*t,angle:Math.atan2(b.y-a.y,b.x-a.x)};};return {length,at};export const warpNames={arc:'Arc',arcLower:'Arc Lower',arcUpper:'Arc Upper',arch:'Arch',bulge:'Bulge',shellLower:'Shell Lower',shellUpper:'Shell Upper',flag:'Flag',wave:'Wave',fish:'Fish',rise:'Rise',fisheye:'Fisheye',inflate:'Inflate',squeeze:'Squeeze',twist:'Twist'};
export type TextWarp={style:keyof typeof warpNames;bend:number;horizontal:number;vertical:number;orientation:'horizontal'|'vertical'};
export function validateTextWarp(w:TextWarp){if(!w||!Object.hasOwn(warpNames,w.style)||!['horizontal','vertical'].includes(w.orientation)||[w.bend,w.horizontal,w.vertical].some(n=>!Number.isFinite(n)||n< -100||n>100))throw Error('Invalid text warp.');}export type OpenType={kerning?:boolean;ligatures?:boolean;discretionaryLigatures?:boolean;fractions?:boolean;ordinals?:boolean;alternates?:boolean;swash?:boolean;oldStyle?:boolean;tabular?:boolean;caps?:'normal'|'small-caps'|'all-small-caps'|'uppercase';position?:'normal'|'superscript'|'subscript'};
export const openTypeFlags=['kerning','ligatures','discretionaryLigatures','fractions','ordinals','alternates','swash','oldStyle','tabular'] as const;
export function validateOpenType(t:OpenType){if(!t||typeof t!=='object')throw Error('Invalid OpenType options.');for(const k of openTypeFlags)if(t[k]!==undefined&&typeof t[k]!=='boolean')throw Error('Invalid OpenType option.');if(t.caps!==undefined&&!['normal','small-caps','all-small-caps','uppercase'].includes(t.caps)||t.position!==undefined&&!['normal','superscript','subscript'].includes(t.position))throw Error('Invalid character variant.');}
export function openTypeStyle(t:OpenType={}){const pairs=[['kern',t.kerning??true],['liga',t.ligatures??true],['clig',t.ligatures??true],['dlig',!!t.discretionaryLigatures],['frac',!!t.fractions],['ordn',!!t.ordinals],['salt',!!t.alternates],['swsh',!!t.swash],['onum',!!t.oldStyle],['tnum',!!t.tabular],['smcp',t.caps==='small-caps'||t.caps==='all-small-caps'],['c2sc',t.caps==='all-small-caps']];return `font-feature-settings:${pairs.map(([k,v])=>`'${k}' ${v?1:0}`).join(',')};font-kerning:${t.kerning===false?'none':'normal'};text-transform:${t.caps==='uppercase'?'uppercase':'none'};`;}
export function openTypeToPsd(t:OpenType={}){return {autoKerning:t.kerning!==false,ligatures:t.ligatures!==false,dLigatures:!!t.discretionaryLigatures,fontCaps:t.caps==='uppercase'?2:t.caps==='small-caps'||t.caps==='all-small-caps'?1:0,fontBaseline:t.position==='superscript'?1:t.position==='subscript'?2:0};}
export function openTypeFromPsd(s:any):OpenType|undefined{if(!s)return;const t:OpenType={};if(s.autoKerning===false)t.kerning=false;if(s.ligatures===false)t.ligatures=false;if(s.dLigatures)t.discretionaryLigatures=true;if(s.fontCaps===2)t.caps='uppercase';else if(s.fontCaps===1)t.caps='small-caps';if(s.fontBaseline===1)t.position='superscript';else if(s.fontBaseline===2)t.position='subscript';return Object.keys(t).length?t:undefined;}편집 화면에서.

문자 그림과 편집 정보를 각각 확인한다
.layerstudio를 저장해 다시 열고 재저장한 뒤 문자 레이어를 비교했습니다. 문자열, 0~4의 굵기 범위, 자간, OpenType 옵션과 Arc 설정을 포함한 레이어 데이터가 정확히 일치했습니다. 이 검사는 프로젝트 왕복이며 오늘 Adobe에서 직접 재편집한 결과는 아닙니다.
보관된 opentype-native 보고서는 지정된 PSD·PSB의 범위 옵션 유지와 실제 Photoshop 문자 편집을 통과로 기록합니다. 같은 보고서에는 Adobe DOM으로 전체 문자를 교체하면 범위가 초기화된다는 조건도 있습니다. 과거 보고서와 이번 시연을 구분하고 설치 폰트의 영향도 남깁니다. 24편에서는 도형과 베지어 경로를 다룹니다.
원본과 편집 가능한 문자.


AI 생성 시연용 사진. 편집 화면과 출력 이미지는 실제 포토샷에서 만들었습니다.