PerfectVector
โดย Irene Kim

SVG เป็น Canvas: โหลดภาพก่อนส่งออก PNG

โหลด SVG ทั้งไฟล์ด้วย Image.decode วาดตามขนาดบิตแมปที่เลือก ทดสอบ CORS และอ่าน PNG แล้วเก็บต้นฉบับแก้ไขได้แยกจากพิกเซลส่งออก

อ่าน 6 นาที
ในหน้านี้

หากต้องการวาด SVG ทั้งไฟล์บน canvas ให้โหลดเป็นภาพ รอ image.decode() แล้วส่งภาพเข้า drawImage() จากนั้นตรวจส่งออก PNG แยก ภาพอาจเห็นบน canvas แม้เบราว์เซอร์บล็อกการอ่านหรือบันทึกพิกเซล

วิธีนี้ระบายเอกสาร SVG เป็นภาพ ไม่ให้เส้น SVG แก้ไขได้กับ canvas หากต้องการสร้างบางเส้นขอบใหม่ ใช้คู่มือ Path2D SVG เก็บ SVG ต้นฉบับไม่ว่าจะเลือกทางแสดงใด

แยกถอดรหัส ระบาย และส่งออก

HTMLImageElement เป็นอินพุตที่ canvas drawImage() รับ ความกว้างสูงปลายทางควบคุมขนาดระบาย Image.decode() คืน promise ที่สำเร็จเมื่อภาพถอดรหัสแล้ว คำขอล้มเหลวหรือปัญหาถอดรหัสอาจทำให้ถูกปฏิเสธ

ถอดรหัสสำเร็จเป็นเพียงการตรวจแรก ภาพข้ามต้นทางที่โหลดโดยไม่มีสิทธิ์ CORS ยังวาดได้ แต่ทำให้ canvas ปนเปื้อน การอ่านพิกเซลและส่งออกจะล้มเหลว คู่มือภาพเปิด CORS ของ MDN อธิบายความต่าง

เก็บต้นฉบับ ตรวจพิกเซล
ภาพแนวคิดของข้าวหลามตัดโปร่งกลางและวงกลมเป็นเส้นต้นฉบับ ภาพเรนเดอร์เรียบ และผลกริดพิกเซล
ภาพอธิบาย: เส้นขอบต้นฉบับกับพิกเซลที่ระบายเป็นการแทนคนละแบบ กรอบ ลูกศร และกริดหยาบเป็นแนวคิด ผลเบราว์เซอร์ด้านล่างมาจากไฟล์ต้นฉบับที่ทดลองอิสระ

สร้าง 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 ที่ใช้เป็นภาพมีข้อจำกัด รวมทรัพยากรภายนอกและสคริปต์ ดูคู่มือ SVG เป็นภาพของ MDN อย่าถือว่าถอดรหัสไฟล์เล็กนี้สำเร็จพิสูจน์ว่า SVG ใด ๆ จะโหลดฟอนต์ สไตล์ หรือภาพเชื่อมภายนอกได้ สำหรับทางส่งงาน เทียบ SVG inline กับองค์ประกอบภาพ

รันสองต้นทางในเครื่องที่ตอบ CORS ต่างกัน

ต้นทางรวมพอร์ตด้วย เซิร์ฟเวอร์เฉพาะในเครื่องต่อไปให้ไบต์ SVG เดียวกันผ่านสองต้นทาง ต้นทางที่สอง /cors/master.svg มี Access-Control-Allow-Origin: * ส่วน /plain/master.svg ไม่มี จึงแยกสิทธิ์คำตอบออกจากตัวภาพ

บันทึกเป็น serve-proof.py ข้าง SVG กับ HTML ด้านล่าง รัน 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 แต่ละกรณีมี canvas ของตน จึงไม่ให้กรณีปนเปื้อนส่งผลต่อกรณีสำเร็จ ตั้ง crossOrigin ก่อน src สำหรับคำขอที่ต้องใช้ CORS ตามเอกสาร crossOrigin ของ MDN

บิตแมป canvas ระบุ 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>

ฟังก์ชันส่งออกจัดการทั้ง exception ที่โยนและ blob ว่าง Canvas.toBlob() รองรับ PNG และอาจโยน SecurityError เมื่อ canvas ไม่สะอาดด้านต้นทาง สองกรณีสำเร็จเพิ่มลิงก์ 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) มุมก็มีอัลฟาศูนย์ ตัวอย่างสีเขียวน้ำทะเลที่ (192,80) คือ (23,107,91,255) ตัวอย่างเหล่านี้ตรวจช่องกับสีเติมของไฟล์นี้ ไม่ใช่ทุกพิกเซลขอบหรือทุกคุณสมบัติ SVG

สี่เหลี่ยมขาวบนหน้าเบราว์เซอร์คือพื้นหลัง CSS หลัง canvas ไม่ใช่สีขาวเพิ่มใน PNG หากต้องการพื้นทึบ ให้ระบายพื้นนั้นชัดเจนก่อนวาดภาพ แล้วตรวจไฟล์ส่งออกอีกครั้ง

เก็บต้นฉบับแก้ไขได้แยกจาก PNG

PNG ที่บันทึกมีพิกเซล ไม่ได้บรรจุเส้น วงกลม หรือขอบในที่แก้ไขได้เดิม เก็บ master.svg เป็นต้นทางเวกเตอร์ และเลือกขนาดบิตแมป canvas สำหรับปลายทางที่ต้องใช้ PNG จริง การขยาย PNG เดิมภายหลังไม่คืนรูปทรงต้นฉบับ

ชื่อ SVG อย่างเดียวก็ไม่พิสูจน์ว่าภายในทั้งหมดเป็นเวกเตอร์ ใช้คู่มือตรวจแรสเตอร์ฝังตัว เมื่อไฟล์มีภาพเชื่อมหรือฝัง

หากต้นฉบับมีเพียงแรสเตอร์และต้องการเส้นขอบแก้ไขได้ ขั้นตอนภาพเป็นเวกเตอร์ของ PerfectVector เตรียมต้นฉบับ SVG ได้ ตรวจเส้น สี และช่องก่อนใช้ในรูทีนโหลดนี้ การแปลงเวกเตอร์ไม่แก้เฮดเดอร์ CORS หรือให้สิทธิ์อ่าน canvas

คำถามที่พบบ่อย

ส่งมาร์กอัป SVG ตรงให้ drawImage() ได้หรือไม่? ไม่ได้ ตัวอย่างนี้โหลดไฟล์ SVG เป็น HTMLImageElement และส่งภาพถอดรหัสให้ drawImage() สตริงมาร์กอัปไม่ใช่วัตถุภาพนั้น แยกการโหลดเอกสารออกจากสร้างข้อมูลเส้น Path2D

ทำไมเห็นภาพแต่ส่งออก PNG ไม่ได้? ภาพข้ามต้นทางที่โหลดโดยไม่มีสิทธิ์ CORS อาจทำ canvas ปนเปื้อนหลังวาด ในกรณีไม่มี CORS ของเรา ระบายสำเร็จแต่อ่านพิกเซลและส่งออกโยน SecurityError ตรวจโหมดคำขอกับคำตอบเซิร์ฟเวอร์แทนคิดว่ารูปทรง SVG เสีย

crossOrigin anonymous แก้ทุก URL ภาพหรือไม่? ไม่ ตั้งก่อน src และเซิร์ฟเวอร์ภาพต้องตอบ CORS ที่เข้ากัน ภาพเดียวกันของเราถอดรหัสล้มเมื่อขอ anonymous จากปลายทางไม่มีเฮดเดอร์สิทธิ์

ส่งออก canvas เป็น PNG เก็บเส้น SVG แก้ไขได้หรือไม่? ไม่ PNG เป็นผลแรสเตอร์ เก็บ SVG ต้นฉบับแยกหากต้องแก้เส้น รูป หรือช่อง และตรวจ PNG ที่ขนาดส่งงานเป้าหมาย

แหล่งข้อมูล

  1. MDN: CanvasRenderingContext2D.drawImage() — วัตถุภาพที่รับ ขนาดปลายทาง และขนาดธรรมชาติ
  2. MDN: HTMLImageElement.decode() — Promise ภาพพร้อมและการปฏิเสธคำขอ/ถอดรหัส
  3. MDN: ภาพเปิด CORS — ข้อจำกัดต้นทางสะอาดในการอ่านและส่งออกภาพวาด
  4. MDN: HTMLImageElement.crossOrigin — โหมดคำขอ CORS สำหรับโหลดภาพ
  5. MDN: HTMLCanvasElement.toBlob() — สร้าง PNG blob ผลว่าง และ SecurityError
  6. MDN: SVG เป็นภาพ — บริบทภาพ SVG และข้อจำกัดทรัพยากรภายนอก

ทำงานจากแรสเตอร์เดิมที่ต้องการภาพแก้ไขได้หรือไม่? สร้างต้นฉบับ SVG ด้วย PerfectVector ตรวจเส้นขอบและช่อง แล้วเก็บต้นฉบับขณะตรวจ canvas เรนเดอร์กับ PNG ส่งออกแยก

เพิ่มเติมจากบล็อก

PerfectVector

เริ่มต้นด้วย SVG ที่เรียบร้อยขึ้น
และแก้ไขได้ง่ายกว่า

ไม่ต้องใช้บัตรเครดิต