07余白の自動トリミング書き出し
空いた余白を自分で切り落とし、背景が透けた画像として取り出します。
使いどころ署名画像の添付、書類に貼る印影画像の作成
getImageDatatoDataURLdrawImage

<main class="stage">
<div class="ex is-demo" id="ex">
<div class="ex__card">
<div class="ex__bar">
<p class="ex__label"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ex__ico" aria-hidden="true"><path d="M67.41,142.56C35.71,129.52,45.41,32.07,78,32,111.82,32.05,92,90.26,67.41,142.56Z" opacity="0.2"/><path d="M232,168H63.86c2.66-5.24,5.33-10.63,8-16.11,14.94,1.65,32.58-8.78,52.66-31.14,5,13.46,14.45,30.93,30.58,31.25,9.09.18,18.11-5.2,27.42-16.37C189.31,143.75,203.3,152,232,152a8,8,0,0,0,0-16c-30.43,0-39.43-10.45-40-16.11a7.67,7.67,0,0,0-5.46-7.75,8.14,8.14,0,0,0-9.25,3.49C165.23,134.17,158,136.06,155.38,136c-8.26-.16-16.66-19.52-19.54-33.42a8,8,0,0,0-14.09-3.37C101.54,124.55,88,133.08,79.57,135.29,88.06,116.42,94.4,99.85,98.46,85.9c6.82-23.44,7.32-39.83,1.51-50.1-3-5.38-9.34-11.8-22.06-11.8C61.85,24,49.18,39.18,43.14,65.65c-3.59,15.71-4.18,33.21-1.62,48s7.87,25.55,15.59,31.94c-3.73,7.72-7.53,15.26-11.23,22.41H24a8,8,0,0,0,0,16H37.41c-11.32,21-20.12,35.64-20.26,35.88a8,8,0,1,0,13.71,8.24c.15-.26,11.27-18.79,24.7-44.12H232a8,8,0,0,0,0-16ZM58.74,69.21C62.72,51.74,70.43,40,77.91,40c5.33,0,7.1,1.86,8.13,3.67,3,5.33,6.52,24.19-21.66,86.39C56.12,118.78,53.31,93,58.74,69.21Z"/></svg><span>署名の書き出し</span></p>
<button type="button" class="ex__btn" id="exportBtn"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" class="ex__ico" aria-hidden="true"><path d="M216,48V208H40V48A16,16,0,0,1,56,32H200A16,16,0,0,1,216,48Z" opacity="0.2"/><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg><span>PNGで書き出す</span></button>
</div>
<div class="ex__body">
<div class="ex__pad" id="pad">
<canvas class="ex__ink" id="ink" role="img" aria-label="署名欄"></canvas>
<!-- 미리보기용 署名 — canvas 에 그려지는 SAMPLE 과 같은 좌표를 중점 2차 곡선으로 옮긴 것 -->
<svg class="ex__ghost" viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">
<path class="ex__stroke" d="M30,56 Q30,56 33,46 Q36,36 41,31 Q46,26 50,31 Q54,36 52,44 Q50,52 47,56 Q44,60 48,59 Q52,58 59,52 Q66,46 72,39 Q78,32 82,30 Q86,28 88,34 Q90,40 93,46 Q96,52 100,53"/>
<path class="ex__stroke" d="M112,50 Q112,50 117,42 Q122,34 127,30 Q132,26 136,29 Q140,32 138,39 Q136,46 140,47 Q144,48 150,43 Q156,38 160,33"/>
<path class="ex__stroke" d="M36,70 Q36,70 53,72 Q70,74 90,75 Q110,76 130,74 Q150,72 161,68"/>
</svg>
<span class="ex__crop" id="crop" aria-hidden="true"></span>
</div>
<div class="ex__out">
<span class="ex__outLabel">プレビュー</span>
<div class="ex__frame">
<span class="ex__hint">結果がここに表示されます</span>
<img class="ex__img" id="out" alt="余白を切り取った署名プレビュー">
<!-- プレビュー 루프에서만 보이는 결과 사본 — viewBox 가 インク 바깥선이라 잘린 그림이 된다 -->
<svg class="ex__thumb" viewBox="21 19 149 65" aria-hidden="true">
<path class="ex__stroke" d="M30,56 Q30,56 33,46 Q36,36 41,31 Q46,26 50,31 Q54,36 52,44 Q50,52 47,56 Q44,60 48,59 Q52,58 59,52 Q66,46 72,39 Q78,32 82,30 Q86,28 88,34 Q90,40 93,46 Q96,52 100,53"/>
<path class="ex__stroke" d="M112,50 Q112,50 117,42 Q122,34 127,30 Q132,26 136,29 Q140,32 138,39 Q136,46 140,47 Q144,48 150,43 Q156,38 160,33"/>
<path class="ex__stroke" d="M36,70 Q36,70 53,72 Q70,74 90,75 Q110,76 130,74 Q150,72 161,68"/>
</svg>
</div>
</div>
</div>
<p class="ex__live">
<span class="ex__liveReal" id="live" aria-live="polite">書き出しを押すと外側の余白を切り取ります</span>
<span class="ex__liveDemo ex__liveDemo--before" aria-hidden="true">書き出しを押すと外側の余白を切り取ります</span>
<span class="ex__liveDemo ex__liveDemo--after" aria-hidden="true">外側の余白を切り取ったプレビューです</span>
</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 여백 자동 크롭 내보내기 — 실제 동작은 canvas 2D 가 한다. getImageData 로 알파 채널을 훑어
// 잉크가 있는 픽셀의 바운딩 박스를 구하고, 그 크기의 보조 canvas 에 drawImage 로 잘라 담은 뒤
// toDataURL('image/png') 를 미리보기 <img> 에 넣는다. 배경을 칠하지 않으니 알파가 남아 투명 배경이 된다.
// 격자 미리보기(.is-demo)는 canvas 를 감추고 같은 장면을 CSS @keyframes 로 재현한다 —
// demo.py 는 CSS 애니메이션 시계로 프레임을 뽑으므로 JS 로 그린 그림은 24장이 전부 같다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ex { display: flex; justify-content: center; width: 100%; }
.ex__card {
width: min(100%, 340px); background: $stage-paper; border-radius: $r-card;
box-shadow: $shadow-raised; overflow: hidden;
}
.ex__bar {
display: flex; align-items: center; justify-content: space-between; gap: $sp-2;
background: $subject-ink; padding: $sp-2 $sp-3;
}
.ex__label {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 800; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.3; color: $stage-paper;
}
.ex__ico { width: 14px; height: 14px; flex: 0 0 auto; }
.ex__btn {
display: flex; align-items: center; gap: $sp-1; cursor: pointer;
border: 1px solid $subject-blue; background: $subject-blue; color: $stage-paper;
border-radius: $r-pill; font-family: inherit; font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px); padding: $sp-1 $sp-2; line-height: 1.3;
}
.ex__btn:focus-visible { outline: 3px solid $stage-paper; outline-offset: 2px; }
.ex__body { display: grid; grid-template-columns: 1.6fr 1fr; gap: $sp-2; padding: $sp-2; }
.ex__pad {
position: relative; background: $subject-cream; border-radius: $r-sm;
aspect-ratio: 200 / 100; touch-action: none; overflow: hidden;
}
.ex__ink, .ex__ghost { position: absolute; inset: 0; width: 100%; height: 100%; }
.ex__ghost { display: none; }
.ex__stroke { fill: none; stroke: $color; stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; }
// 크롭 테두리 — 네 변이 잉크 바깥선까지 함께 조여든다. 1px 점선을 scale 로 줄이면 선과 점이 같이
// 굵어지므로 여기서는 절대배치 오버레이의 변(inset)을 움직인다. 실사용에서는 JS 가 잰 바운딩 박스를
// 퍼센트로 넣어 같은 자리로 조인다
.ex__crop {
position: absolute; inset: 0; pointer-events: none;
border: 1px dashed $subject-blue; border-radius: $r-xs;
transition: inset $dur-base $ease-spring;
}
.ex__out { display: flex; flex-direction: column; gap: $sp-1; min-width: 0; }
.ex__outLabel { font-weight: 800; font-size: 9px; line-height: 1.3; color: rgba(23, 20, 26, .72); }
.ex__frame {
position: relative; flex: 1 1 auto; min-height: 0; display: grid; place-items: center;
border: 1px solid rgba(23, 20, 26, .16); border-radius: $r-sm; overflow: hidden;
// 투명 배경을 뜻하는 체크무늬
background-color: $stage-paper;
background-image: repeating-conic-gradient(rgba(23, 20, 26, .1) 0% 25%, transparent 0% 50%);
background-size: 12px 12px;
}
.ex__hint {
padding: 0 $sp-1; text-align: center; font-weight: 700;
font-size: clamp(8px, 2.2vw, 9px); line-height: 1.3; color: rgba(23, 20, 26, .72);
}
.ex__img { display: none; max-width: 100%; max-height: 100%; }
.ex.has-out .ex__img { display: block; }
.ex.has-out .ex__hint { display: none; }
// 결과 사본은 축소되어 들어가므로 선을 조금 굵게 — 원본과 같은 굵기로 보이고 등장 프레임도 또렷하다
.ex__thumb { display: none; position: absolute; inset: $sp-1; }
.ex__thumb .ex__stroke { stroke-width: 6; }
.ex__live {
position: relative; margin: 0; padding: 0 $sp-3 $sp-2; font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px); line-height: 1.3; color: rgba(23, 20, 26, .72);
}
.ex__liveDemo { display: none; position: absolute; left: $sp-3; top: 0; }
// ── 미리보기 루프: 0~10% 버튼이 자기 중심에서 눌리고, 12~44% 크롭 테두리가 판 전체에서
// 잉크 바깥선까지 네 변이 함께 조여든다. 46% 에 잘린 결과가 오른쪽 칸에 컷으로 나타나
// 62% 까지 scale(.78) → scale(1) 로 팝한다 — 앞 프레임과의 면적차가 여기서 가장 커서 poster 가 된다.
// 64~81% 는 「여백을 잘라냈다」 문구가 컷으로 바뀌며 올라오고, 68~84% 결과가 작아지다 꺼지고,
// 84~92% 테두리가 판 전체로 벌어져 시작 상태로 돌아온다 (마지막 프레임은 2초의 95.8% 지점이다).
.ex.is-demo .ex__ink { visibility: hidden; }
.ex.is-demo .ex__ghost { display: block; }
.ex.is-demo .ex__thumb { display: block; }
.ex.is-demo .ex__btn {
animation-name: exPress;
animation-duration: $duration;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes exPress {
0% { transform: scale(1); }
4% { transform: scale(.9); }
10%, 100% { transform: scale(1); }
}
.ex.is-demo .ex__crop {
animation-name: exCrop;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes exCrop {
0%, 12% { inset: 0; }
44%, 84% { inset: 19% 15% 16% 11%; }
92%, 100% { inset: 0; }
}
// 결과는 나타날 때만 컷(steps)이고 커지는 건 자기 중심 팝이다 — transform-origin 은 기본값 50% 50%.
// 등장은 46% 에 이미 scale(.78) 인 채로 컷해 한 칸에 거의 다 나타나고(면적차 최대 = poster),
// 퇴장은 68~82% 에 걸쳐 scale(.5) 까지 천천히 작아진 뒤 84% 에 꺼져 면적차가 등장 때보다 작다.
// 실측 2026-09-12: 퇴장을 크롭 테두리 벌어짐과 겹쳐 두면 그쪽이 poster 를 가져갔다 (1.69 대 1.13)
.ex.is-demo .ex__thumb {
animation-name: exShow, exPop, exWipe;
animation-duration: $duration, $duration, $duration;
animation-timing-function: steps(1, end), $ease-pop, $easing;
animation-iteration-count: infinite, infinite, infinite;
animation-fill-mode: backwards, backwards, backwards;
}
@keyframes exShow {
0%, 45% { opacity: 0; }
46%, 100% { opacity: 1; }
}
@keyframes exPop {
0%, 45% { transform: scale(.86); }
62%, 100% { transform: scale(1); }
}
// 퇴장은 clip-path 위프 — 프레임마다 지워지는 띠만 변해 면적차가 등장의 컷보다 작다.
// 위프는 컷(steps)과 같은 애니메이션에 묶지 않는다 — steps 는 clip-path 보간을 죽인다
@keyframes exWipe {
0%, 70% { clip-path: inset(0 0 0 0); }
84%, 100% { clip-path: inset(0 100% 0 0); }
}
.ex.is-demo .ex__hint {
animation-name: exHint;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes exHint {
0%, 45% { opacity: 1; }
46%, 85% { opacity: 0; }
86%, 100% { opacity: 1; }
}
// 안내 문구는 칸 두 개를 steps(1, end) 로 갈아 끼운다 — 반투명 겹침이 없다.
// 바뀐 문구는 컷 옆에 작은 올라오기를 함께 둬 컷만으로는 비는 프레임을 채운다
.ex.is-demo .ex__liveReal { color: transparent; }
.ex.is-demo .ex__liveDemo { display: block; }
.ex.is-demo .ex__liveDemo--before {
animation-name: exSayBefore;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ex.is-demo .ex__liveDemo--after {
animation-name: exSayAfter;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 문구가 되돌아오는 컷은 94% — 크롭 테두리가 다 벌어진 뒤라 다른 움직임과 겹치지 않고,
// 마지막 프레임(95.8%)이 첫 프레임과 같은 문구가 되어 루프 이음매도 맞는다
@keyframes exSayBefore {
0%, 63% { opacity: 1; }
64%, 93% { opacity: 0; }
94%, 100% { opacity: 1; }
}
@keyframes exSayAfter {
0%, 63% { opacity: 0; }
64%, 93% { opacity: 1; }
94%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.ex__bar { padding: $sp-1 $sp-2; }
.ex__body { gap: $sp-1; padding: $sp-1; }
.ex__live { padding: 0 $sp-2 $sp-1; }
.ex__liveDemo { left: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.ex.is-demo .ex__btn,
.ex.is-demo .ex__crop,
.ex.is-demo .ex__thumb,
.ex.is-demo .ex__hint,
.ex.is-demo .ex__liveDemo { animation: none !important; }
// 움직임 없이 「잘라낸 결과가 나와 있는」 최종 상태로 세운다
.ex.is-demo .ex__crop { inset: 19% 15% 16% 11% !important; }
.ex.is-demo .ex__thumb {
opacity: 1 !important; transform: scale(1) !important; clip-path: inset(0 0 0 0) !important;
}
.ex.is-demo .ex__hint { opacity: 0 !important; }
.ex.is-demo .ex__liveDemo--before { opacity: 0 !important; }
.ex.is-demo .ex__liveDemo--after { opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ex {
display: flex;
justify-content: center;
width: 100%;
}
.ex__card {
width: min(100%, 340px);
background: #ffffff;
border-radius: 16px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.ex__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: #17141a;
padding: 8px 12px;
}
.ex__label {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.3;
color: #ffffff;
}
.ex__ico {
width: 14px;
height: 14px;
flex: 0 0 auto;
}
.ex__btn {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
border: 1px solid #2f6df6;
background: #2f6df6;
color: #ffffff;
border-radius: 999px;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
padding: 4px 8px;
line-height: 1.3;
}
.ex__btn:focus-visible {
outline: 3px solid #ffffff;
outline-offset: 2px;
}
.ex__body {
display: grid;
grid-template-columns: 1.6fr 1fr;
gap: 8px;
padding: 8px;
}
.ex__pad {
position: relative;
background: #fff7e6;
border-radius: 8px;
aspect-ratio: 200/100;
touch-action: none;
overflow: hidden;
}
.ex__ink, .ex__ghost {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ex__ghost {
display: none;
}
.ex__stroke {
fill: none;
stroke: #17141a;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.ex__crop {
position: absolute;
inset: 0;
pointer-events: none;
border: 1px dashed #2f6df6;
border-radius: 4px;
transition: inset 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ex__out {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.ex__outLabel {
font-weight: 800;
font-size: 9px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ex__frame {
position: relative;
flex: 1 1 auto;
min-height: 0;
display: grid;
place-items: center;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 8px;
overflow: hidden;
background-color: #ffffff;
background-image: repeating-conic-gradient(rgba(23, 20, 26, 0.1) 0% 25%, transparent 0% 50%);
background-size: 12px 12px;
}
.ex__hint {
padding: 0 4px;
text-align: center;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 9px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ex__img {
display: none;
max-width: 100%;
max-height: 100%;
}
.ex.has-out .ex__img {
display: block;
}
.ex.has-out .ex__hint {
display: none;
}
.ex__thumb {
display: none;
position: absolute;
inset: 4px;
}
.ex__thumb .ex__stroke {
stroke-width: 6;
}
.ex__live {
position: relative;
margin: 0;
padding: 0 12px 8px;
font-weight: 700;
font-size: clamp(8px, 2.2vw, 10px);
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ex__liveDemo {
display: none;
position: absolute;
left: 12px;
top: 0;
}
.ex.is-demo .ex__ink {
visibility: hidden;
}
.ex.is-demo .ex__ghost {
display: block;
}
.ex.is-demo .ex__thumb {
display: block;
}
.ex.is-demo .ex__btn {
animation-name: exPress;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes exPress {
0% {
transform: scale(1);
}
4% {
transform: scale(0.9);
}
10%, 100% {
transform: scale(1);
}
}
.ex.is-demo .ex__crop {
animation-name: exCrop;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes exCrop {
0%, 12% {
inset: 0;
}
44%, 84% {
inset: 19% 15% 16% 11%;
}
92%, 100% {
inset: 0;
}
}
.ex.is-demo .ex__thumb {
animation-name: exShow, exPop, exWipe;
animation-duration: 2s, 2s, 2s;
animation-timing-function: steps(1, end), cubic-bezier(0.2, 1.4, 0.4, 1), ease-out;
animation-iteration-count: infinite, infinite, infinite;
animation-fill-mode: backwards, backwards, backwards;
}
@keyframes exShow {
0%, 45% {
opacity: 0;
}
46%, 100% {
opacity: 1;
}
}
@keyframes exPop {
0%, 45% {
transform: scale(0.86);
}
62%, 100% {
transform: scale(1);
}
}
@keyframes exWipe {
0%, 70% {
clip-path: inset(0 0 0 0);
}
84%, 100% {
clip-path: inset(0 100% 0 0);
}
}
.ex.is-demo .ex__hint {
animation-name: exHint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes exHint {
0%, 45% {
opacity: 1;
}
46%, 85% {
opacity: 0;
}
86%, 100% {
opacity: 1;
}
}
.ex.is-demo .ex__liveReal {
color: transparent;
}
.ex.is-demo .ex__liveDemo {
display: block;
}
.ex.is-demo .ex__liveDemo--before {
animation-name: exSayBefore;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ex.is-demo .ex__liveDemo--after {
animation-name: exSayAfter;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes exSayBefore {
0%, 63% {
opacity: 1;
}
64%, 93% {
opacity: 0;
}
94%, 100% {
opacity: 1;
}
}
@keyframes exSayAfter {
0%, 63% {
opacity: 0;
}
64%, 93% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ex__bar {
padding: 4px 8px;
}
.ex__body {
gap: 4px;
padding: 4px;
}
.ex__live {
padding: 0 8px 4px;
}
.ex__liveDemo {
left: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.ex.is-demo .ex__btn,
.ex.is-demo .ex__crop,
.ex.is-demo .ex__thumb,
.ex.is-demo .ex__hint,
.ex.is-demo .ex__liveDemo {
animation: none !important;
}
.ex.is-demo .ex__crop {
inset: 19% 15% 16% 11% !important;
}
.ex.is-demo .ex__thumb {
opacity: 1 !important;
transform: scale(1) !important;
clip-path: inset(0 0 0 0) !important;
}
.ex.is-demo .ex__hint {
opacity: 0 !important;
}
.ex.is-demo .ex__liveDemo--before {
opacity: 0 !important;
}
.ex.is-demo .ex__liveDemo--after {
opacity: 1 !important;
}
}var ex = document.getElementById('ex');
var pad = document.getElementById('pad');
var ink = document.getElementById('ink');
var out = document.getElementById('out');
var crop = document.getElementById('crop');
var live = document.getElementById('live');
var ctx = ink.getContext('2d');
var strokes = [], current = null;
var TRIM_LABEL = '余白 横%wpx・縦%hpx を切り取ったプレビューです';
var EMPTY_LABEL = '署名が空なので切り取るものがありません';
// 마운트 때 깔아 두는 예시 署名 — 200×100 칸 기준 좌표. 판 크기에 맞춰 늘려 그린다
var SAMPLE = [
[[30, 56], [36, 36], [46, 26], [54, 36], [50, 52], [44, 60], [52, 58], [66, 46], [78, 32], [86, 28], [90, 40], [96, 52], [104, 54]],
[[112, 50], [122, 34], [132, 26], [140, 32], [136, 46], [144, 48], [156, 38], [164, 28]],
[[36, 70], [70, 74], [110, 76], [150, 72], [172, 64]]
];
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function resize() {
var r = pad.getBoundingClientRect(), dpr = window.devicePixelRatio || 1;
ink.width = Math.round(r.width * dpr);
ink.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}
function drawPoints(pts) {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (var i = 1; i < pts.length; i++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다
ctx.quadraticCurveTo(pts[i - 1][0], pts[i - 1][1], (pts[i][0] + pts[i - 1][0]) / 2, (pts[i][1] + pts[i - 1][1]) / 2);
}
ctx.stroke();
}
function repaint() {
var r = pad.getBoundingClientRect();
ctx.clearRect(0, 0, ink.width, ink.height);
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.lineWidth = 4;
ctx.strokeStyle = '#17141a';
for (var s = 0; s < SAMPLE.length; s++) {
var scaled = [];
for (var i = 0; i < SAMPLE[s].length; i++) {
scaled.push([SAMPLE[s][i][0] * r.width / 200, SAMPLE[s][i][1] * r.height / 100]);
}
drawPoints(scaled);
}
for (var u = 0; u < strokes.length; u++) { drawPoints(strokes[u]); }
}
function exportCrop() {
ex.classList.remove('is-demo');
var pixels = ctx.getImageData(0, 0, ink.width, ink.height);
var data = pixels.data;
var minX = ink.width, minY = ink.height, maxX = -1, maxY = -1;
for (var y = 0; y < ink.height; y++) {
for (var x = 0; x < ink.width; x++) {
// 배경을 칠하지 않았으므로 잉크가 없는 자리는 알파가 0 이다 — 알파 채널만 훑으면 바운딩 박스가 나온다
if (data[(y * ink.width + x) * 4 + 3] > 8) {
if (x < minX) { minX = x; }
if (x > maxX) { maxX = x; }
if (y < minY) { minY = y; }
if (y > maxY) { maxY = y; }
}
}
}
if (maxX < 0) { live.textContent = EMPTY_LABEL; return; }
var dpr = window.devicePixelRatio || 1;
var edge = Math.round(6 * dpr);
minX = Math.max(0, minX - edge);
minY = Math.max(0, minY - edge);
maxX = Math.min(ink.width - 1, maxX + edge);
maxY = Math.min(ink.height - 1, maxY + edge);
var sw = maxX - minX + 1, sh = maxY - minY + 1;
// 잰 바운딩 박스를 퍼센트로 바꿔 크롭 테두리를 그 자리로 조인다 — 무엇이 잘렸는지 눈에 보인다
crop.style.inset = (minY / ink.height * 100).toFixed(1) + '% ' +
((ink.width - maxX - 1) / ink.width * 100).toFixed(1) + '% ' +
((ink.height - maxY - 1) / ink.height * 100).toFixed(1) + '% ' +
(minX / ink.width * 100).toFixed(1) + '%';
var cropped = document.createElement('canvas');
cropped.width = sw;
cropped.height = sh;
// 배경을 칠하지 않고 잘린 자리만 옮기므로 알파가 그대로 남는다 — 투명 배경 PNG 가 된다
cropped.getContext('2d').drawImage(ink, minX, minY, sw, sh, 0, 0, sw, sh);
// 데모는 iframe 안에서 도니 <a download> 는 동작하지 않는다. 결과는 プレビュー <img> 까지만 보여 준다
out.src = cropped.toDataURL('image/png');
ex.classList.add('has-out');
live.textContent = TRIM_LABEL
.replace('%w', String(Math.round((ink.width - sw) / dpr)))
.replace('%h', String(Math.round((ink.height - sh) / dpr)));
}
function toLocal(e) {
var r = ink.getBoundingClientRect();
return [e.clientX - r.left, e.clientY - r.top];
}
pad.addEventListener('pointerdown', function (e) {
ex.classList.remove('is-demo');
crop.style.inset = '';
current = [toLocal(e)];
strokes.push(current);
pad.setPointerCapture(e.pointerId);
});
pad.addEventListener('pointermove', function (e) {
if (!current) { return; }
current.push(toLocal(e));
repaint();
});
window.addEventListener('pointerup', function () { current = null; });
document.getElementById('exportBtn').addEventListener('click', exportCrop);
window.addEventListener('resize', resize);
resize();// SignatureExport — vanilla 판(run/287/demo/07-signature-export-crop/index.html) 의 React 포트.
// getImageData 로 알파 채널을 훑어 잉크의 바운딩 박스를 구하고, 그 크기의 보조 canvas 에 drawImage 로
// 잘라 담은 뒤 toDataURL('image/png') 을 미리보기 <img> 에 넣는다. 배경을 칠하지 않으니 알파가
// 그대로 남아 투명 배경 PNG 가 된다. 값·수치·키프레임은 vanilla 와 같다.
import { useCallback, useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SignatureExport.module.scss";
export type SignatureExportProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Point = [number, number];
// useRef 는 T | null 을 돌려주므로, 초기값이 있는 상자는 이 타입으로 좁혀 쓴다
type Box<T> = { current: T };
const TRIM_LABEL = "여백 가로 %wpx · 세로 %hpx 를 잘라낸 미리보기입니다";
const EMPTY_LABEL = "서명이 비어 있어 잘라낼 것이 없습니다";
const READY_LABEL = "내보내기를 누르면 바깥 여백을 잘라냅니다";
// 마운트 때 깔아 두는 예시 서명 — 200×100 칸 기준 좌표. 판 크기에 맞춰 늘려 그린다
const SAMPLE: Point[][] = [
[[30, 56], [36, 36], [46, 26], [54, 36], [50, 52], [44, 60], [52, 58], [66, 46], [78, 32], [86, 28], [90, 40], [96, 52], [104, 54]],
[[112, 50], [122, 34], [132, 26], [140, 32], [136, 46], [144, 48], [156, 38], [164, 28]],
[[36, 70], [70, 74], [110, 76], [150, 72], [172, 64]],
];
function drawPoints(ctx: CanvasRenderingContext2D, pts: Point[]): void {
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i++) {
// 두 점의 중점을 제어점으로 삼아 꺾임을 둥글게 잇는다
ctx.quadraticCurveTo(pts[i - 1][0], pts[i - 1][1], (pts[i][0] + pts[i - 1][0]) / 2, (pts[i][1] + pts[i - 1][1]) / 2);
}
ctx.stroke();
}
export default function SignatureExport({
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: SignatureExportProps) {
const padRef = useRef<HTMLDivElement | null>(null);
const inkRef = useRef<HTMLCanvasElement | null>(null);
const strokesRef = useRef<Point[][]>([]) as Box<Point[][]>;
const currentRef = useRef<Point[] | null>(null);
const [demo, setDemo] = useState(true);
const [src, setSrc] = useState("");
const [cropInset, setCropInset] = useState("");
const [message, setMessage] = useState(READY_LABEL);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const repaint = useCallback(() => {
const pad = padRef.current;
const canvas = inkRef.current;
if (!pad || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const r = pad.getBoundingClientRect();
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = 4;
ctx.strokeStyle = color;
for (const pts of SAMPLE) {
const scaled: Point[] = pts.map((p) => [p[0] * r.width / 200, p[1] * r.height / 100]);
drawPoints(ctx, scaled);
}
for (const pts of strokesRef.current) drawPoints(ctx, pts);
}, [color]);
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
const resize = useCallback(() => {
const pad = padRef.current;
const canvas = inkRef.current;
if (!pad || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const r = pad.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.round(r.width * dpr);
canvas.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
repaint();
}, [repaint]);
useEffect(() => {
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, [resize]);
useEffect(() => {
function onUp(): void {
currentRef.current = null;
}
window.addEventListener("pointerup", onUp);
return () => window.removeEventListener("pointerup", onUp);
}, []);
function exportCrop(): void {
setDemo(false);
const canvas = inkRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
let minX = canvas.width;
let minY = canvas.height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
// 배경을 칠하지 않았으므로 잉크가 없는 자리는 알파가 0 이다 — 알파 채널만 훑으면 바운딩 박스가 나온다
if (data[(y * canvas.width + x) * 4 + 3] > 8) {
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
}
if (maxX < 0) {
setMessage(EMPTY_LABEL);
return;
}
const dpr = window.devicePixelRatio || 1;
const edge = Math.round(6 * dpr);
minX = Math.max(0, minX - edge);
minY = Math.max(0, minY - edge);
maxX = Math.min(canvas.width - 1, maxX + edge);
maxY = Math.min(canvas.height - 1, maxY + edge);
const sw = maxX - minX + 1;
const sh = maxY - minY + 1;
// 잰 바운딩 박스를 퍼센트로 바꿔 크롭 테두리를 그 자리로 조인다 — 무엇이 잘렸는지 눈에 보인다
setCropInset(
`${(minY / canvas.height * 100).toFixed(1)}% ${((canvas.width - maxX - 1) / canvas.width * 100).toFixed(1)}% ` +
`${((canvas.height - maxY - 1) / canvas.height * 100).toFixed(1)}% ${(minX / canvas.width * 100).toFixed(1)}%`
);
const cropped = document.createElement("canvas");
cropped.width = sw;
cropped.height = sh;
const cropCtx = cropped.getContext("2d");
if (!cropCtx) return;
// 배경을 칠하지 않고 잘린 자리만 옮기므로 알파가 그대로 남는다 — 투명 배경 PNG 가 된다
cropCtx.drawImage(canvas, minX, minY, sw, sh, 0, 0, sw, sh);
// 데모는 iframe 안에서 도니 <a download> 는 동작하지 않는다. 결과는 미리보기 <img> 까지만 보여 준다
setSrc(cropped.toDataURL("image/png"));
setMessage(
TRIM_LABEL
.replace("%w", String(Math.round((canvas.width - sw) / dpr)))
.replace("%h", String(Math.round((canvas.height - sh) / dpr)))
);
}
function toLocal(event: ReactPointerEvent<HTMLDivElement>): Point | null {
const canvas = inkRef.current;
if (!canvas) return null;
const r = canvas.getBoundingClientRect();
return [event.clientX - r.left, event.clientY - r.top];
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
setDemo(false);
setCropInset("");
const point = toLocal(event);
const pad = padRef.current;
if (!point || !pad) return;
currentRef.current = [point];
strokesRef.current.push(currentRef.current);
pad.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const current = currentRef.current;
if (!current) return;
const point = toLocal(event);
if (!point) return;
current.push(point);
repaint();
}
const cls = [styles.root, demo ? styles.isDemo : "", src ? styles.hasOut : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<div className={cls} style={vars}>
<div className={styles.card}>
<div className={styles.bar}>
<p className={styles.label}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M67.41,142.56C35.71,129.52,45.41,32.07,78,32,111.82,32.05,92,90.26,67.41,142.56Z" opacity="0.2"/><path d="M232,168H63.86c2.66-5.24,5.33-10.63,8-16.11,14.94,1.65,32.58-8.78,52.66-31.14,5,13.46,14.45,30.93,30.58,31.25,9.09.18,18.11-5.2,27.42-16.37C189.31,143.75,203.3,152,232,152a8,8,0,0,0,0-16c-30.43,0-39.43-10.45-40-16.11a7.67,7.67,0,0,0-5.46-7.75,8.14,8.14,0,0,0-9.25,3.49C165.23,134.17,158,136.06,155.38,136c-8.26-.16-16.66-19.52-19.54-33.42a8,8,0,0,0-14.09-3.37C101.54,124.55,88,133.08,79.57,135.29,88.06,116.42,94.4,99.85,98.46,85.9c6.82-23.44,7.32-39.83,1.51-50.1-3-5.38-9.34-11.8-22.06-11.8C61.85,24,49.18,39.18,43.14,65.65c-3.59,15.71-4.18,33.21-1.62,48s7.87,25.55,15.59,31.94c-3.73,7.72-7.53,15.26-11.23,22.41H24a8,8,0,0,0,0,16H37.41c-11.32,21-20.12,35.64-20.26,35.88a8,8,0,1,0,13.71,8.24c.15-.26,11.27-18.79,24.7-44.12H232a8,8,0,0,0,0-16ZM58.74,69.21C62.72,51.74,70.43,40,77.91,40c5.33,0,7.1,1.86,8.13,3.67,3,5.33,6.52,24.19-21.66,86.39C56.12,118.78,53.31,93,58.74,69.21Z"/></svg>
<span>서명 내보내기</span>
</p>
<button type="button" className={styles.btn} onClick={exportCrop}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" fill="currentColor" className={styles.ico} aria-hidden="true"><path d="M216,48V208H40V48A16,16,0,0,1,56,32H200A16,16,0,0,1,216,48Z" opacity="0.2"/><path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z"/></svg>
<span>PNG 로 내보내기</span>
</button>
</div>
<div className={styles.body}>
<div className={styles.pad} ref={padRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove}>
<canvas className={styles.ink} ref={inkRef} role="img" aria-label="서명 칸"></canvas>
{/* 미리보기용 서명 — canvas 에 그려지는 SAMPLE 과 같은 좌표를 중점 2차 곡선으로 옮긴 것 */}
<svg className={styles.ghost} viewBox="0 0 200 100" preserveAspectRatio="none" aria-hidden="true">
<path className={styles.stroke} d="M30,56 Q30,56 33,46 Q36,36 41,31 Q46,26 50,31 Q54,36 52,44 Q50,52 47,56 Q44,60 48,59 Q52,58 59,52 Q66,46 72,39 Q78,32 82,30 Q86,28 88,34 Q90,40 93,46 Q96,52 100,53"/>
<path className={styles.stroke} d="M112,50 Q112,50 117,42 Q122,34 127,30 Q132,26 136,29 Q140,32 138,39 Q136,46 140,47 Q144,48 150,43 Q156,38 160,33"/>
<path className={styles.stroke} d="M36,70 Q36,70 53,72 Q70,74 90,75 Q110,76 130,74 Q150,72 161,68"/>
</svg>
<span className={styles.crop} style={{ inset: cropInset }} aria-hidden="true"></span>
</div>
<div className={styles.out}>
<span className={styles.outLabel}>미리보기</span>
<div className={styles.frame}>
<span className={styles.hint}>결과가 여기 보입니다</span>
{src ? <img className={styles.img} src={src} alt="여백을 잘라낸 서명 미리보기"/> : null}
{/* 미리보기 루프에서만 보이는 결과 사본 — viewBox 가 잉크 바깥선이라 잘린 그림이 된다 */}
<svg className={styles.thumb} viewBox="21 19 149 65" aria-hidden="true">
<path className={styles.stroke} d="M30,56 Q30,56 33,46 Q36,36 41,31 Q46,26 50,31 Q54,36 52,44 Q50,52 47,56 Q44,60 48,59 Q52,58 59,52 Q66,46 72,39 Q78,32 82,30 Q86,28 88,34 Q90,40 93,46 Q96,52 100,53"/>
<path className={styles.stroke} d="M112,50 Q112,50 117,42 Q122,34 127,30 Q132,26 136,29 Q140,32 138,39 Q136,46 140,47 Q144,48 150,43 Q156,38 160,33"/>
<path className={styles.stroke} d="M36,70 Q36,70 53,72 Q70,74 90,75 Q110,76 130,74 Q150,72 161,68"/>
</svg>
</div>
</div>
</div>
<p className={styles.live}>
<span className={styles.liveReal} aria-live="polite">{message}</span>
<span className={`${styles.liveDemo} ${styles.sayBefore}`} aria-hidden="true">내보내기를 누르면 바깥 여백을 잘라냅니다</span>
<span className={`${styles.liveDemo} ${styles.sayAfter}`} aria-hidden="true">바깥 여백을 잘라낸 미리보기입니다</span>
</p>
</div>
</div>
</main>
);
}07 余白の自動トリミング書き出し — 計測 累積面積 5.496% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · canvas 手書きサインと描画UI 9選 — ライブラリなし
このデモが属する記事
canvas 手書きサインと描画UI 9選 — ライブラリなしcanvas 手書きサインとは、契約書や同意書で手書きの署名を受け取る入力欄のことです。この9つはライブラリなしで、ブラウザ標準のcanvasとPointer Eventsだけを使って作ってあります。ファイルを貼り付ければ、筆圧による太さ・ この記事のデモをすべて見る

