インクに分けて再び開く / original
DEVELOPMENT JOURNAL / 31
Photoshot
CODE × UI
無料Photoshop代替の開発記

インクに分けて再び開く

黒と茶の人物写真を作る途中、保存したダブルトーンPSDが開けない問題を見つけました。2026年10月22日に読込条件を修正し、公開エディターで同じファイルを再検証して、CMYKと色分解TIFFまで確認しました。

PHOTOSHOT DEVELOPMENT JOURNAL / 31

AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。

Photoshot無料Photoshop代替の開発記 / 31FOUNDATION

ソースから始める。

二つのインク曲線を設定する

30回のAI生成の架空の成人モデル原画像を使いました。BlackとWarm Brownを選び、茶は#98552e、入力50%でインク35%、入力100%で65%に設定しました。黒は既定の直線曲線です。PreviewとOKを実行すると、色が二つのインク濃度へ変わりました。

duotone.tsは白い紙上に合成してグレースケールへ変換し、各インク曲線を線形補間します。複数の編集レイヤーを一つのダブルトーン層へまとめる変換なので、原本と変換プロジェクトを別々に保存しました。単なる茶色の効果とは異なる文書構造です。

保存後の読込でモード検査の問題を発見

出力したPSDを開くと色モードのエラーになりました。ヘッダーは正しくダブルトーンのモード8、8ビット、単一チャンネルでした。しかし共通の画素読込関数がGray要求に対してモード1だけを許可し、正常なファイルまで拒否していました。

precision-psd.tsへ限定した条件を追加しました。モード8かつGray、8ビット、一チャンネル、別アルファなしの場合だけ許可します。不一致のモード・深度・アルファは引き続き拒否します。実際に失敗したファイルで、修正前の再現と修正後の成功を確認しました。

app/studio/duotone.ts · SHA-256 b822fb75df4a · L9–14Photoshotの実装コード ↗
export type Duotone={inks:DuotoneInk[];overprints?:Record<string,string>;original?:string};
export const inkInputs=[0,5,10,20,30,40,50,60,70,80,90,95,100];
export const nullInkCurve=()=>[0,...Array(11).fill(-1),1000];
export const defaultDuotone=():Duotone=>({inks:[{name:'Black',color:'#000000',curve:nullInkCurve()},{name:'Warm Brown',color:'#98552e',curve:[0,...Array(11).fill(-1),650]}]});
const hex=(v:number[])=>'#'+v.map(n=>Math.round(Math.max(0,Math.min(1,n))*255).toString(16).padStart(2,'0')).join(''),rgb=(s:string)=>[1,3,5].map(i=>parseInt(s.slice(i,i+2),16)/255);
export function validateDuotone(d:Duotone){if(d?.original&&(typeof d.original!=='string'||d.original.length>87384))throw Error('Invalid preserved Duotone options.');if(!d||!Array.isArray(d.inks)||d.inks.length<1||d.inks.length>4)throw Error('Choose one to four inks.');for(const i of d.inks){if(typeof i.name!=='string'||!i.name.trim()||i.name.length>63||!/^#[0-9a-f]{6}$/i.test(i.color)||!Array.isArray(i.curve)||i.curve.length!==13||i.curve.some((n,j)=>!Number.isInteger(n)||n<((j===0||j===12)?0:-1)||n>1000))throw Error('Invalid ink name, color or curve.');}for(const [key,color] of Object.entries(d.overprints??{}))if(!Number.isInteger(+key)||+key<3||+key>15||!/^#[0-9a-f]{6}$/i.test(color))throw Error('Invalid overprint color.');}
Photoshot無料Photoshop代替の開発記 / 31IMPLEMENTATION

計算の
内側へ。

公開エディターで同じファイルを確認

修正を公開エディターへ反映し、同じPSDを再読込しました。2 Ink Duotone文書として開き、BlackとWarm Brownの名前、色、50%→35%・100%→65%の曲線を保持しました。PSD・PSB往復と既存のRGB32・Lab16読込検査も通過しました。

524バイトのADOプリセットはPSD内の設定と完全に一致しました。PSD往復でのグレー画素の最大差は約0.001961で、8ビット量子化の範囲でした。茶インクの分版は16ビット単一チャンネルTIFFとなり、曲線から独立計算した全値と一致しました。

CMYK合成と黒版を別々に調べる

Save a Copyで既存のRSWOP.icmを出力先に指定し、Relative Colorimetric、黒点補正オン、300ppiを使いました。CMYK合成TIFFとBlack Plateを保存しました。二つのインクを保持するPSDと、四チャンネルへ変換したCMYK TIFFは別の出力物です。

TIFFは1536×1024、四つの16ビットCMYKチャンネル、300ppiタグを持ち、入力ICCの218,103バイトも完全に保持しました。黒版の全画素はK値を白い紙基準のグレーへ変換した値と一致しました。フルサイズの総インク量は最大約170.005%、平均約122.617%でした。

PHOTOSHOT / SOURCE NOTES31

コードを読む。

app/studio/precision-psd.ts · SHA-256 abedaf5b0299 · L19–25Photoshotの実装コード ↗
export function precisionCompositePixels(bytes:Uint8Array,space:PrecisionSpace,depth:PrecisionDepth,alpha:boolean,profile?:Uint8Array):PrecisionRaster{
 const n=precisionChannels(space),info=compositeInfo(bytes),{width,height,channels,large,compression}=info,size=width*height,needed=n+(alpha?1:0);
 // Duotone stores one 8-bit grayscale plane while keeping PSD color mode 8.
 const duotoneGray=info.colorMode===8&&space==='Gray'&&depth===8&&channels===1&&!alpha;
 if(size>16000000||channels<needed||info.depth!==depth||info.colorMode!==({RGB:3,Gray:1,CMYK:4,Lab:9})[space]&&!duotoneGray||depth===32&&!['RGB','Gray'].includes(space))throw Error('Unsupported flattened PSD dimensions, color mode, depth or channels.');
 const {planes}=compositePayloads(bytes,Array.from({length:needed},(_,i)=>i)),data=new Float32Array(size*(n+1));for(let i=n;i<data.length;i+=n+1)data[i]=1;
 for(let c=0;c<needed;c++){const payload=planes.get(c)!,values=decodePrecisionPlane(payload.data,compression,width,height,depth,large,payload.inflated);for(let i=0;i<size;i++)data[i*(n+1)+c]=space==='CMYK'&&c<n?1-values[i]:values[i];}
app/studio/duotone.ts · SHA-256 b822fb75df4a · L19–24Photoshotの実装コード ↗
/** Piecewise-linear ink curves provide predictable control without clipping intermediate knots. */
export function inkCoverage(curve:number[],input:number){const points=curve.flatMap((v,i)=>v<0?[]:[{x:inkInputs[i]/100,y:v/1000}]);const x=Math.max(0,Math.min(1,input));let i=1;while(i<points.length-1&&x>points[i].x)i++;const a=points[i-1],b=points[i];return a.y+(b.y-a.y)*(x-a.x)/(b.x-a.x);}
export function duotoneCanvas(r:PrecisionRaster,d:Duotone){validateDuotone(d);if(r.space!=='Gray')throw Error('Duotone needs a grayscale source.');const c=surface(r.width,r.height),g=c.getContext('2d')!,im=g.createImageData(r.width,r.height),inks=d.inks.map(i=>rgb(i.color)),colors=Array.from({length:1<<inks.length},(_,mask)=>{if(d.overprints?.[mask])return rgb(d.overprints[mask]);return [0,1,2].map(k=>inks.reduce((n,ink,j)=>mask&1<<j?n*ink[k]:n,1));}),table=Array.from({length:256},(_,tone)=>{const coverage=d.inks.map(i=>inkCoverage(i.curve,tone/255)),out=[0,0,0];for(let m=0;m<colors.length;m++){const weight=coverage.reduce((n,t,j)=>n*(m&1<<j?t:1-t),1);for(let k=0;k<3;k++)out[k]+=colors[m][k]*weight;}return out;});for(let i=0;i<r.width*r.height;i++){const gray=r.data[i*2]*r.data[i*2+1]+1-r.data[i*2+1],color=table[Math.round(Math.max(0,Math.min(1,1-gray))*255)];for(let k=0;k<3;k++)im.data[i*4+k]=color[k]*255;im.data[i*4+3]=255;}g.putImageData(im,0,0);return c;}
export async function convertDuotone(doc:StudioDoc,settings:Duotone,wasm?:Uint8Array):Promise<StudioDoc>{validateDuotone(settings);if(doc.duotone)return {...doc,duotone:structuredClone(settings)};const image=await composite(doc,true),pixels=await convertPrecision(floatCanvas(image),'Gray',8,undefined,wasm),src=(await precisionCanvas(pixels,wasm)).toDataURL();return {...doc,version:6,duotone:structuredClone(settings),precision:{space:'Gray',depth:8},layers:[makeLayer({kind:'image',name:'Duotone',width:doc.width,height:doc.height,src,precision:packPrecision(pixels),precisionSource:precisionSource(src)})],groups:undefined,artboards:undefined,artboardWorkspace:undefined,psdData:undefined,selection:undefined,savedSelections:undefined,spotChannels:undefined,layerComps:undefined,activeLayerCompId:undefined};}
export async function readDuotonePSD(bytes:Uint8Array,name:string,base:StudioDoc){const v=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);if(v.getUint16(22)!==8)throw Error('Duotone PSD requires 8-bit grayscale.');const length=v.getUint32(26);if(length>65536||30+length>bytes.length)throw Error('Invalid Duotone data length.');const resolution=readResource(bytes,1005),ppi=resolution&&resolution.length>=4?new DataView(resolution.buffer,resolution.byteOffset,resolution.byteLength).getUint32(0)/65536:72,duotone=readDuotoneOptions(bytes.subarray(30,30+length)),pixels=precisionCompositePixels(bytes,'Gray',8,false),src=(await precisionCanvas(pixels)).toDataURL();return {...base,resolution:ppi,name:name.replace(/\.(psd|psb)$/i,''),width:pixels.width,height:pixels.height,version:6 as const,duotone,precision:{space:'Gray' as const,depth:8 as const},layers:[makeLayer({kind:'image',name:'Duotone',width:pixels.width,height:pixels.height,src,precision:packPrecision(pixels),precisionSource:precisionSource(src)})],sourceFile:{name,mode:8,depth:8,warnings:[]}};}
export async function writeDuotonePSD(doc:StudioDoc,psb:boolean){if(!doc.duotone||doc.precision?.space!=='Gray'||doc.precision.depth!==8)throw Error('Duotone requires an 8-bit grayscale document.');const options=writeDuotoneOptions(doc.duotone),r=await compositePrecisionDocument(doc);for(let i=0;i<r.width*r.height;i++){r.data[i*2]=r.data[i*2]*r.data[i*2+1]+1-r.data[i*2+1];r.data[i*2+1]=1;}const header=new Uint8Array(30),v=new DataView(header.buffer);header.set(new TextEncoder().encode('8BPS'));v.setUint16(4,psb?2:1);v.setUint16(12,1);v.setUint32(14,r.height);v.setUint32(18,r.width);v.setUint16(22,8);v.setUint16(24,8);v.setUint32(26,options.length);const tail=new Uint8Array(4+(psb?8:4)+2),pixels=channelBytes(r,0),out=new Uint8Array(header.length+options.length+tail.length+pixels.length);out.set(header);out.set(options,header.length);out.set(tail,header.length+options.length);out.set(pixels,header.length+options.length+tail.length);const resolution=new Uint8Array(16),rv=new DataView(resolution.buffer),ppi=Math.round((doc.resolution??72)*65536);rv.setUint32(0,ppi);rv.setUint16(4,1);rv.setUint16(6,1);rv.setUint32(8,ppi);rv.setUint16(12,1);rv.setUint16(14,1);return writeResource(out,1005,resolution);}
app/studio/color-output.ts · SHA-256 8e4b90dbd19b · L10–17Photoshotの実装コード ↗
export function inkSummary(output:ColorOutput){if(output.profile.space!=='CMYK')return undefined;let max=0,mean=0;for(let i=0;i<output.pixels.length;i+=4){const sum=(output.pixels[i]+output.pixels[i+1]+output.pixels[i+2]+output.pixels[i+3])/65535*100;max=Math.max(max,sum);mean+=sum;}return {max,mean:mean/(output.width*output.height)};}
export async function colorOutputPreview(output:ColorOutput,plate=-1,wasmBinary?:Uint8Array){const cmyk=output.profile.space==='CMYK',out=new Uint8ClampedArray(output.width*output.height*4);if(cmyk&&plate>=0){if(!Number.isInteger(plate)||plate>3)throw Error('Choose a CMYK plate.');for(let i=0;i<out.length;i+=4){out[i]=out[i+1]=out[i+2]=255-output.pixels[i+plate]/257;out[i+3]=255;}return out;}const m=await loadICCEngine(wasmBinary),a=m.cmsOpenProfileFromMem(output.profile.bytes,output.profile.bytes.length),b=m.cmsCreate_sRGBProfile();let t=0;try{const n=cmyk?4:3;t=m.cmsCreateTransform(a,((cmyk?6:4)<<16)|(n<<3)|2,b,(4<<16)|(3<<3)|1,1,0x2000);if(!t)throw Error('This output profile cannot be previewed.');for(let start=0;start<output.width*output.height;start+=8192){const count=Math.min(8192,output.width*output.height-start),input=new Uint16Array(count*n);for(let i=0;i<count;i++)for(let k=0;k<n;k++)input[i*n+k]=output.pixels[(start+i)*4+k];const rgb=m.cmsDoTransform(t,input,count);for(let i=0;i<count;i++){out[(start+i)*4]=rgb[i*3];out[(start+i)*4+1]=rgb[i*3+1];out[(start+i)*4+2]=rgb[i*3+2];out[(start+i)*4+3]=cmyk?255:output.pixels[(start+i)*4+3]/257;}await new Promise(r=>setTimeout(r,0));}return out;}finally{if(t)m.cmsDeleteTransform(t);if(a)m.cmsCloseProfile(a);if(b)m.cmsCloseProfile(b);}}
/** Single-channel uncompressed 16-bit TIFF, BlackIsZero; ink 100% is black. */
export function separationTiff(output:ColorOutput,plate:number,dpi:number){if(output.profile.space!=='CMYK'||!Number.isInteger(plate)||plate<0||plate>3||!Number.isFinite(dpi)||dpi<1||dpi>1200)throw Error('Invalid separation output.');return gray16Tiff(Uint16Array.from({length:output.width*output.height},(_,i)=>65535-output.pixels[i*4+plate]),output.width,output.height,dpi);}
export function saveColorOutput(output:ColorOutput,dpi:number,plate=-1){return plate>=0?separationTiff(output,plate,dpi):tiff16(output.pixels,output.width,output.height,dpi,output.profile.bytes,output.profile.space==='CMYK');}
export const defaultOutputProfile=():PrintProfile=>({name:'sRGB IEC61966-2.1',bytes:srgbProfile(),space:'RGB '});

export async function convertDocumentColorOutput(doc:StudioDoc,profile:PrintProfile,options:ColorOutputOptions,cancelled:()=>boolean=()=>false,maxEdge?:number,wasmBinary?:Uint8Array):Promise<ColorOutput>{if(!doc.precision||doc.duotone){const c=await composite(doc,false,{cancelled,maxEdge});return convertColorOutput(c.getContext('2d')!.getImageData(0,0,c.width,c.height).data,c.width,c.height,profile,options,cancelled,wasmBinary);}validatePrintProfile(profile.bytes,profile.name);if(![0,1,2,3].includes(options.intent)||!Number.isFinite(options.dpi)||options.dpi<1||options.dpi>1200)throw Error('Invalid output settings.');const check=()=>{if(cancelled())throw new DOMException('Export cancelled','AbortError');};check();let raster=await compositePrecisionDocument(doc,wasmBinary);const n=precisionChannels(raster.space);for(const [name,spot] of Object.entries(doc.spotChannels??{})){if(spot.visible===false||!spot.solidity)continue;const plate=doc.savedSelections?.[name];if(!plate)continue;const rgb=spot.color.slice(1).match(/../g)!.map(v=>parseInt(v,16)/255),color=await convertPrecision({width:1,height:1,space:'RGB',depth:8,data:new Float32Array([...rgb,1])},raster.space,raster.depth,profileBytes(doc.precision.profile),wasmBinary),ink={...raster,data:new Float32Array(raster.data.length)};for(let i=0;i<raster.width*raster.height;i++){ink.data.set(color.data.subarray(0,n),i*(n+1));ink.data[i*(n+1)+n]=(1-plate.alpha[i]/255)*spot.solidity/100;}raster=compositePrecision(raster,ink);}if(maxEdge&&Math.max(raster.width,raster.height)>maxEdge){const ratio=maxEdge/Math.max(raster.width,raster.height),width=Math.max(1,Math.round(raster.width*ratio)),height=Math.max(1,Math.round(raster.height*ratio)),original=raster;raster=resamplePrecision(original,width,height,(x,y)=>({x:x*original.width/width,y:y*original.height/height}));}check();const cmyk=profile.space==='CMYK',converted=await convertPrecision(raster,cmyk?'CMYK':'RGB',16,profile.bytes,wasmBinary,{intent:options.intent,blackPoint:options.blackPoint}),channels=cmyk?4:3,pixels=new Uint16Array(converted.width*converted.height*4);for(let i=0;i<converted.width*converted.height;i++){const a=converted.data[i*(channels+1)+channels];for(let c=0;c<channels;c++)pixels[i*4+c]=Math.round(Math.max(0,Math.min(1,converted.data[i*(channels+1)+c]*(cmyk?a:1)))*65535);if(!cmyk)pixels[i*4+3]=Math.round(a*65535);}check();return {pixels,width:converted.width,height:converted.height,profile};}
Photoshot無料Photoshop代替の開発記 / 31IN PRACTICE

エディターで見る。

インクに分けて再び開く / workspace
RSWOP.icm・300ppi・CMYK TIFFの実出力設定。

縮小プレビューと出力値を区別する

縮小プレビューの総インク量は最大171.8%、平均122.6%でした。フルサイズTIFFと最大値が違うのは、統計対象のラスター寸法が異なるためです。本記事のファイル数値はダウンロードした実画素から計算しました。300ppiは解像度タグで、画像の画素数は変わりません。

A4、余白10mm、Scale to Fitの印刷プレビューでは190×126.7mmに配置されました。今回の確認は画面、ファイル構造、画素までで、実プリンターと紙による測定ではありません。再読込の失敗はコード修正で解決しました。次の32回はスマートオブジェクトとリンク元を実際に変更します。

Photoshot無料Photoshop代替の開発記 / 31BEFORE / AFTER

原画像と再読込したダブルトーン。

インクに分けて再び開く / original
01 / AI生成の実例原画像
インクに分けて再び開く / result
02 / 修正後にPSD再読込・二つのインクを復元

AI生成の動作例用写真。編集画面と出力画像は実際のPhotoshotで作成しました。

確認したコードと検査

コード・検査記録・日付 ↗