漢字描紅在Flutter側的落地

大力智慧技術發表於2021-04-08

背景

業務需要

漢字自動描紅是 大力愛輔導-語文字詞專項 中的重要功能部分。前期技術調研階段發現,公司內部已經存在能夠滿足需求效果的Native SDK,但是考慮到以下原因:

  1. 字詞專項的技術選型是Flutter作為主要實現
  2. 漢字繪製或者描紅的需求在多功能、多介面中出現
  3. 大量使用PlatformView對Flutter介面的效能有較大傷害 最終決定,使用Flutter實現一套漢字自動描紅的方案,便於整合和後續相似功能迭代。

Flutter的實現則經過:

  1. 資料可操作性處理
  2. 資料校準
  3. 動畫繪製
  4. 動畫補幀 等操作,最終實現整個漢字描紅功能的落地。

站在巨人的肩膀

無論是已經實現的Native SDK或者Flutter的落地,都不是一蹴而就的,兩套方案皆是基於一套開源方案。其中較為重要的漢字點陣資料來自Github。這部分資源的錄入是完全自動化的,但是其原理還有待發掘,目前是Js實現的一套開源方案。

如何將漢字用程式碼表現出來

這個話題聽起來非常的“程式碼智慧”,程式碼是很難智慧的。如果沒有前人的成果,這個問題確實讓人抓不著頭腦。而上面提到的文字資源提供了我們可行的方案。我們以“八”字的資料為例子,這一節中涉及最主要的欄位是strokes:

{ "strokes": [ "M 317 465 Q 318 338 155 190 Q 136 178 79 126 Q 67 110 85 113 Q 110 114 146 137 Q 258 209 325 305 Q 368 363 406 409 Q 419 422 404 441 Q 355 486 329 484 Q 316 483 317 465 Z", "M 446 687 Q 507 636 530 577 Q 608 343 711 190 Q 732 163 846 151 Q 892 147 958 141 Q 983 140 984 146 Q 984 152 963 163 Q 756 269 675 396 Q 621 480 551 644 Q 530 690 483 702 Q 449 709 445 702 Q 438 692 446 687 Z" ], "medians": [ [ [ 331, 470 ], [ 358, 421 ], [ 291, 303 ], [ 204, 206 ], [ 143, 156 ], [ 89, 123 ] ], [ [ 452, 695 ], [ 484, 681 ], [ 525, 640 ], [ 645, 378 ], [ 693, 302 ], [ 755, 227 ], [ 839, 190 ], [ 978, 147 ] ] ]}
複製程式碼

strokes欄位是一個陣列,陣列的長度表示當前字的筆畫數。陣列元素是一串字串,查閱資料得知這是一串SVG指令,按照一定的格式可以轉化成可讀的座標點,而這麼座標點連線起來則圍成了漢字的筆畫,所有的筆畫疊加起來,就有了字的輪廓。

轉化SVG指令到可讀座標點

遇到這種麻煩的樣板程式碼,我們一般都是Google解決。我們可以簡單瞭解基本原理。像這樣一串SVG指令,其中的M Q H V其實都是一系列指令,和大多數UI SDK中Path的方法可以對照理解。

"M 317 465 Q 318 338 155 190 Q 136 178 79 126 Q 67 110 85 113 Q 110 114 146 137 Q 258 209 325 305 Q 368 363 406 409 Q 419 422 404 441 Q 355 486 329 484 Q 316 483 317 465 Z"
複製程式碼

貼一段資料:

M = moveto(M X,Y) : 將畫筆移動到指定的座標位置
L = lineto(L X,Y) : 畫直線到指定的座標位置
H = horizontal lineto(H X) : 畫水平線到指定的X座標位置
V = vertical lineto(V Y) : 畫垂直線到指定的Y座標位置
C = curveto(C X1,Y1,X2,Y2,ENDX,ENDY) : 三次貝賽曲線
S = smooth curveto(S X2,Y2,ENDX,ENDY)
Q = quadratic Belzier curve(Q X,Y,ENDX,ENDY) : 二次貝賽曲線>> T = smooth quadratic Belzier curveto(T ENDX,ENDY) : 對映
A = elliptical Arc(A RX,RY,XROTATION,FLAG1,FLAG2,X,Y) : 弧線

我們按照上述指定進行操作,最終就能得到一個List物件(在實際程式碼中得到的是Path物件,這裡方便理解用座標點陣列代替)。而這些座標點連起來組成Path,使用Canvas繪製出來,就是我們想要的漢字輪廓。上文提到“八”字的輪廓繪製下來如下圖:

但是SVG座標轉化出的座標值,並不能直接繪製出如上圖的形狀。

轉化原始座標點到可用座標點

我們在上一步得到了文字的座標點是基於如下的一個座標系:

  • 田字格大小: 1024- Y軸朝上

而我們在業務中需要指定的田字格區域,則是一個隨機的正方形,而且我們的canvas座標系是Y軸朝下的。我們記業務中田字格的實際寬度為width,那麼如何把基於1024大小且Y軸向上的座標點,轉化到width大小且Y軸向下的畫布上呢,我們需要經過以下步驟:

  1. 按按比例縮小(scale):將 1024 大小的座標點轉化成基於 width 的座標點
  2. 翻轉y軸(rotation):將座標點轉化成基於 y 軸朝下的座標點
  3. 平移(translate):將翻轉後的座標點平移到“原來的”位置

按比例縮小

首先我們對座標點進行比例縮小:

final scale = width / 1024;
final Point point = Point(point.x * scale, point.y * scale); 
複製程式碼

比例縮小之後的座標點繪製出來看下效果:

可以看到,本來很大的文字輪廓,比例縮小之後則能夠在指定區域內顯示,只是方向不太正確。

翻轉Y軸

這一步我們需要針對座標點,以x軸為中心翻轉y座標:

final point = Point(point.x, -point.y)
複製程式碼

經過座標軸翻轉之後得到的繪製結果如下:

左右圖的對比可以看出,翻轉的座標系x軸其實就是正方形區域的上邊,經過旋轉之後的座標點不出所料的出現在了田字格的上邊,而其顯示方向已經符合預期了。

平移

針對翻轉後文字繪製超出了田字格的問題,我們需要對座標點沿Y軸進行平移,而平移的距離就是田字格的寬度(寬度、高度相等)。

final Point point = Point(point.x, point.y + width);
複製程式碼

這一步得出的繪製結果如下:

經過縮小 + 翻轉 + 平移的三步操作之後,得到的座標點已經是比較理想的形態,如果所有的漢字都如示例一樣聽話,則完全可以作為線上方案發布。隨著case的逐漸增加,發現了經過三步操作之後,依然無法正常繪製的文字:

很明顯,上面的“薅”字下邊超出了田字格。經過長期地Review上述三步操作,貌似並不能找出明顯漏洞所在。

居中矯正

有上面步驟我們得出了最終的可用座標點,但是還有一些瑕疵。隨著case的增加,我發現在有些字上,最終繪製出的字超出了指定範圍。經過在三步操作中打log排查,發現在scale的過程中,一些座標點的值變成負值。這是一個明顯的問題,我們的座標點無論是在y軸向上的座標系,還是y軸向下的座標系,始終都完全保留在第一象限,座標值變為負值,也就意味著最終繪製勢必超出田字格。因為SVG指令到座標點的轉化過程式樣板程式碼,不會只針對我們的場景有問題,如果要追根究底是不是上步操作的順序和數值有問題,則需要研讀開源方案,找出其原理才能找出正確的處理順序,而最快的解決方案則是對不正確的座標點進行糾正。考慮到我們漢字的書寫習慣,大部分的字都是居中顯示的,所以我們可以對經過三步操作的座標點進行居中矯正。居中矯正的步驟如下:

  1. 找出所有座標點中(minX, maxX) 和 (minY, maxY)四個值,計算出使文字居中需要在 x 軸 和 y 軸分別需要的偏移值
  2. 如果偏移值 > 0.5 則對相應座標軸上的座標值進行平移。太小的偏移值沒有必要去浪費計算量。

最終我們得到的繪製結果如下:

經過比較多的case驗證,經過居中矯正的方案可以用於生產。

如何描紅

描紅動畫則如一開始動圖中所示,是“順滑的寫出一筆”,“順滑的”則勢必就要使用到動畫。參考了iOS上的實現:

Flutter則找不到strokeEnd類似的動畫屬性。對於描紅動畫,我們能想到最理想的狀態則是:

  1. 在足夠小的筆畫走勢區間內,應有一個合適寬度的刷子,刷過這個區間
  2. 合適的寬度的計算:經過此區間內筆畫的中心點,並且和兩側的筆畫邊緣儘量垂直

對於以上“合適的寬度”這個計算規則,一個普通開發者在短時間內用程式碼實現出來是很難完成的任務。那我們只能換一種思路。當我看到最終繪製出來的字型是楷體時,我想到了大一的書法課。我們可以想象毛筆在宣紙上寫出一個筆畫的時候,正符合我們所描繪的理想狀態,只是這裡合適的寬度的計算過程,變成了毛筆的受力程度。當把毛筆的書寫過程剖面解析時,他的場景是這樣的:一個圓在二維平面上不斷的移動,圓的走勢和大小決定了筆畫的形狀。這裡有兩個比較重要的點:

  1. 走勢
  2. 大小

筆畫走勢

筆畫的走勢要引入字型資料的第二個欄位:medians。從上面的資料結構可以看出,medians 欄位的資料是一個二維陣列,而維度2的陣列其實可以看成一個Point結構,陣列的兩個元素分別儲存了x座標值和y座標值。那麼整個medians就是一個 List,而這個座標陣列就是筆畫走勢中比較關鍵的座標點,我們叫他骨骼點。連線這些骨骼點就可以看出整個筆畫的走勢。如下圖:

走勢我們已經可以知道,骨骼點的連線,則是毛筆的走位。而剩下的大小則讓我們繞回了原來的問題。

圓的大小

通過計算的方式得到圓的半徑,依然是短時間難以完成的任務。但是另一個書法工具-凹版書法貼給了我靈感。我們無需知道圓的半徑應該是多少,我們可以給圓指定一個固定的半徑,按照上述的筆畫走勢寫出一個很粗的筆畫(圓的半徑要足夠大,要保證圓形區域覆蓋筆畫的邊界,我們取一個經驗數值35,如果田字格範圍擴大,則等比擴大半徑),我們再利用字型的邊界Path,對齊進行框選,則能實現工整的筆畫。剛好Path也提供了我們這樣的方法,我們使用 Path.combine 來模擬書發帖的效果。一個筆畫的繪製步驟如下:

  1. 按照骨骼點的順序,依次以骨骼點繪製出半徑為radius的圓,取所有圓的並集得到一個Path。此時的Path代表了一個很粗的筆畫走勢
  2. 給筆畫加上凹版書法帖:和筆畫的邊界Path取交集得到Path2,這一步可以剔除超出邊界的部分,只保留邊界內的區域。此時的Path2經過書法帖的糾正,已經是很工整的筆畫了
  3. 假設當前筆畫的骨骼點的數目為N,我們把0 -> N-1的繪製過程做成動畫,自動描紅就出來了

不支援在 Docs 外貼上 block

解決文字幀數不夠的問題

雖然描紅動畫如期出現,但是這樣的描紅動畫還是一個不理想的狀態,出現了跳幀和筆畫斷裂的情況。

我們把上述筆畫的骨骼點視覺化之後得到如下圖的樣子:

可以看到整體的骨骼點非常的稀疏,這就導致上面的兩個問題:

  1. 在較短時間內,描紅動畫前進的距離較大,且不均勻
  2. 右側筆畫中第三個到第四個骨骼點之間的距離較大,而我們取的畫筆半徑,在這兩點上繪製的圓形區域是無法取到交集的,於是就出現了斷裂筆畫

從取點方的視角來看,這樣取點也是比較合理的,因為中間筆畫的走勢基本保持的同一個方向,前後兩個點則是關鍵點,中間的區域無需取關鍵點。基於這樣的現狀,如果我們想讓描紅動畫是連續的,那就需要再次補錄一些骨骼點。考慮到動畫 60幀/s時比較流暢,也就是每秒鐘向前走60個間距較小的骨骼點,才會顯得比較流暢。所以補點的時候也應該遵循這樣的原則。補幀步驟如下:

  1. 計算出一個筆畫上所有骨骼點折線的長度
  2. 經過上步的計算,已知繪製一個筆畫的時長(業務自定義),筆畫的長度,則計算出每秒應該前進的筆畫長度
  3. 到這裡,已知每秒前進的距離,除以理想幀數,得到每幀應當前進的距離
  4. 在長度超過每幀前進距離的骨骼點之間,以每幀前進的距離為單位長度,補點

最終我們可以得到這樣的骨骼點:

經過補幀之後,得到的描紅動畫就比較流暢了。

不支援在 Docs 外貼上 block

到這裡,整個筆畫描紅的動畫經過更多的case驗證都無誤的話,就可以用於生產了。

遲到的正義

上面我們說到,排查了很久,都沒找到三步操作的漏洞。直到梳理這期文件時候,為了有據可循,我重新檢視了iOS的原始碼,不小心看到了魔法數字:900。於是我花了一個小時把這個引數加入到三步操作中,奇蹟般地,所有的字在不經過居中校準的情況下,正常顯示。加入魔法數字之後的步驟如下:

  1. 翻轉y軸座標
  2. 延y軸方向平移900
  3. 等比縮小

繪製結果如下圖:(左邊是魔法900,右邊的居中糾正)

可以說兩者的結果別無二致。

這是“卜”字的SVG程式碼:

<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
  <g transform="scale(1, -1) translate(0, -900)">
      <path d="M 519 53 Q 517 149 517 156 L 529 400 L 530 439 Q 531 469 533 518 Q 546 730 559 778 Q 563 793 539 808 Q 508 829 464 837 Q 445 841 433 830 Q 429 825 429 821 Q 428 812 443 790 Q 465 757 466 733 Q 470 664 470 600 L 465 397 Q 461 363 457 296 Q 455 262 443 216 Q 439 171 439 129 Q 437 25 447 -3 Q 462 -58 490 -75 Q 498 -76 502 -71 Q 517 -56 519 53 Z"> </path>
      <path d="M 529 400 Q 570 394 663 410 Q 784 435 791 441 Q 797 447 798 453 Q 798 470 753 483 Q 725 489 622 457 L 530 439 C 501 433 499 403 529 400 Z" ></path>
  </g>
</svg>
複製程式碼

可以看到這裡有一個translate(0, -900)的標誌,猜測是和上述的900保持對應,當然為什麼這麼做有待深挖開源庫的實現。

遺留問題

  • 部分生僻字還無法支援
  • 開始繪製不是從筆畫最頂端開始
  • 繪製豎勾、橫折鉤時存在額外繪製
  • 手寫校驗尚未支援

相關文章