SVG를 Canvas에 그리기: PNG 내보내기 전에 로드 확인하기
Image.decode로 SVG 전체를 로드하고 정한 비트맵 크기로 그립니다. CORS와 PNG 픽셀 읽기를 테스트하고 편집 가능한 원본은 내보낸 픽셀과 따로 보존하세요.
목차
SVG 전체를 캔버스에 그리려면 이미지로 로드하고 image.decode()가 끝날 때까지 기다린 다음 해당 이미지를 drawImage()에 전달하세요. 그 후 PNG 내보내기를 별도로 확인해야 합니다. 이미지가 캔버스에 보여도 브라우저가 픽셀을 읽거나 저장하는 작업을 차단할 수 있습니다.
이 방법은 SVG 문서를 이미지로 그립니다. 캔버스에 편집 가능한 SVG 패스를 제공하는 것은 아닙니다. 선택한 윤곽선만 재구성하고 싶다면 Path2D SVG 가이드를 사용하세요. 어떤 표시 방식을 선택하든 SVG 원본은 보존하세요.
디코딩, 그리기, 내보내기 구분하기
HTMLImageElement는 canvas drawImage()에 전달할 수 있는 입력입니다. 대상 너비와 높이가 그려지는 크기를 제어합니다. Image.decode()는 이미지 디코딩이 완료되면 이행되는 Promise를 반환합니다. 요청 실패나 디코딩 문제로 거부될 수도 있습니다.
디코딩 성공은 첫 번째 확인 단계일 뿐입니다. CORS 권한 없이 로드한 다른 출처의 이미지도 그릴 수는 있지만 캔버스가 오염 상태가 됩니다. 이후 픽셀값 읽기와 내보내기는 실패합니다. MDN의 CORS 사용 이미지 가이드는 이 차이를 설명합니다.

외부 의존성이 없는 SVG 원본 만들기
다음을 master.svg로 저장하세요. 패스 하나와 원 하나가 있으며 삽입 이미지, 글꼴, 외부 의존성은 없습니다. 원본에서 안쪽 마름모와 주변 캔버스는 투명합니다.
<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160" viewBox="0 0 240 160">
<path d="M96 24 L152 80 L96 136 L40 80 Z M96 58 L118 80 L96 102 L74 80 Z" fill="#176b5b" fill-rule="evenodd"/>
<circle cx="200" cy="80" r="24" fill="#d4a53a"/>
</svg>두 도형과 viewBox를 포함한 전체 파일을 이미지로 사용합니다. 패스의 d 값만 추출하는 것과 달리 브라우저의 SVG 이미지 렌더러에 문서 전체를 그리도록 요청합니다.
예제는 의도적으로 자체 완결형으로 만들었습니다. 이미지로 사용되는 SVG에는 외부 리소스와 스크립트 등을 제한하는 규칙이 있습니다. MDN의 이미지로 사용하는 SVG 가이드를 확인하세요. 이 작은 파일의 디코딩이 성공했다고 해서 임의의 SVG가 외부 글꼴, 스타일, 연결된 그림을 모두 로드한다고 판단하지 마세요. 전달 방식을 정할 때는 인라인 SVG와 이미지 요소 비교를 참고하세요.
CORS 응답이 다른 로컬 출처 두 개 실행하기
출처에는 포트도 포함됩니다. 아래 로컬 전용 서버는 같은 SVG 바이트를 두 출처로 제공합니다. 두 번째 출처의 /cors/master.svg에는 Access-Control-Allow-Origin: *가 포함되고, /plain/master.svg에는 없습니다. 그림은 그대로 두고 응답 권한만 구분할 수 있습니다.
SVG 및 아래 HTML과 같은 폴더에 serve-proof.py로 저장하세요. python3 serve-proof.py를 실행한 뒤 http://127.0.0.1:8770/starter.html을 여세요. 두 포트가 모두 비어 있어야 합니다. 포트를 바꾼다면 서버와 HTML을 함께 수정하세요. 터미널에서 Enter를 누르면 두 서버가 종료됩니다.
from http.server import ThreadingHTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from threading import Thread
root = Path(__file__).parent
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(root), **kwargs)
def do_GET(self):
if self.path in ['/cors/master.svg', '/plain/master.svg']:
payload = (root / 'master.svg').read_bytes()
self.send_response(200)
self.send_header('Content-Type', 'image/svg+xml')
self.send_header('Cache-Control', 'no-store')
if self.path.startswith('/cors/'):
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(payload)
else:
super().do_GET()
servers = [ThreadingHTTPServer(('127.0.0.1', port), Handler) for port in (8770, 8783)]
for server in servers:
Thread(target=server.serve_forever, daemon=True).start()
try:
input('Original SVG proof on 127.0.0.1:8770 and :8783. Enter to stop.\n')
finally:
for server in servers:
server.shutdown()
server.server_close()이 허용 헤더는 직접 만든 그림을 제공하는 로컬 예제용입니다. 실제 운영 이미지 호스트에서는 제어할 수 있는 서버에 적절한 허용 출처를 설정하세요. 이미지 속성을 지정하는 것만으로 다른 서버의 콘텐츠에 대한 접근 권한이 생기지는 않습니다.
그리기 전에 이미지 디코딩 기다리기
아래 전체 페이지를 starter.html로 저장하세요. 각 경우에 별도 캔버스를 사용하므로 오염된 예제가 성공한 예제에 영향을 주지 않습니다. MDN의 crossOrigin 참고 문서에서 설명하듯 CORS가 필요한 요청에는 crossOrigin을 src보다 먼저 설정하세요.
캔버스 비트맵은 명시적으로 480 × 320, CSS 표시 크기는 240 × 160으로 정합니다. 원본 이미지의 고유 크기는 240 × 160이며 drawImage()가 선택한 비트맵 크기로 조절해 그립니다. 직접 정한 크기이며 기기 픽셀 비율을 측정한 값이 아닙니다.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Original SVG to canvas export</title>
<style>
body { margin: 24px; font: 16px system-ui; background: #f6f2e8; }
main { display: grid; grid-template-columns: repeat(2, 280px); gap: 20px; }
figure { margin: 0; }
canvas { display: block; width: 240px; height: 160px; background: white; }
figcaption { margin: 6px 0; }
pre { white-space: pre-wrap; font-size: 12px; }
</style>
<h1>Decode, paint, then check PNG access</h1>
<main id="cases"></main>
<pre id="report"></pre>
<script>
function pngBlob(canvas) {
return new Promise((resolve, reject) => {
try { canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Empty PNG')), 'image/png'); }
catch (error) { reject(error); }
});
}
function dataURL(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
async function render(test) {
const figure = document.createElement('figure');
const canvas = document.createElement('canvas');
canvas.width = 480;
canvas.height = 320;
const caption = document.createElement('figcaption');
figure.append(canvas, caption);
document.getElementById('cases').append(figure);
const result = { id: test.id, url: new URL(test.src, location.href).href,
crossOrigin: test.crossOrigin ?? null, canvasWidth: canvas.width, canvasHeight: canvas.height };
const image = new Image();
if (test.crossOrigin) image.crossOrigin = test.crossOrigin;
image.src = result.url;
try { await image.decode(); }
catch (error) {
result.stage = 'decode';
result.error = error.name;
caption.textContent = test.id + ': decode ' + error.name;
return result;
}
result.naturalWidth = image.naturalWidth;
result.naturalHeight = image.naturalHeight;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0, canvas.width, canvas.height);
result.stage = 'painted';
try {
result.opening = [...context.getImageData(192, 160, 1, 1).data];
result.fill = [...context.getImageData(192, 80, 1, 1).data];
result.corner = [...context.getImageData(0, 0, 1, 1).data];
} catch (error) { result.readbackError = error.name; }
try {
const blob = await pngBlob(canvas);
result.pngType = blob.type;
result.pngBytes = blob.size;
result.pngDataURL = await dataURL(blob);
result.export = 'ok';
const download = document.createElement('a');
download.href = result.pngDataURL;
download.download = test.id + '.png';
download.textContent = 'Save PNG';
figure.append(download);
} catch (error) { result.export = 'failed'; result.exportError = error.name; }
caption.textContent = test.id + ': ' + result.stage + ', PNG ' + result.export;
return result;
}
async function run() {
const tests = [
{ id: 'same-origin', src: 'master.svg' },
{ id: 'cors-allowed', src: 'http://127.0.0.1:8783/cors/master.svg', crossOrigin: 'anonymous' },
{ id: 'no-cors-image', src: 'http://127.0.0.1:8783/plain/master.svg' },
{ id: 'cors-denied', src: 'http://127.0.0.1:8783/plain/master.svg', crossOrigin: 'anonymous' },
{ id: 'missing-file', src: 'missing.svg' },
];
const results = [];
for (const test of tests) results.push(await render(test));
document.getElementById('report').textContent = JSON.stringify({ pageOrigin: location.origin, results }, (key, value) => key === 'pngDataURL' ? undefined : value, 2);
}
run();
</script>
</html>내보내기 함수는 예외가 던져지는 경우와 null Blob이 반환되는 경우를 모두 처리합니다. Canvas.toBlob()은 PNG 출력을 지원하며 캔버스가 origin-clean 상태가 아니면 SecurityError를 던질 수 있습니다. 성공한 두 경우에는 Save PNG 링크가 추가됩니다. 화면의 보고서에서는 base64 데이터를 제외합니다.
SVG를 바꾸기 전에 실패한 단계 확인하기
동일한 시작 예제를 한 Chrome 브라우저 환경에서 실행한 뒤 저장한 PNG 두 개를 다시 열었습니다. 아래 관찰 결과는 외부 의존성이 없는 직접 작성 파일과 두 로컬 출처에 대한 것입니다.
| 경우 | 이미지 요청 | 관찰한 그리기·픽셀 읽기·내보내기 |
|---|---|---|
| 같은 출처 | 로컬 SVG, CORS 속성 없음 | 240 × 160으로 디코딩, 그리기 완료, 픽셀 읽기와 PNG 성공 |
| 다른 출처, 권한 허용 | anonymous와 응답 헤더 | 디코딩, 그리기, 픽셀 읽기, PNG 모두 성공 |
| 다른 출처, CORS 요청 없음 | 속성 및 응답 헤더 없음 | 디코딩과 그리기 성공, 픽셀 읽기와 내보내기에서 SecurityError 발생 |
| 다른 출처, 권한 거부 | anonymous, 응답 헤더 없음 | EncodingError로 디코딩 거부, 그리기를 시도하지 않음 |
| 파일 없음 | 같은 출처의 404 | EncodingError로 디코딩 거부, 그리기를 시도하지 않음 |
CORS 요청이 없는 경우에도 그림은 화면에 표시되었습니다. 보인다는 사실이 읽기나 내보내기 권한을 입증하지는 않습니다. CORS가 거부된 경우와 파일이 없는 경우에는 그보다 앞 단계에서 실패했으므로, 기하 형태를 다시 작성해도 해당 요청 실패를 해결하지 못합니다.
내보낸 PNG 두 개는 다시 열었을 때 모두 480 × 320이었습니다. 이번 실행에서는 각각 10,509 바이트로 바이트 단위까지 동일했습니다. 비트맵 좌표 (192,160)에서 빈 공간은 RGBA (0,0,0,0)이었고 모서리도 알파값이 0이었습니다. (192,80)의 청록색 표본은 (23,107,91,255)였습니다. 이 표본은 해당 파일의 빈 공간과 채우기를 확인하며, 모든 가장자리 픽셀이나 모든 SVG 기능을 검사한 것은 아닙니다.
브라우저 페이지의 흰 사각형은 캔버스 뒤의 CSS 배경입니다. PNG에 흰색을 칠한 것이 아닙니다. 불투명한 배경이 필요하다면 이미지를 그리기 전에 배경을 명시적으로 칠한 뒤 내보낸 파일을 다시 확인하세요.
편집 가능한 원본을 PNG와 따로 보존하기
저장된 PNG에는 픽셀이 들어 있습니다. 원래 패스, 원, 편집 가능한 내부 윤곽선은 유지되지 않습니다. master.svg를 벡터 원본으로 보존하고, 실제 PNG가 필요한 대상에 맞춰 캔버스 비트맵 크기를 선택하세요. 나중에 PNG를 확대해도 원본의 기하 형태가 복원되지는 않습니다.
또한 SVG 파일명만으로 내부 그림이 모두 벡터 기하 형태라고 판단할 수는 없습니다. 연결되거나 삽입된 이미지가 있는 파일이라면 삽입 래스터 검사 가이드를 사용하세요.
원본 그림이 래스터 이미지로만 있고 편집 가능한 윤곽선이 필요하다면 PerfectVector의 이미지 벡터 변환 작업 절차로 SVG 원본을 준비할 수 있습니다. 이 로드 절차에 사용하기 전에 윤곽선, 색상, 빈 공간을 검사하세요. 벡터 변환은 CORS 헤더를 고치거나 캔버스 픽셀 읽기 권한을 부여하지 않습니다.
자주 묻는 질문
SVG 마크업을 drawImage()에 직접 전달할 수 있나요? 아니요. 이 예제는 SVG 파일을 HTMLImageElement로 로드한 뒤 디코딩된 이미지를 drawImage()에 전달합니다. 마크업 문자열은 그 이미지 개체가 아닙니다. 문서 로드와 Path2D 패스 데이터 구성을 구분하세요.
그림은 보이는데 PNG 내보내기가 실패하는 이유는 무엇인가요? CORS 권한 없이 로드한 다른 출처의 이미지를 그리면 캔버스가 오염 상태가 될 수 있습니다. 예제의 CORS 요청 없는 경우에는 그리기는 성공했지만 픽셀 읽기와 내보내기 모두 SecurityError를 던졌습니다. SVG의 기하 형태가 잘못되었다고 가정하지 말고 요청 모드와 서버 응답을 확인하세요.
crossOrigin anonymous를 설정하면 모든 이미지 URL이 해결되나요? 아니요. src보다 먼저 설정해야 하고 이미지 서버도 호환되는 CORS 응답을 제공해야 합니다. 같은 그림이라도 권한 헤더가 없는 엔드포인트에 anonymous로 요청했을 때는 디코딩이 실패했습니다.
캔버스를 PNG로 내보내면 편집 가능한 SVG 패스가 유지되나요? 아니요. PNG는 래스터 출력입니다. 패스, 도형, 빈 공간을 편집해야 한다면 SVG 원본을 따로 보존하고 의도한 납품 크기에서 PNG를 검사하세요.
출처
- MDN: CanvasRenderingContext2D.drawImage(): 사용할 수 있는 이미지 개체, 대상 크기, 고유 크기를 설명합니다.
- MDN: HTMLImageElement.decode(): 이미지 준비 Promise와 요청·디코딩 거부를 설명합니다.
- MDN: CORS 사용 이미지: 그린 이미지의 읽기와 내보내기에 적용되는 origin-clean 제한을 설명합니다.
- MDN: HTMLImageElement.crossOrigin: 이미지 로드의 CORS 요청 모드를 설명합니다.
- MDN: HTMLCanvasElement.toBlob(): PNG Blob 생성, null 출력, SecurityError를 설명합니다.
- MDN: 이미지로 사용하는 SVG: SVG 이미지 컨텍스트와 외부 리소스 제한을 설명합니다.
래스터 원본을 편집 가능한 그림으로 만들어야 한다면 PerfectVector로 SVG 원본을 만들고 윤곽선과 빈 공간을 검사하세요. 그 원본을 보존하면서 캔버스 렌더링과 내보낸 PNG를 각각 확인하세요.


