Keep type editable / original
DEVELOPMENT JOURNAL / 23
Photoshot
CODE × UI
Building a free Photoshop alternative

Keep type editable

We placed SOFT and STUDY on two lines over the AI original from Part 22. This Photoshot journal follows the string, character overrides, tracking and Warp settings that must survive beyond a picture of the lettering.

PHOTOSHOT DEVELOPMENT JOURNAL / 23

AI-generated study photograph. Editor captures and outputs show Photoshot in use.

PhotoshotDEVELOPMENT JOURNAL / 23FOUNDATION

From the source.

One layer can contain multiple styles

A TextRun in text-runs.ts stores a start, end and style. Layer-wide font, size and color provide defaults while a selected range overrides individual properties. This example makes only the first four characters, SOFT, bold in one text layer.

Validation rejects overlapping ranges. Applying styles divides boundaries and joins adjacent equal styles. editTextRuns adjusts ranges around inserted or removed text. Leaving indices fixed would risk moving a style onto the wrong characters after editing.

Path text follows distance along a curve

text-path.ts subdivides curves and accumulates their lengths. Equal steps in Bézier parameter t do not imply equal distances along the curve. Glyph width and tracking determine placement, and the local tangent determines rotation.

Offsets, end limits, flipping and the requirement for at least two anchors are validated separately. Path text and Warp are different features. No new path text was created in this UI demonstration; it uses Arc Warp instead.

app/studio/text-runs.ts · SHA-256 60199cfc855f · L4–9Photoshot implementation ↗
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;}
PhotoshotDEVELOPMENT JOURNAL / 23IMPLEMENTATION

Inside the
calculation.

OpenType depends on the selected font

opentype.ts validates options such as kerning, ligatures, fractions and numeral variants and connects them to font-feature-settings. The selected font must supply the relevant glyphs. Enabling discretionary ligatures for SOFT / STUDY does not test an actual ligature shape.

Browser options and PSD mappings also differ. In the inspected conversion, fontCaps maps small-caps and all-small-caps to the same value. The existence of a checkbox does not prove a one-to-one external-format round trip for every option.

Save character overrides, tracking and Arc

On October 22, 2026, we created two lines at 80px on the same 1536×1024 AI original. The first four characters are bold, Tracking is 50 and Discretionary Ligatures is enabled. Arc Warp uses horizontal orientation, Bend 15, and zero horizontal and vertical distortion.

text-warp.ts retains Warp in a separate settings object and validates its ranges. The displayed deformation does not replace the string with pixels. The demonstration retains the editor’s lime color and keeps the portrait and text in separate layers.

PHOTOSHOT / SOURCE NOTES23

Read the code.

app/studio/text-path.ts · SHA-256 93519a1b76af · L3–9Photoshot implementation ↗
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};
app/studio/text-warp.ts · SHA-256 9996422b9f8b · L2–4Photoshot implementation ↗
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.');}
app/studio/opentype.ts · SHA-256 3c200f1216cd · L4–9Photoshot implementation ↗
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;}
PhotoshotDEVELOPMENT JOURNAL / 23IN PRACTICE

In the editor.

Keep type editable / workspace
Actual Photoshot workspace. Settings and the image are shown together.

Check both appearance and editable data

After saving .layerstudio, reopening and saving again, the complete text-layer data matched exactly, including the string, bold range 0–4, tracking, OpenType option and Arc settings. This is a project round trip, not a fresh Adobe editing test today.

The stored opentype-native report records preservation and actual Photoshop text edits for designated PSD/PSB cases. It also states that replacing all text through the Adobe DOM resets ranges. Historical reports remain separate from this demonstration, and installed fonts still matter. Part 24 covers shapes and Bézier paths.

PhotoshotDEVELOPMENT JOURNAL / 23BEFORE / AFTER

Original and editable text.

Keep type editable / original
01 / AI-generated demonstration original
Keep type editable / result
02 / Project reopened · character styles, tracking and Arc

AI-generated study photograph. Editor captures and outputs show Photoshot in use.