漢字筆順動畫C端實現&B端原理 - [大力智能 前端]

✊ Give Me the Font, Back to You the Animationhtml

1、簡介

筆順後臺的目標是隻要對於給定的字體文件(WOFF, OTF, TTF )以及須要的字形(漢字,字母 or 其餘各國的語言),就能產出與之對應的筆順動畫數據。是對開源項目Make me han zi的實踐。前端

2、效果演示

展現效果

大力硬件端展現效果

1.gif

後臺數據資源

後臺產出筆順動畫的 json 文件,並經過 CDN 資源分發。肯定字體的狀況下,一個字形對應惟一一個數據資源(字形經過encodeURI,並去除"%"進行編碼,即"我" -> "E68891")。業務方能夠經過拼接 URL 直接獲取到對應的筆順靜態資源。git

亮點功能一

|筆畫的拆解github

  • 防止算法生成的筆畫數量有誤,提供人工干預能力
  • 左邊圖同一顏色表明同一筆
  • 能夠經過增減右邊的紅色連線,來作到將字形結構進行筆畫的拆解或者是合併

亮點功能二

|筆順方向調節算法

2幀.gif

  • 防止算法生成的筆畫順序有誤,提供人工干預能力
  • 能夠靈活調節筆順的前後順序,或者是描紅的方向

亮點功能三

|縮放&平移功能npm

5.gif

  • 當字形渲染出來位置或者大小不符合要求的時候,提供人工修改能力
  • 字形的大小以及在田字格的位置,在數據生成的時候,已經進行過統一調整
  • 拖動右上角紅點進行大小縮放
  • 拖動字形進行位置平移

3、動畫實現介紹

這裏主要是解釋如何去使用筆順後臺生產的數據json

/** 筆順動畫原數據 */

{"strokes":["M 350 571 Q 380 593 449 614 Q 465 615 468 623 Q 471 633 458 643 Q 439 656 396 668 Q 381 674 370 672 Q 363 668 363 657 Q 364 621 200 527 Q 196 518 201 516 Q 213 516 290 546 Q 303 550 316 556 L 350 571 Z","M 584 466 Q 666 485 734 497 Q 746 496 754 511 Q 755 524 729 533 Q 693 554 622 527 Q 598 520 575 511 L 537 499 Q 518 495 500 488 Q 442 472 386 457 L 337 446 Q 327 446 179 416 Q 148 409 173 392 Q 212 365 241 376 Q 287 389 339 404 L 387 416 Q 460 438 545 457 L 584 466 Z","M 386 457 Q 387 493 398 517 Q 405 535 390 548 Q 371 564 350 571 C 323 583 303 583 316 556 Q 315 556 316 555 Q 338 519 337 478 Q 337 462 337 446 L 339 404 Q 340 343 339 289 L 338 241 Q 337 180 334 133 Q 333 115 323 109 Q 317 105 250 119 Q 238 122 239 114 Q 240 108 249 100 Q 309 42 328 6 Q 341 -10 357 3 Q 390 36 390 126 Q 387 169 387 265 L 387 306 Q 387 355 387 416 L 386 457 Z","M 339 289 Q 254 261 161 229 Q 139 222 101 221 Q 86 220 85 207 Q 84 192 94 184 Q 119 166 157 147 Q 169 144 182 154 Q 239 199 338 241 L 387 265 Q 477 314 484 318 Q 499 327 498 337 Q 492 343 479 340 Q 434 324 387 306 L 339 289 Z","M 635 195 Q 690 75 797 -14 Q 876 -62 898 -47 Q 920 -37 914 3 Q 905 34 899 152 Q 900 174 894 178 Q 890 179 884 160 Q 857 75 838 60 Q 823 56 785 88 Q 710 155 670 226 L 644 279 Q 599 381 584 466 L 575 511 Q 547 659 576 752 Q 586 779 543 805 Q 509 827 489 825 Q 470 824 479 795 Q 503 752 507 707 Q 517 601 537 499 L 545 457 Q 573 334 612 245 L 635 195 Z","M 612 245 Q 558 197 452 138 Q 442 132 448 128 Q 455 124 468 126 Q 523 135 574 160 Q 608 175 635 195 L 670 226 Q 706 260 747 317 Q 762 336 778 354 Q 788 361 785 374 Q 781 386 753 410 Q 734 428 723 428 Q 708 427 707 411 Q 701 354 644 279 L 612 245 Z","M 687 669 Q 718 648 754 623 Q 770 613 786 615 Q 798 618 801 632 Q 802 648 789 678 Q 780 697 746 708 Q 665 726 651 715 Q 647 711 651 697 Q 655 687 687 669 Z"],"medians":[[[458,627],[392,631],[336,588],[274,552],[258,550],[253,542],[220,530],[212,532],[203,522]],[[174,404],[215,398],[241,402],[672,514],[742,512]],[[323,556],[351,542],[365,522],[361,116],[340,67],[246,113]],[[100,206],[124,195],[163,189],[492,334]],[[492,807],[537,760],[538,627],[569,435],[612,299],[676,170],[717,112],[779,48],[817,22],[859,12],[880,78],[891,140],[886,147],[894,173]],[[723,412],[737,365],[664,259],[594,198],[489,142],[454,132]],[[657,710],[750,668],[781,634]]],"strokeInfos":[{"strokeMode":29,"strokeName":"撇"},{"strokeMode":27,"strokeName":"橫"},{"strokeMode":40,"strokeName":"豎鉤"},{"strokeMode":1,"strokeName":"提"},{"strokeMode":4,"strokeName":"斜鉤"},{"strokeMode":29,"strokeName":"撇"},{"strokeMode":31,"strokeName":"點"}]}
複製代碼

如何渲染字形

原數據中strokes對應的字形中每一筆的筆畫輪廓數據小程序

<svg version="1.1" viewBox="0 0 1024 1024">

     {/* 田字格繪製 */}

     <g

         key="wordBg"

         stroke="var(--color-text-4)"

         strokeDasharray="1,1"

         strokeWidth="1"

         transform="scale(4, 4)"

      >

         <line x1="0" y1="0" x2="256" y2="0"></line>

         <line x1="0" y1="0" x2="0" y2="256"></line>

         <line x1="256" y1="0" x2="256" y2="256"></line>

         <line x1="0" y1="256" x2="256" y2="256"></line>

         <line x1="0" y1="0" x2="256" y2="256"></line>

         <line x1="256" y1="0" x2="0" y2="256"></line>

         <line x1="128" y1="0" x2="128" y2="256"></line>

         <line x1="0" y1="128" x2="256" y2="128"></line>

     </g>



     {/* 文字svg路徑 */}

     <g transform="scale(1, -1) translate(0, -900)">

         {strokes.map((strokePath, idx) => (

            <path key={strokePath} d={strokePath} />

         ))}

     </g>

 </svg>
複製代碼
  • 設置svgviewBox爲"0 0 1024 1024";由於,在獲取TTF字體字形的指令數據的時候,咱們將對數據作統一化的處理,將字體單位長度都轉化至 1024 單位長度,保證了輸出的動畫數據在使用的時候不須要再作適配。
  • 在繪製文字路徑的時候,注意須要作一個變換transform="scale(1, -1) translate(0, -900)";由於,這裏svg的座標系方向跟字體字形所在的座標系是不同的。
    • 先放一個不作transform的效果

  • transform="scale(1, -1)"後,會將g內的元素,沿着 x 軸作一個反轉,能夠看出要將字形移到田字格的中間,還須要將字形下移

  • *transform="scale(1, -1) translate(0, -900)"*後

這裏爲何不是移動 1024 單位長度呢?由於,TTF字體規範中有一個baseline的概念;在當前的座標系裏面,紅色線爲字體的基準線;yMax = 900, yMin=-124。所以,須要將字形往下移動到baseline的位置。   從圖中座標系(原點在baseline與左邊界的交點處,y 軸正方向朝上)能夠看出,跟svg本來的座標系(原點在左上角,y 軸正方向朝下)是有差異的,因此一開始須要transform的變換,對齊咱們選擇的標準字體的座標系。數組

如何作出動畫效果

  • 經過strokes可以畫出字形的輪廓了,而後怎麼加入描紅效果呢?
    • 這個時候須要用到原數據中的medians字段對應的數據了。medians對應的數據,是中位線的數組,而中位線是中點的數組集合。以下圖

  • 如何將medians數據轉換成動畫數據呢?
  • 計算每一箇中位線的長度
const lengths = medians

    .map((x) => getMedianLength(x))

    .map(Math.round);
複製代碼
  • 計算每一筆中位線的動畫duration&delay
let totalDuration = 0;

for (let i = 0; i < medians.length; i++) {

    const offset = lengths[i] + kWidth;

    const duration = (delay + offset) / speed / 60;

    const fraction = Math.round((100 * offset) / (delay + offset));

    animations.push({

      animationId: `animation-${i}`,

      clipId: `clip-${i}`,

      keyframeId: `keyframes${i}`,

      path: paths[i],

      delay: totalDuration,

      duration,

      fraction,

      length: lengths[i],

      offset,

      spacing: 2 * lengths[i],

      stroke: strokes[i],

      width: kWidth,

    });

    totalDuration += duration;

}
複製代碼
const animationStyle = `@keyframes ${keyframeId} {

        0% {

            stroke: blue;

            stroke-dashoffset: ${animation.offset};

            stroke-width: ${animation.width};

        }

        ${animation.fraction}% {

            /* animation-timing-function: step-end; */

            stroke: blue;

            stroke-dashoffset: 0;

            stroke-width: ${animation.width};

        }

        100% {

            stroke: var(--color-text-1);

            stroke-width: ${STANDARD_LEN};

        }

    }

    #${animationId} {

        animation: ${keyframeId} ${duration}s linear ${delay}s both;

    }

`;
複製代碼
<g key={`${animationId}${playCount}`}>

    <style>{animationStyle}</style>

    <clipPath key={clipId} id={clipId}>

        <path d={stroke} />

    </clipPath>

    <path

        id={animationId}

        clipPath={`url(#${clipId})`}

        d={path}

        fill="none"

        strokeDasharray={`${length} ${spacing}`}

        strokeLinecap="round"

    />

</g>
複製代碼
  • stroke-dashoffsetstroke-dasharray&keyframe動畫效果;像是拿了一把大刷子,按照方向一把刷過去。

  • 優化動畫效果,只須要字形對應的輪廓效果,利用clip-path只保留字形輪廓內的動畫效果

4、數據生產原理

字形點位信息獲取

TTF 字體文件規範

  • 官方-字體配置規定
  • TTF字體生產主要流程(從設計稿原件到數字化字形,再到字體文件中數字化輪廓)

  • 每一個字體都會規定一個EM基準字體框(虛擬的),這個em框通常爲長寬相等的正方形;其中AsecentDescent分別表明字形相對 baseline 的一個距離

  • 同時這裏會有一個FUnit,如:512,1024,2048,來描述em框的相對大小。兩個 em 方塊的網格:左側每em包含 8 個單位,右側每em包含 16 個單位。當這個單位數字越大的時候,對應的字體分辨率就越高,越不容易失真

  • TTF的字形由一個或者多個輪廓(contour)組成,例如:對於「我」字,這裏有兩個contours:綠色部分+藍色部分

  • 將全部的點位,在FUnit座標系裏面進行定位。最終,轉換成在對應座標系下的一系列繪製指令

利用開源工具opentype.js解析 TTF 字體文件

  • 拿到需求字體的座標系信息(ascender: 最頂部距離baseline的距離;descender: 最底部距離 baseline 的距離,通常爲負數;unitsPerEm:FUnit的單位格子數,也能夠認爲是TTF字體所在的座標系大小)

  • 獲取全部輪廓的點位信息以及點位之間的相連關係 (TTF 鏈接點位常見命令:MLQZ)

  • 統一轉換成咱們的標準座標系(1024 * 1024,baseline 到上下距離分別爲 900, 124)

筆畫拆分

以前提到過,TTF字形只會包含多個輪廓,並不感知當前字形具體的筆畫細分。下圖釋義了當前輪廓點將和後面哪個輪廓點鏈接成一條路徑

所以,這裏咱們但願在筆畫交界處讓路徑橫穿過去,因而須要其餘的方法來將咱們須要的漢字筆畫拆解出來。將筆畫拆解出來的關鍵是要識別筆畫公共交界處。

提取 corner 點位

  • 經過比較當前點位在先後路徑中分別做爲終點和起點時候,穿過它的切線角度差(如圖中的r1),若是這個角度差大於18°,則將此點判斷爲拐點(corner),表明字形輪廓在此處有比較大的幅度轉折,有必定多是多筆的交界點。

深度學習拿到corners之間的匹配度

  • 那麼如何判斷這個corner點是否是多筆的交界點呢?這個時候須要比較全部corner點,尋找他們之間是否有關聯關係。要拿到corners中 點與點的關係,須要藉助神經網絡(模型下載地址convnetjs進行深度學習,獲取corners之間的匹配度
  • 獲得corners點與點之間的特徵信息
const getFeatures = (ins: EndPoint, out: EndPoint) => {

  const diff = out.subtract(ins);

  const trivial = diff.equal(new Point([0, 0]));

  const angle = Math.atan2(diff[1], diff[0]); // 兩點之間斜率的弧度

  const distance = Math.sqrt(out.distance2(ins)); // 兩點之間的距離

  return [

    subtractAngle(angle, ins.angles[0]),

    subtractAngle(out.angles[1], angle),

    subtractAngle(ins.angles[1], angle),

    subtractAngle(angle, out.angles[0]),

    subtractAngle(ins.angles[1], ins.angles[0]),

    subtractAngle(out.angles[1], out.angles[0]),

    trivial ? 1 : 0,

    distance / MAX_BRIDGE_DISTANCE,

  ];

};
複製代碼
  • 經過模型訓練corners之間的特徵信息,獲得對應的匹配分數
const input = new convnetjs.Vol(1, 1, 8 /* feature vector dimensions */ );

const net = new convnetjs.Net();

net.fromJSON(NEURAL_NET_TRAINED_FOR_STROKE_EXTRACTION);

const weight = 0.8;



const trainedClassifier = (features: number[]) => {

  input.w = features;

  const softmax = net.forward(input).w;

  return softmax[1] - softmax[0];

};
複製代碼
  • 經過上述,最後獲得一個帶權重的二分圖

  • 利用匈牙利算法,獲得一個最大權重匹配圖。當corner點最大匹配的對象不是自己的時候,就將它們鏈接起來造成一個bridge(兩個corner點相連造成的一個線段),固然也要注意去重不要重複鏈接bridge

1111111111111111111111111.png

筆畫拆分算法

如今咱們經過生成bridge,可以識別出了筆畫的公共交界處了,下一步就須要藉助bridge來對筆畫進行拆分。【下面經過代碼片斷,以及對應的動畫進行解釋】

...

const visited = [];



while (true) {

    /**

 * 直接將目前的路徑片斷添加到result中

 */

    result.push(paths[current[0]][current[1]]);



    /** 記錄當前這一筆visited過的點,到一個局部變量中 */

    visited[get2LenArrKey(current)] = true;



    /** 去到下一個片斷路徑的起始點 */

    current = advance(current);



    const key = get2LenArrKey(current);

    /** 判斷是不是bridge */

    if (bridgeAdjacency.hasOwnProperty(key)) {

      endpoint = endpointMap[key];

      /**

       * 若是當前點位是多個bridge的公共點,

       * 則按照「bridge的切線,直線的切線的斜率等於本身的斜率」與「當前路徑前進的切線方向」角度差大小 從小到達排列,

       * 優先選擇與當前路徑方向切線角度差最小的

       */

      const options = bridgeAdjacency[key].sort(

        (a, b) => angle(endpoint!.pos, a) - angle(endpoint!.pos, b),

      );

      const next = options[0];



      ...



      result.push({

        start: current,

        end: next,

        control: undefined,

      })



      /**

        * 這裏要注意一個點,current被加入到了路徑中,可是沒有被打上visited標籤就直接到下一個點了,

        * 目的是拆解下一筆的時候,這個bridge點就是下一筆的起始點

        */

      current = next;

    }



    const newKey = get2LenArrKey(current);



    if (comp2LenArr(current, start)) {

      /** 當走回到start的點的時候,這一筆就結束了 */

      let numSegmentsOnPath = 0;

      /** 局部visited 同步到 全局的vistied中 */

      for (const index in visited) {

        extractedIndices[index] = true;

        numSegmentsOnPath += 1;

      }

      /** 只有一個點的時候,不造成筆畫 */

      if (numSegmentsOnPath === 1) {

        return undefined;

      }

      return result;

    } else if (extractedIndices[newKey] || visited[newKey]) {

      /** 訪問過的點直接跳過,在這裏判斷是不容許以被訪問過的點開啓一下次局部循環判斷 */

      return undefined;

    }

  }

...
複製代碼
  • 對算法的解釋動畫

原始輪廓指令有一個默認的順序【嚴格有序,ttf保證】,因此對於不是bridge的點,很容易知道當前點的下一個點是哪個

  1. 藍色點表明被標記爲visited的點【首次碰到bridge的一個端點的時候,直接將此點加入路徑,並跳過visited標記,而後走到下一個點】

  2. 當遇到的corner點處有多個bridge的時候,選擇bridge的斜率角度應該與當前筆畫路徑前進方向的切線斜率角度差最小

  3. 紅色的bridge可讓筆畫直接穿過筆畫交界處,並以**線段(Line)**將bridge的兩點相連

筆畫修復

經過bridge將筆畫拆分之後,能夠獲得下圖的展現,看似完美的背後其實仍是有一點兒小瑕疵的:那是由於在bridge鏈接的地方都是經過直線鏈接,會致使筆鋒的位置看上去好像被刀削過同樣

22222222222222.png

  • 將全部直線,換用三次貝塞爾曲線替代
  1. L1與以P1爲終點的上一條路徑片斷相切於P1
  2. L2與以P2爲起點的下一條路徑片斷相切於P2
  3. L1L2交於CP
  4. MP1P1CP間的中點;MP2P2CP間的中點。這兩點將做爲貝塞爾曲線的控制點
  5. 畫三次貝塞爾曲線,即字形圖中黑色的曲線

  • 修復後的效果

333333333.png

筆順動畫

拆分完筆畫之後,此時便到了肯定筆順動畫的時候

獲取筆畫中位線骨幹

  • 增長每一筆筆畫上的採樣點(運用二次貝賽爾曲線公式,拿到更多筆畫上的關鍵點)

export function getPolygonApproximation(

  path: SVGPathType[],

  approximationError = 64,

): PolygonType {

  const result: Point[] = [];

  for (const segment of path) {

    const control = segment.control || segment.start.midpoint(segment.end);

    const distance = Math.sqrt(segment.start.distance2(segment.end));

    const numPoints = Math.floor(distance / approximationError);

    for (let i = 0; i < numPoints; i++) {

      const t = (i + 1) / (numPoints + 1);

      const s = 1 - t;

      result.push(

        new Point([

          s * s * segment.start[0] +

            2 * s * t * control[0] +

            t * t * segment.end[0],

          s * s * segment.start[1] +

            2 * s * t * control[1] +

            t * t * segment.end[1],

        ]),

      );

    }

    result.push(segment.end);

  }

  return result;

}
複製代碼

34.png

  1. 藍色點爲筆畫輪廓上的輪廓點(也至關於泰森多邊形的採樣點)
  2. 泰森多邊形中每個多邊形內的點到對應的採樣點的距離是最短的;也就是說多個多邊形的交界點是距離這些多邊形中採樣點的距離都最短的一個點(也就是圖中黑色的點)
  3. 留下全部筆畫內的黑色點,鏈接黑色點即可以造成控制該筆畫方向的中位線

  1. 控制中位線中關鍵點的數量(保證中位線長度最長,可是點位儘可能最少),最終造成下圖

筆順動畫排序

當拿到全部筆畫對應的方向中位線之後,還須要肯定筆順的前後順序

44444444444.png

  • 對於給定的字形,查漢字結構表得到該字形的結構拆分,構成結構樹

  • 中位線
  1. 將全部子結構的`medians`添加到一個集合(按照結構的拆解順序加入),方便和當前字形生成的`medians`作對比
    複製代碼
  2. 對比子字形結構和當前字形結構的`medians`,並對應打上匹配分數,轉換成帶權重的二分圖匹配問題
    複製代碼
const scoreMedians = (median1: number[][], median2: number[][]) => {

  assert(median1.length === median2.length);

  /** 這裏要記兩個分值,由於對比的兩個median可能恰好只是順序反了,最後取距離差最小的那個 */

  let option1 = 0;

  let option2 = 0;

  range(median1.length).forEach((i) => {

    option1 -= dist2(median1[i], median2[i]);

    option2 -= dist2(median1[i], median2[median2.length - i - 1]);

  });

  return Math.max(option1, option2);

};
複製代碼
  1. 利用匈牙利算法,找出最大權重匹配關係,拿到該字形相對子字形結構的筆畫順序排列。

5、總結

  1. 經過上述算法事後,能夠將筆順數據生成爲 json 格式的文件並存儲在 CDN 上,文件的平均大小在 4 kB 左右。
  2. 筆順動畫數據的生產過程當中,用了比較多的推測對比算法,能知足不少字形的 case;可是依然不能百分之百保證數據的準確性(字形複雜的時候,算法很容易誤判)。因此,在新字體的數據生成過程當中,依然須要人工干預的方式去保證數據的準確性。
  3. 目前筆順後臺也是提供了半自動半人工的方式去生產給定字體以及給定字形狀況下的筆順數據。爲了下降人工成本,須要探索糾錯算法;這樣在作批量生成的時候,能夠有針對性的進行錯誤定位。

團隊招聘

介紹一下咱們團隊,咱們團隊隸屬於字節跳動大力智能部門,一方面從事大力智能做業燈/大力輔導APP以及相關海內外教育產品的前端研發工做,業務場景包含 H5,Flutter,小程序以及各類 Hybrid 場景;另外咱們團隊在 monorepo,微前端,serverless 等各類前沿前端技術也有必定實踐與沉澱。經常使用的技術棧包括可是不限於 React、TS、Nodejs。


歡迎關注「 字節前端 ByteFE 」 簡歷投遞聯繫郵箱「tech@bytedance.com

相關文章
相關標籤/搜索