실제 제작 소스에서 찾은 질문을 독립 합성 코드로 다시 만들었다. 전체 구현과 실행 예제, 테스트를 함께 공개하며 원래 업체의 프로그램과 자료는 연결하지 않는다.

이미지와 주석이 한 변환을 쓴다

원본 크기와 화면 크기로 배율을 구하고 정렬 위치에 따라 이동값을 계산한다. cover는 큰 배율로 채워 일부가 잘릴 수 있고 contain은 작은 배율로 전체를 넣어 여백이 생긴다. box는 원본 영역에 같은 배율과 이동을 적용한다. 화면 폭 비율만 따로 곱하는 근사식을 두지 않았다.

실제 광고 서비스의 캡처는 포함하지 않는다. 원래 코드에서 찾은 위치 설명의 질문을 독립 도형으로 재현했다. 이 모듈이 현재 광고 상품의 위치와 성과를 확인해 주지는 않는다. 도형과 숫자가 가리키는 관계만 검사할 수 있다.

잘린 부분을 원래 영역과 구별한다

visible은 전체 box와 화면의 교집합이며 완전히 사라지면 null이다. 이 경우 label도 null이다. 남은 라벨은 선언한 너비·높이를 기준으로 화면 안에 제한한다. 부분 크롭은 전체 상자를 잃지 않고 보이는 너비를 따로 남겨 출력에서 확인할 수 있게 했다.

viewportToSource는 같은 변환을 거꾸로 적용하고 contain의 여백은 이미지 점이 아니라고 돌려준다. 정규화한 object position도 입력할 수 있지만 범위 밖 값은 거절한다. 실제 DOM의 변환 행렬과 이미지 로딩을 읽는 기능은 아니다.

직접 바꾸는 것은 합성 캔버스다

아래 예제에서 fit, 캔버스 비율과 표시할 영역을 바꿀 수 있다. 전체 코어를 그대로 인라인에 넣었고 SVG 도형과 표시를 같은 결과에 연결했다. 두 언어 각각 18조합의 이벤트를 DOM 대역에서 실행해 순수 함수 결과와 비교했다. 기록만 바꿔 보여 주는 영상은 아니다.

반면 실제 글꼴 폭과 긴 번역 문장의 줄바꿈은 측정하지 않는다. 라벨이 계산상 안쪽에 있다는 사실과 읽기 편하다는 판단은 다르다. 실제 모바일·확대·보조 기술 검수는 통합 후 수행해야 한다.

코드와 구현 살펴보기

독립 합성 전체 구현

// Independent synthetic geometry. No image capture, network, DOM or font measurement.
function finite(value,min,max){if(typeof value!=='number'||!Number.isFinite(value)||value<min||value>max)throw Error('bounded finite number');return value;}
function size(value){if(!value||Object.keys(value).sort().join(',')!=='height,width')throw Error('size fields');return{width:finite(value.width,1,10000),height:finite(value.height,1,10000)};}
function clamp(n,lo,hi){return Math.min(hi,Math.max(lo,n));}
export function mapRegion({source,viewport,region,fit='contain',position={x:0.5,y:0.5},label={width:120,height:32,gap:8}}){
 source=size(source);viewport=size(viewport);
 if(!['cover','contain'].includes(fit))throw Error('fit');
 if(!position||Object.keys(position).sort().join(',')!=='x,y')throw Error('position');
 const px=finite(position.x,0,1),py=finite(position.y,0,1);
 if(!region||Object.keys(region).sort().join(',')!=='height,width,x,y')throw Error('region');
 const x=finite(region.x,0,source.width),y=finite(region.y,0,source.height),width=finite(region.width,0.001,source.width),height=finite(region.height,0.001,source.height);
 if(x+width>source.width||y+height>source.height)throw Error('source region bounds');
 if(!label||Object.keys(label).sort().join(',')!=='gap,height,width')throw Error('label');
 const lw=finite(label.width,1,viewport.width),lh=finite(label.height,1,viewport.height),gap=finite(label.gap,0,100);
 const scale=(fit==='cover'?Math.max:Math.min)(viewport.width/source.width,viewport.height/source.height);
 const image={x:(viewport.width-source.width*scale)*px,y:(viewport.height-source.height*scale)*py,width:source.width*scale,height:source.height*scale};
 const box={x:image.x+x*scale,y:image.y+y*scale,width:width*scale,height:height*scale};
 const left=Math.max(0,box.x),top=Math.max(0,box.y),right=Math.min(viewport.width,box.x+box.width),bottom=Math.min(viewport.height,box.y+box.height);
 const visible=right>left&&bottom>top?{x:left,y:top,width:right-left,height:bottom-top}:null;
 const mappedLabel=visible?{x:clamp(visible.x,0,viewport.width-lw),y:clamp(visible.y-lh-gap>=0?visible.y-lh-gap:visible.y+visible.height+gap,0,viewport.height-lh),width:lw,height:lh}:null;
 return{source,viewport,fit,scale,image,box,visible,label:mappedLabel,cropped:!visible||visible.width<box.width-1e-9||visible.height<box.height-1e-9};
}
export function viewportToSource(point,model){
 const x=finite(point.x,0,model.viewport.width),y=finite(point.y,0,model.viewport.height);
 const sx=(x-model.image.x)/model.scale,sy=(y-model.image.y)/model.scale;
 return sx<0||sy<0||sx>model.source.width||sy>model.source.height?null:{x:sx,y:sy};
}
export const syntheticSource={width:800,height:600};
export const syntheticRegions={center:{x:300,y:250,width:200,height:100},top:{x:20,y:0,width:120,height:40},edge:{x:700,y:200,width:100,height:200}};
export function exampleModel(fit='cover',shape='wide',region='center'){
 const viewports={wide:{width:640,height:360},square:{width:360,height:360},portrait:{width:300,height:480}};
 if(!viewports[shape]||!syntheticRegions[region])throw Error('example selection');
 return mapRegion({source:syntheticSource,viewport:viewports[shape],region:syntheticRegions[region],fit});
}

부분 노출을 검사할 때의 기준

세로 캔버스에서 원본의 x=150 영역은 화면의 -50에서 시작하며 전체 너비 160 중 110만 보인다. 중심 영역은 같은 조건에서도 모두 보일 수 있다. 부분 크롭 테스트는 실제로 잘리는 원본 영역을 써야 한다. 실패한 가정을 고쳐 검산 조건을 정확히 정했다.

이 반례는 실행 기록과 테스트로 다시 확인할 수 있다. 다른 업무에 적용할 때는 이름만 바꾸지 말고 입력의 의미와 실패 정책이 같은지 먼저 확인해야 한다. 작은 독립 함수는 운영 프로그램 전체를 대신하지 않는다.

공개 코드와 ZIP은 같은 검토 파일을 사용한다. 실제 업체의 자료를 가져오거나 외부 시스템을 실행하지 않은 상태에서도 핵심 판단을 확인할 수 있도록 만들었다. 새로운 연결을 추가하면 그 연결의 결과는 별도로 기록해야 한다.

직접 확인한 실행과 남겨 둔 경계

소스 폴더와 ZIP을 새로 푼 폴더에서 각각 15개 테스트가 통과했다. 실행 예제의 실제 반환값도 records.json으로 남겼다. 공개 페이지의 코드는 내려받기 구현과 대조할 수 있다. 과거 프로젝트의 완료 보고와 테스트 수치를 이번 실행의 증거로 가져오지 않았다.

아래 화면은 실제 인라인 코드로 합성 상태를 바꾼다. 이벤트 스크립트는 DOM 대역에서도 실행해 결과를 확인했다. 이 검수는 실제 브라우저의 렌더링과 물리적 기기 조작을 포함하지 않는다. 실서비스 통합의 결과는 별도 근거로 확인해야 한다.

이 프레임 안의 합성 상태만 바꿉니다. 실제 회사 업무나 외부 전송과 연결되지 않습니다.

내려받아 적용하기 전에

ZIP에는 전체 코어와 테스트, 실행기, 저장된 결과와 사용 안내가 있다. 라이브 예제는 두 언어 HTML도 포함한다. 수정과 재사용이 가능한 새 합성 코드이며 고객 원본 이미지, 실데이터와 계정 정보는 없다. 실제 프로그램을 실행하는 지시와 외부 전송도 넣지 않았다.

적용할 때는 먼저 단위와 입력 출처, 실패한 경우의 안내를 실제 업무에 맞춰 정해야 한다. 테스트 통과가 모든 입력 사실과 운영 권한을 보장하지는 않는다. 필요한 인증·저장·전송과 화면 검수는 작은 계약 바깥의 일로 남긴다. 나는 재사용할 코드와 함께 어떤 판단을 다시 해야 하는지도 읽을 수 있게 공개한다.

구현·테스트·실행 예제 ZIP 받기
독립 합성 코드, 수정·재사용 가능