PerfectVector
โดย Irene Kim

ตัดบรรทัดข้อความ SVG ด้วยการวัดและ tspan

ตัดบรรทัดป้าย SVG โดยวัดคำด้วยฟอนต์จริงแล้วสร้าง tspan ทดสอบป้ายแคบ ปฏิเสธคำที่ล้นหรือบรรทัดเกินกำหนด แล้วบันทึกผลลัพธ์

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

หากต้องการแบ่งป้ายข้อความ SVG ที่เปลี่ยนแปลงได้เป็นบรรทัดที่ระบุไว้อย่างชัดเจน ให้วัดกลุ่มคำที่เสนอด้วย getComputedTextLength() แล้วเขียน <tspan> หนึ่งตัวต่อบรรทัดที่ยอมรับ รอให้ฟอนต์ของเอกสารพร้อมก่อน และกำหนดว่าจะทำอย่างไรเมื่อคำหนึ่งกว้างกว่าป้ายหรือผลลัพธ์เกินจำนวนบรรทัดที่อนุญาต

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

เลือกกติกาการตัดบรรทัดก่อนวางตำแหน่ง

องค์ประกอบ tspan ช่วยวางตำแหน่งส่วนต่าง ๆ ของข้อความ SVG ฟังก์ชันของเราตั้ง x เท่ากันในทุกบรรทัด และตั้งระยะเลื่อน dy ในบรรทัดถัดไป ฟังก์ชันเป็นผู้ตัดสินใจว่าจะขึ้นบรรทัดตรงไหน ส่วนองค์ประกอบเก็บผลลัพธ์นั้น

นี่เป็นวิธีจัดวางแบบระบุตำแหน่งอย่างชัดเจนวิธีหนึ่ง ข้อกำหนดข้อความ SVG 2 ยังระบุการตัดบรรทัดอัตโนมัติผ่านพื้นที่เนื้อหาและการจัดข้อความด้วย CSS บทความนี้ไม่ได้ทดสอบความสามารถเหล่านั้นหรืออ้างว่าทุกเบราว์เซอร์ไม่มีให้ใช้

สำหรับย่อหน้า HTML ที่อยู่นอกภาพ ให้ใช้ CSS จัดข้อความล้อมเงารูปร่าง SVG แบบ float รูปแบบนั้นตามแนวขอบภาพ โดยข้อความยังเป็น HTML ธรรมดา แทนที่จะกลายเป็นบรรทัดป้าย SVG ที่กำหนดตำแหน่ง

สำหรับตัวอย่างป้ายภาษาอังกฤษขนาดเล็กนี้ กติกาคือ:

  • ยุบช่องว่างและลองคำตามลำดับเดิม โดยคั่นด้วยช่องว่างเดียว
  • เก็บบรรทัดที่เสนอไว้เฉพาะเมื่อความยาวที่วัดได้พอดีกับความกว้างที่มี
  • ปฏิเสธคำที่วางเดี่ยว ๆ แล้วยังไม่พอดี ไม่กำหนดจุดแบ่งคำด้วยยัติภังค์ขึ้นเอง
  • ปฏิเสธผลลัพธ์ทั้งหมดเมื่อต้องใช้มากกว่า maxLines ไม่ตัดคำท้ายทิ้งโดยไม่แจ้ง

จำนวนบรรทัดสูงสุดเป็นการตัดสินใจด้านการจัดวาง เลือกพร้อมกับเส้นฐานเริ่มต้น ระยะระหว่างบรรทัด และความสูงที่มี ฟังก์ชันตัดบรรทัดไม่ได้คำนวณกรอบเนื้อหาแนวตั้งให้คุณ

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

วัดด้วยฟอนต์ที่จะใช้แสดงป้าย

getComputedTextLength() วัดความยาวข้อความ SVG ที่คำนวณแล้ว ตัวอย่างนี้โคลนองค์ประกอบข้อความของป้ายเป็นตัววัดที่ซ่อนไว้ภายใน SVG เดียวกันซึ่งแนบกับเอกสาร แล้วลบตัววัดหลังจากวางแผนบรรทัด ทั้งสองใช้ Arial, sans-serif ขนาด 22 หน่วยที่กำหนดไว้อย่างชัดเจน

โค้ดรอ document.fonts.ready ก่อนวัด ซึ่งรอการโหลดฟอนต์ของเอกสารและการจัดวางที่เกี่ยวข้อง ไม่ได้ฝังฟอนต์ลงใน SVG ที่ส่งออก ฟอนต์ที่ใช้ได้ต่างกันอาจทำให้ความยาวบรรทัดต่างกัน คำนวณการจัดวางใหม่เมื่อข้อความ การตั้งค่าฟอนต์ หรือความกว้างที่มีเปลี่ยนไป

เรียกใช้ตัวอย่างครบชุดโดยไม่ต้องมีไลบรารีเพิ่ม

บันทึกเป็น starter.html แล้วเปิดในเบราว์เซอร์ โค้ดสร้างหกกรณี แสดงค่าที่วัด และแปลง SVG ที่ได้แต่ละอันเป็นข้อความ สองกรณีแรกเปลี่ยนเฉพาะความกว้างป้าย กรณีที่สามเปลี่ยนข้อความ ส่วนที่เหลือทดสอบการปฏิเสธและข้อความตามตัวอักษร

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Measured SVG label lines</title>
<style>
  body { margin: 24px; font: 16px system-ui; background: #f6f2e8; }
  #cases { display: grid; grid-template-columns: repeat(2, 360px); gap: 20px; }
  figure { margin: 0; }
  svg { display: block; background: white; }
  figcaption { margin: 6px 0; }
  pre { white-space: pre-wrap; font-size: 12px; }
</style>
<h1>Keep artwork and label layout separate</h1>
<div id="cases"></div>
<pre id="report"></pre>
<script>
  const ns = 'http://www.w3.org/2000/svg';
  const emblem = 'M60 32 L100 72 L60 112 L20 72 Z M60 54 L78 72 L60 90 L42 72 Z';
  function element(name, attributes) {
    const node = document.createElementNS(ns, name);
    for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
    return node;
  }
  function wrapWords(text, source, { width, maxLines, lineHeight }) {
    text.replaceChildren();
    if (!(width > 0 && lineHeight > 0 && Number.isInteger(maxLines) && maxLines > 0)) {
      return { status: 'invalid-options', lines: [] };
    }
    const words = source.trim().split(/\s+/u).filter(Boolean);
    const probe = text.cloneNode(false);
    probe.setAttribute('visibility', 'hidden');
    text.parentNode.append(probe);
    const measure = value => { probe.textContent = value; return probe.getComputedTextLength(); };
    const lines = [];
    let line = '';
    try {
      for (const word of words) {
        const wordWidth = measure(word);
        if (wordWidth > width) {
          return { status: 'overlong-word', word, measuredWidth: wordWidth, lines: [] };
        }
        const candidate = line ? line + ' ' + word : word;
        if (line && measure(candidate) > width) {
          lines.push(line);
          line = word;
        } else line = candidate;
      }
      if (line) lines.push(line);
      if (lines.length > maxLines) {
        return { status: 'too-many-lines', requiredLines: lines.length, lines: [] };
      }
      const x = text.getAttribute('x');
      lines.forEach((value, index) => {
        const span = element('tspan', { x, dy: index ? lineHeight : 0 });
        span.textContent = value;
        text.append(span);
      });
      return { status: 'ok', lines,
        lengths: [...text.children].map(span => span.getComputedTextLength()) };
    } finally { probe.remove(); }
  }
  const cases = [
    { id: 'normal', source: 'Keep artwork and labels separate', width: 200, maxLines: 4 },
    { id: 'narrow', source: 'Keep artwork and labels separate', width: 110, maxLines: 5 },
    { id: 'changed', source: 'Keep the original artwork beside every exported label', width: 200, maxLines: 4 },
    { id: 'long-word', source: 'Artwork supercalifragilisticexpialidocious', width: 110, maxLines: 5 },
    { id: 'line-limit', source: 'Keep artwork and labels separate', width: 110, maxLines: 2 },
    { id: 'literal', source: 'Cove <g> & field', width: 200, maxLines: 4 },
  ];
  const entries = cases.map(test => {
    const figure = document.createElement('figure');
    const svg = element('svg', { width: 360, height: 240, viewBox: '0 0 360 240' });
    svg.append(element('path', { d: emblem, fill: '#176b5b', 'fill-rule': 'evenodd' }));
    svg.append(element('rect', { x: 140, y: 34, width: test.width, height: 170,
      fill: 'none', stroke: '#ce7b55', 'stroke-dasharray': '4 4' }));
    const text = element('text', { x: 140, y: 60, 'font-family': 'Arial, sans-serif',
      'font-size': 22, fill: '#172b34' });
    svg.append(text);
    const caption = document.createElement('figcaption');
    figure.append(svg, caption);
    document.getElementById('cases').append(figure);
    return { test, svg, text, caption };
  });
  async function run() {
    await document.fonts.ready;
    const results = entries.map(({ test, svg, text, caption }) => {
      const result = wrapWords(text, test.source, { ...test, lineHeight: 30 });
      caption.textContent = test.id + ': ' + result.status;
      return { ...test, ...result, fontFamily: getComputedStyle(text).fontFamily,
        fontSize: getComputedStyle(text).fontSize, tspanCount: text.children.length,
        pathD: svg.querySelector('path').getAttribute('d'),
        pathCount: svg.querySelectorAll('path').length,
        imageCount: svg.querySelectorAll('image').length,
        foreignObjectCount: svg.querySelectorAll('foreignObject').length,
        serialized: new XMLSerializer().serializeToString(svg) };
    });
    document.getElementById('report').textContent = JSON.stringify({
      fontStatus: document.fonts.status, policy: 'Whitespace words; reject a long word or too many lines without partial output', results,
    }, null, 2);
  }
  run();
</script>
</html>

ฟังก์ชันสร้างแต่ละบรรทัดด้วย textContent แทนการแทรกข้อความเป็นมาร์กอัป ในกรณีข้อความตามตัวอักษร Cove <g> & field ยังคงเป็นข้อความธรรมดา SVG ที่แปลงเป็นข้อความมีวงเล็บมุมและเครื่องหมายแอมเพอร์แซนด์ที่ escape แล้ว ไม่ได้มีองค์ประกอบกลุ่มเพิ่มมา

โคลนสำหรับวัดถูกแนบไว้แต่ซ่อนอยู่ และมีแอตทริบิวต์ฟอนต์เหมือนป้ายที่มองเห็น โคลนจะถูกลบใน finally รวมถึงเมื่อปฏิเสธผลลัพธ์ บรรทัดจะถูกเพิ่มต่อเมื่อผ่านการตรวจความกว้างคำและจำนวนบรรทัดสูงสุดแล้ว ฟังก์ชันล้างป้ายเดิมก่อน การอัปเดตที่ถูกปฏิเสธจึงไม่เหลือข้อความเก่าหรือข้อความบางส่วน ให้จัดการสถานะที่คืนมาในอินเทอร์เฟซแอปพลิเคชัน

ผลจากกรณีต้นฉบับทั้งหก

เราเรียกใช้ตัวอย่างนี้ตามที่แสดงใน Chrome โดยฟอนต์ของเอกสารรายงานสถานะ loaded ความยาวด้านล่างเป็นความยาวข้อความ SVG ในสภาพแวดล้อมนั้น ไม่ใช่ขอบเขตรูปร่างตัวอักษรที่วาดจริงหรือการรับประกันว่าฟอนต์จะให้ผลเหมือนกันทุกระบบ

กรณีความกว้าง / บรรทัดสูงสุดผลลัพธ์บรรทัดยาวที่สุดที่พบ
วลีต้นฉบับ200 / 4สองบรรทัด: Keep artwork and; labels separate172.44
วลีเดิมในกรอบแคบ110 / 5สี่บรรทัด: Keep; artwork; and labels; separate100.30
ข้อความยาวขึ้น200 / 4สามบรรทัด198.13
คำยาวเกินไป110 / 5overlong-word; ไม่มี tspanคำวัดได้ 311.82
วลีในกรอบแคบ จำกัดสองบรรทัด110 / 2too-many-lines; ต้องใช้สี่; ไม่มี tspanไม่สร้างป้าย
วงเล็บมุมและแอมเพอร์แซนด์ตามตัวอักษร200 / 4หนึ่งบรรทัดตามตัวอักษร162.66

วลีต้นฉบับคือ Keep artwork and labels separate เวอร์ชันที่ยาวขึ้นคือ Keep the original artwork beside every exported label ทุกบรรทัดที่ยอมรับมีความยาวที่วัดได้ไม่เกินความกว้างที่เลือก กรณีที่ปฏิเสธยังแสดงภาพแต่ไม่ใส่ป้าย จึงตรวจสอบสถานะล้มเหลวได้ง่าย

ไฟล์ที่แปลงเป็นข้อความทั้งหกยังมีพาธรูปสี่เหลี่ยมข้าวหลามตัดต้นฉบับเหมือนกัน มีองค์ประกอบข้อความเนทีฟหนึ่งตัว และไม่มีรูปภาพหรือ foreignObject กรณีที่ยอมรับมี tspan สอง สี่ สาม และหนึ่งตัวตามลำดับ ส่วนสองกรณีที่ปฏิเสธไม่มีเลย เรายังเปิด SVG ทั้งหกที่แปลงเป็นข้อความแล้วกลับมาเป็นภาพในเบราว์เซอร์เดิม โดยทั้งหมดโหลดที่ 360×240 นี่เป็นการตรวจไฟล์เหล่านี้กับเบราว์เซอร์นี้ ไม่ใช่ทุกโปรแกรมแก้ไขหรือเอนจินแสดงผล

บันทึก SVG ที่จัดวางแล้วและทดสอบไฟล์ส่งมอบ

XMLSerializer.serializeToString() สร้างมาร์กอัปที่แปลงเป็นข้อความในรายงาน บันทึกสตริง serialized ของกรณีที่สำเร็จเป็นไฟล์ .svg ไฟล์มีบรรทัดที่ระบุไว้อย่างชัดเจนอยู่แล้ว จึงไม่ต้องใช้ฟังก์ชัน JavaScript นี้สร้างบรรทัดซ้ำ

คำยังคงเป็นข้อความที่แก้ไขได้ ไฟล์ที่บันทึกเก็บคำขอใช้ตระกูลฟอนต์ ไม่ได้เก็บไฟล์ฟอนต์หรือรูปร่างตัวอักษรที่แปลงเป็นเส้นขอบ ปลายทางที่มีฟอนต์อื่นอาจทำให้ตัวอักษรเปลี่ยน แม้สตริงและตำแหน่งบรรทัดยังเหมือนเดิม เก็บข้อความและข้อมูลการจัดวางต้นฉบับไว้ข้างสำเนาที่ส่งออก และตรวจปลายทางจริงก่อนส่งมอบ หากต้องการรูปร่างตัวอักษรคงที่ ให้สร้างสำเนาส่งมอบที่แปลงตัวอักษรเป็นเส้นขอบด้วยโปรแกรมต้นทางที่เหมาะสม พร้อมเก็บข้อความที่แก้ไขได้ไว้ในไฟล์หลัก

คู่มือข้อความ foreignObject อธิบายป้าย HTML ที่หายไปในเอนจินอื่น ตัวอย่างนี้สร้างข้อความเนทีฟตั้งแต่แรก คู่มือ textLength กล่าวถึงการทำให้บรรทัดเดียวพอดีด้วยการปรับความยาว ซึ่งเป็นการตัดสินใจคนละแบบกับการเพิ่มบรรทัดจากการวัด ใช้คู่มือการจัดแนวตั้ง เมื่อต้องวางตำแหน่งป้ายทั้งชุดเทียบกับภาพรอบข้าง

ระบุข้อจำกัดของฟังก์ชันให้ชัดเจน

การแยกคำด้วยช่องว่างเป็นกติกาที่ตั้งใจให้เรียบง่าย โดยยุบช่องว่างซ้ำ แท็บ และการขึ้นบรรทัดใหม่ที่ระบุไว้ และไม่จัดการกฎตัดบรรทัดเฉพาะภาษา การแบ่งคำด้วยยัติภังค์ ย่อหน้าสองทิศทาง หรือข้อความหลายช่วงที่มีรูปแบบผสม หากข้อความต้องการความสามารถเหล่านั้น ให้เลือกระบบจัดวางอื่นหรือขยายกติกาแล้วทดสอบ

การตรวจความกว้างยังไม่รับประกันว่าส่วนตกแต่งของตัวอักษรที่ยื่นออกมาจะอยู่ในกรอบที่วาด หรือเส้นฐานห้าเส้นจะพอดีกับความสูงที่เลือก ตรวจตัวอักษรที่มองเห็นในขนาดส่งมอบ ชุดทดสอบของเราใช้ป้ายแนวนอน แอตทริบิวต์ฟอนต์คงที่ และระยะบรรทัด 30 หน่วย อย่านำค่าที่วัดไปใช้กับฟอนต์อื่นโดยไม่วัดใหม่

หากภาพมีเพียง PNG หรือ JPG และต้องการงานที่แก้ไขได้ ให้เตรียม SVG คลิปอาร์ตด้วย PerfectVector ตรวจขอบและช่องว่างด้านใน แล้วแยกภาพออกจากป้าย พิมพ์คำใหม่เป็นข้อความเนทีฟ เรียกใช้กติกาตัดบรรทัด และตรวจไฟล์ส่งออกที่ประกอบแล้ว การแปลงเป็นเวกเตอร์เตรียมพาธของภาพ ไม่ได้เลือกจุดตัดบรรทัดหรือกู้คำที่แก้ไขได้จากตัวอักษรที่ลากเส้นตาม หากมีพาธที่ดีอยู่แล้วก็ใช้ต่อได้ คู่มือการแปลงเป็นเวกเตอร์ อธิบายขั้นตอนกู้คืนต้นทางนี้

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

tspan ตัดสินใจจุดขึ้นบรรทัดเองหรือไม่? ตัวอย่างนี้ตัดสินใจด้วยการวัดกลุ่มคำ แล้วสร้าง tspan ที่กำหนดตำแหน่งหนึ่งตัวต่อบรรทัดที่ยอมรับ ไม่ได้ทดสอบการตัดบรรทัดอัตโนมัติของ SVG 2 ที่อาศัย CSS

จะเกิดอะไรขึ้นกับคำที่กว้างกว่าป้าย? ฟังก์ชันคืนค่า overlong-word และไม่สร้างป้าย ให้ขยายพื้นที่ ย่อข้อความ หรือใช้กติกาแบ่งคำที่ทดสอบแยกแล้ว แทนการตัดข้อความที่ล้นทิ้งโดยไม่แจ้ง

maxLines ลบคำที่เหลือหรือไม่? ไม่ กติกานี้ปฏิเสธผลลัพธ์เมื่อต้องใช้บรรทัดมากเกินไป รายงานจำนวนที่ต้องใช้ และไม่เหลือ tspan แอปพลิเคชันต้องจัดการสถานะนั้น

บรรทัดที่ส่งออกจะดูเหมือนเดิมเมื่อใช้ฟอนต์อื่นหรือไม่? คำและตำแหน่งที่ระบุชัดเจนยังอยู่ แต่ไฟล์ไม่ได้ฝังฟอนต์หรือแปลงตัวอักษรเป็นเส้นขอบ ตรวจฟอนต์ที่มีและรูปร่างตัวอักษรในปลายทางจริง

แหล่งข้อมูล

  1. องค์ประกอบ tspan ของ MDN — ส่วนข้อความที่กำหนดตำแหน่งและแอตทริบิวต์เลื่อนบรรทัด
  2. ข้อความ SVG 2 ของ W3C — การกำหนดตำแหน่งชัดเจนและโมเดลตัดบรรทัดอัตโนมัติที่แยกต่างหาก
  3. MDN getComputedTextLength — การวัดข้อความ SVG จริง
  4. MDN FontFaceSet.ready — การรอโหลดฟอนต์และจัดวาง
  5. MDN textContent — การใส่ข้อความธรรมดาแทนมาร์กอัป
  6. MDN XMLSerializer — การบันทึกมาร์กอัป SVG ที่จัดวางแล้ว

สำหรับภาพราสเตอร์ของคุณ ให้เตรียมคลิปอาร์ตที่แก้ไขได้ ตรวจรูปร่างและเก็บไฟล์หลัก เพิ่มข้อความแยกต่างหาก แล้วตรวจบรรทัดที่ยอมรับและตัวอักษรที่มองเห็นใน SVG ที่บันทึกก่อนส่งมอบ

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

PerfectVector

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

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