✊ Give Me the Font, Back to You the Animationhtml
筆順後臺的目標是隻要對於給定的字體文件(WOFF, OTF, TTF )以及須要的字形(漢字,字母 or 其餘各國的語言),就能產出與之對應的筆順動畫數據。是對開源項目Make me han zi的實踐。前端
後臺產出筆順動畫的 json 文件,並經過 CDN 資源分發。肯定字體的狀況下,一個字形對應惟一一個數據資源(字形經過encodeURI
,並去除"%"進行編碼,即"我" -> "E68891")。業務方能夠經過拼接 URL 直接獲取到對應的筆順靜態資源。git
|筆畫的拆解github
|筆順方向調節算法
|縮放&平移功能npm
這裏主要是解釋如何去使用筆順後臺生產的數據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>
複製代碼
svg
的viewBox
爲"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;
}
複製代碼
利用stroke-dashoffset
,stroke-dasharray
&keyframe
&clip-path
製做動畫markdown
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-dashoffset
,stroke-dasharray
&keyframe
動畫效果;像是拿了一把大刷子,按照方向一把刷過去。clip-path
只保留字形輪廓內的動畫效果TTF
字體生產主要流程(從設計稿原件到數字化字形,再到字體文件中數字化輪廓)EM
基準字體框(虛擬的),這個em
框通常爲長寬相等的正方形;其中Asecent
和Descent
分別表明字形相對 baseline 的一個距離FUnit
,如:512,1024,2048,來描述em
框的相對大小。兩個 em 方塊的網格:左側每em
包含 8 個單位,右側每em
包含 16 個單位。當這個單位數字越大的時候,對應的字體分辨率就越高,越不容易失真TTF
的字形由一個或者多個輪廓(contour
)組成,例如:對於「我」字,這裏有兩個contours
:綠色部分+藍色部分FUnit
座標系裏面進行定位。最終,轉換成在對應座標系下的一系列繪製指令ascender
: 最頂部距離baseline
的距離;descender
: 最底部距離 baseline 的距離,通常爲負數;unitsPerEm
:FUnit
的單位格子數,也能夠認爲是TTF
字體所在的座標系大小)以前提到過,TTF
字形只會包含多個輪廓,並不感知當前字形具體的筆畫細分。下圖釋義了當前輪廓點將和後面哪個輪廓點鏈接成一條路徑
所以,這裏咱們但願在筆畫交界處讓路徑橫穿過去,因而須要其餘的方法來將咱們須要的漢字筆畫拆解出來。將筆畫拆解出來的關鍵是要識別筆畫公共交界處。
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
如今咱們經過生成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
的點,很容易知道當前點的下一個點是哪個
藍色點表明被標記爲visited
的點【首次碰到bridge
的一個端點的時候,直接將此點加入路徑,並跳過visited
標記,而後走到下一個點】
當遇到的corner
點處有多個bridge
的時候,選擇bridge
的斜率角度應該與當前筆畫路徑前進方向的切線斜率角度差最小
紅色的bridge
可讓筆畫直接穿過筆畫交界處,並以**線段(Line
)**將bridge
的兩點相連
經過bridge
將筆畫拆分之後,能夠獲得下圖的展現,看似完美的背後其實仍是有一點兒小瑕疵的:那是由於在bridge
鏈接的地方都是經過直線鏈接,會致使筆鋒的位置看上去好像被刀削過同樣
L1
與以P1
爲終點的上一條路徑片斷相切於P1
點L2
與以P2
爲起點的下一條路徑片斷相切於P2
點L1
與L2
交於CP
點MP1
爲P1
與CP
間的中點;MP2
爲P2
與CP
間的中點。這兩點將做爲貝塞爾曲線的控制點拆分完筆畫之後,此時便到了肯定筆順動畫的時候
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;
}
複製代碼
當拿到全部筆畫對應的方向中位線之後,還須要肯定筆順的前後順序
將全部子結構的`medians`添加到一個集合(按照結構的拆解順序加入),方便和當前字形生成的`medians`作對比
複製代碼
對比子字形結構和當前字形結構的`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);
};
複製代碼
介紹一下咱們團隊,咱們團隊隸屬於字節跳動大力智能部門,一方面從事大力智能做業燈/大力輔導APP以及相關海內外教育產品的前端研發工做,業務場景包含 H5,Flutter,小程序以及各類 Hybrid 場景;另外咱們團隊在 monorepo,微前端,serverless 等各類前沿前端技術也有必定實踐與沉澱。經常使用的技術棧包括可是不限於 React、TS、Nodejs。
歡迎關注「 字節前端 ByteFE 」 簡歷投遞聯繫郵箱「tech@bytedance.com」