feat: 修改字母描红逻辑,添加字母手写字体
This commit is contained in:
@@ -3,14 +3,20 @@
|
||||
"pages/home/home",
|
||||
"pages/age/age",
|
||||
"pages/favorites/favorites",
|
||||
"pages/profile/profile",
|
||||
"pages/index/index"
|
||||
"pages/profile/profile"
|
||||
],
|
||||
"subPackages": [
|
||||
{
|
||||
"root": "supportPages",
|
||||
"name": "supportPages",
|
||||
"pages": ["guide/guide", "debug/debug"],
|
||||
"pages": [
|
||||
"guide/guide",
|
||||
"debug/debug",
|
||||
"index/index",
|
||||
"mathIndex/mathIndex",
|
||||
"focusIndex/focusIndex",
|
||||
"copyBook/copyBook"
|
||||
],
|
||||
"independent": false
|
||||
},
|
||||
{
|
||||
|
||||
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'j-icon';
|
||||
/* Project id 4942823 */
|
||||
src:
|
||||
url('data:application/x-font-woff;charset=utf-8;base64,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') format('woff'),
|
||||
url('iconfont.ttf?t=1749287800675') format('truetype');
|
||||
}
|
||||
|
||||
.j-icon {
|
||||
font-family: 'j-icon' !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.j-icon-editor:before {
|
||||
content: '\e72a';
|
||||
}
|
||||
|
||||
.j-icon-close:before {
|
||||
content: '\e704';
|
||||
}
|
||||
|
||||
.j-icon-download:before {
|
||||
content: '\e716';
|
||||
}
|
||||
|
||||
.j-icon-error:before {
|
||||
content: '\e73b';
|
||||
}
|
||||
|
||||
.j-icon-close-bold:before {
|
||||
content: '\e70d';
|
||||
}
|
||||
|
||||
.j-icon-translate:before {
|
||||
content: '\e73c';
|
||||
}
|
||||
|
||||
.j-icon-share:before {
|
||||
content: '\e729';
|
||||
}
|
||||
|
||||
.j-icon-share-fill:before {
|
||||
content: '\e728';
|
||||
}
|
||||
|
||||
.j-icon-wechat:before {
|
||||
content: '\e727';
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "toy-icon";
|
||||
/* Project id 4942823 */
|
||||
src:
|
||||
url('data:application/x-font-woff;charset=utf-8;base64,d09GRgABAAAAAAs8AAsAAAAAEIwAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABHU1VCAAABCAAAADsAAABUIIslek9TLzIAAAFEAAAARAAAAGA9GUoMY21hcAAAAYgAAACXAAACFnq9cFdnbHlmAAACIAAABtkAAAlIw4INB2hlYWQAAAj8AAAALwAAADYr/ISZaGhlYQAACSwAAAAcAAAAJAfeA4xobXR4AAAJSAAAAA8AAAAsLAAAAGxvY2EAAAlYAAAAGAAAABgJhAvgbWF4cAAACXAAAAAfAAAAIAEcAJpuYW1lAAAJkAAAAUAAAAJnEKM8sHBvc3QAAArQAAAAagAAAJHJ/5fpeJxjYGRgYOBiMGCwY2BycfMJYeDLSSzJY5BiYGGAAJA8MpsxJzM9kYEDxgPKsYBpDiBmg4gCACY7BUgAeJxjYGFhYJzAwMrAwNTJdIaBgaEfQjO+ZjBi5ACKMrAyM2AFAWmuKQwHnrM8t2Fu+N/AwMB8hwFIMjCiKGICAGnHDKJ4nO2SUQ6CQAxEZ2VZlIhfhj++UeIN4CgeiC+vOcfAaYvGQ9jNazLNtptMF0ANoBIPkYH0QoLFqmryeoXW6xlP6QuOOKAws2PPgROXbQO+euRs+ieSej7n6q8VzUqaevJZNRpdKvjH2fO6q9ZcDWxD7AK5B/aBbYxDYNviGHjvLZDL4D2Q3+AUyHlwDuwXcAnQvAFSRSjvAHichZVdbBxXFYDvz9w7Pzuzs7MzO+P98a53dj1re3/szP7ZeNe21NR2/aMiETsqdVIRYCNcCRSpon0olUOkRghFROQporL60iLKEwKV1z7wAE37AGoitaElUvNEFHiI4AHhgXPHMTQSErujmXPvOffec79z7rmIIfSvP9Ef0glUQtOojVbQNkK40u0EdezzcVzxueMuYstx2+Ww17XaYa8TTHZ6izh0QYsdXin7Qdcq47A3xJ1AjAHbLzboi7capXdKjVu64+i9RhHfKTZ6IEfTnL3D+Ht4h0vRjMTxn6N3oTtr2782bDvrOORHq6VGo7Rq5+yjPwqJVEFcZZyz1aM3xIdMRDu2cRvMn3whJMG+7tE3aRUlYGdTKERoMnSPve302tgrYi5jbmIeLONeHy9hN272Qa7JXr/XmcWBTw/EhEdIvMlvTDeVcqMvl5s3rRyv6wX8K3c4+EX0RlrOfz1v4wt6+au+aZqpxgS4CQ/5zPQGQzFCq0j59I3aOP4lTPHj6CeW94LLHfy1n5Wf980mDEEYwY9ukQ9QHiEVA2RfVsED7ngqhhCEfRW7wL5GmtjWnKwWPYweallHw3b0QEsXVWxjWy2mNXwAeuiPHuKMZue06IGws0EfPVTtrAbLEGDzN/om+TsqoA5wicMUk/lCBMvHIWRuuBx7Y2JL6DJWLXZKkOHsaJdx2ChEBuJrR3tKUbkjFjm0c5lDNWer92zjQAQq5nhQbDaLIPVV9bZQHuLfiu5o/lC1cypsH/x6n75Le8iBXFyFPPRlLsNirgf770FMakEtoL54d0Q7FBpH2NjCwdizEpYFrmXsCVyzENAOQS+PLizC78LoLyfCy988eyZst8MzZ98/ERpaVhtdBMe00UgQHY0Ez/2RRrtPDI2Ff955YmwsEF0TQ2EvF0daNq2MRko6q33jolpAj+O7S+783/gOsKFnfSN6FD0y/KyOQRJtDP+4fXD8jR79b7vH8b0Pue+jb6NLEN8abYnUhoQGcoLbMoYD2u3UsQDUwklcOj7n7TKYCaOg0gmS2IvJe1wArkATjrUY3RedUBNMSJqMA6KY71jZdZ1gsu/Rw9lXpbGsvG5Um1MLNb0zlfFwP+n4H1DqUjenU/fDpOMk8U5z1SrnzRTHGejUTacwETQrEzlqcM7HVAmb33m6sTQ7N82t3KykyfqYHHwYCI1FSPTX9WeMfexqiqL91EjZmfGc0Tl7aoPYqaysyCu6zj1jJV0yb8AiYWHKNHTZNdj4ZPVUdbzgWiao0wwry/PlctaxBi8ShWgWlRPGc89ZEh+TiPTa22+j47y8TW9RF+rI8+i7QLSFu/0lsoiX8RIBACXsOlBJ5CTABMrwAMcknhQ9Mq+1IDP7S5CJXgioeoBXThKZe0XiuTANEO0EgvHkEgSiiDOulwE7CENGTAB1OInxdsIg5NkrWw2JYt3xtKvnbc7t81c1N6NjSgl/+nuVgGXwP6jCB/WPP5kZslyODWeu3WAyJuDFzev19Zboa63X792rr7W4RjSWGWPR1Nz17QndJNL+Fudb+5TgtLG5cGHpM5pOfGv78ubqVMLWMD/7/YLiqPkruxxrab3WqOPE0uC0pq58aX1mwLJZNpj59G59wFJMZhWuYRCaa/X7n9fXmlIuJ4G8d54x5jrszHBBl4ilD0O6ua8o+5s0PGWku+fQ49y9Td+jBlSAPSDtFLHIV0EGMDvecTOuzjWRuQJ7BQDJLK4IrkBGBHMimANbGCFiUQniWEBLMIdJ8IpiyYwG1SvrKSxRyvPG6+dsSbLPvS5bpoopob2N1zYJMZmdY8CKt9YEuP9AvH6Tx2TZjWsnsD/5uD7gSiTrkkTZ6ZWVBJ2dq3FuKMruQZ4nWf7yroTlpFrLd595dWNLZqkcEDm/919En98HGchhjVeAY4oN6nc/PcG7DnzkuI78nPweddESWkMvoFfQZXQNIbsD22qHYn+8wuW4SFZ82HR8RMdF9rmw+wyc6YpfkznUerDoi+s8PvonAusAVz8uu0WYrw/HewgXZK8WxqNBMQ6pCQXBB7sOKDyHiys1dEGo+C04EUW8MzP1g2eXL+04hNG0y2l+cXn79EJvZTpIzCuyP0nhtqez9a8Y7oyX/V3K8yZc9w8p153wPHy36r909aVyFT5PBZOnDbeazhz1ZxudcikxLStlf77VbLebLcUpOPC85SS1tCKZjq3jj1YXF56qb4VUxoz1/Woht9epbxUUo5IiRMMQZtcjuses5HylNbfjwXInz4NhOLfQbZ16BT6aLVlGs+gHH3mZjbo/9JSkmyBEhdbORsa7lLRtWDqZtBO2Y0qKBRH5N6Neg0oAAAB4nGNgZGBgAOI5jqYH4/ltvjJwszCAwJO8BX4I+v8pFgZmMyCXg4EJJAoAIwQKFAB4nGNgZGBgbvjfwBDDwgACQJKRARVwAwBHEQJ0eJxjYWBgYCESAwAETAAtAAAAAAAAbADEAPoBRgG0AeoCkgNIA9gEpHicY2BkYGDgZuhjYGcAASYg5gJCBob/YD4DABfyAbcAeJyFkT1uwkAQhZ/BkASUKEqkNGlWKSgSyfyUSKlQoKegB7PmR7bXWi9I1DlNjpAT5AjpaHOKSHnYQwNFvNrZb96+mR3JAO6wh4fyu+cu2cMls5IruMCjcJX6k7BPfhauoYmecJ36q3ADL3gTbrJjxg6ef8WshXdhD7f4EK7gBp/CVepfwj75W7iGB/wI16n/Cjcw8a6Fm2h54cDqqdNzNdupVWjSyKTueI71YhNP7ak80TZfmVR1g85RGulU22ObfLvoORepyJpEDXmr49iozJq1Dl2wdC7rt9uR6EFoEgxgoTGFY5xDYYYd4wohDFJERXRn+ZjuBTaIWWn/dU/otsipH3KFLgJ0zlwjutLCeTpNji1f61F1dCtuy5qENJRazUlisuIPPNytqYTUAyyLqgx9tLmiE39QzJD8AdiTb1d4nG2LSwrDMAwF9dKm/qRX8aGUSJCAscBWyfXbptvOZmBgaKIfmf6zYMINd8x4ICAiIWPBk5K8hq9HKxZVDrdebN6qDY1iZ6vGErT3b85XLqtVSd65jcquYezc9fNcjqduO3sxojegoB80AAA=') format('woff'),
|
||||
url('iconfont.ttf?t=1749610446637') format('truetype');
|
||||
}
|
||||
|
||||
.toy-icon {
|
||||
font-family: "toy-icon" !important;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.toy-icon-dustbin-o:before {
|
||||
content: "\e71d";
|
||||
}
|
||||
|
||||
.toy-icon-editor-o:before {
|
||||
content: "\e72a";
|
||||
}
|
||||
|
||||
.toy-icon-close:before {
|
||||
content: "\e704";
|
||||
}
|
||||
|
||||
.toy-icon-download:before {
|
||||
content: "\e716";
|
||||
}
|
||||
|
||||
.toy-icon-error-o:before {
|
||||
content: "\e73b";
|
||||
}
|
||||
|
||||
.toy-icon-close-bold:before {
|
||||
content: "\e70d";
|
||||
}
|
||||
|
||||
.toy-icon-translate:before {
|
||||
content: "\e73c";
|
||||
}
|
||||
|
||||
.toy-icon-share-o:before {
|
||||
content: "\e729";
|
||||
}
|
||||
|
||||
.toy-icon-share:before {
|
||||
content: "\e728";
|
||||
}
|
||||
|
||||
.toy-icon-wechat-o:before {
|
||||
content: "\e727";
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 41 KiB |
@@ -14,7 +14,7 @@ import drawHeader from './drawHeader';
|
||||
* - 打印配置管理
|
||||
*/
|
||||
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
||||
const LINE_WIDTH = 2;
|
||||
const LINE_WIDTH = 3;
|
||||
|
||||
export class BaseDrawService {
|
||||
canvas: WechatMiniprogram.Canvas;
|
||||
|
||||
@@ -24,7 +24,7 @@ const META_FONT_PX = 12;
|
||||
const META_UNDERLINE_GAP = 3;
|
||||
const TITLE_COLOR = '#322E25';
|
||||
const LINE_COLOR = 'rgba(50, 46, 37, 0.4)';
|
||||
const LINE_WIDTH = 2;
|
||||
const LINE_WIDTH = 3;
|
||||
const META_COLOR = '#7C766A';
|
||||
const META_ROW_MARGIN_X = 130;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type LetterTracingGeneratorConfig,
|
||||
type LetterTracingMode,
|
||||
} from '../shared/generators/letter-tracing-generator';
|
||||
import { LETTERS_UPPER } from '../../core/data/alphabet';
|
||||
import { LETTERS_UPPER } from '../shared/data/alphabet';
|
||||
|
||||
/** 各 worksheetId 对应的页面标题和描述 */
|
||||
const PAGE_META: Record<string, { title: string; desc: string }> = {
|
||||
@@ -99,7 +99,7 @@ createPage(
|
||||
});
|
||||
},
|
||||
|
||||
/** 页面就绪:初始化 Canvas 和绘制服务,完成后触发首次绘制 */
|
||||
/** 页面就绪:初始化 Canvas → 首次绘制(标题行前缀使用系统 emoji,无需 loadFontFace) */
|
||||
onReady() {
|
||||
this.initCanvas({
|
||||
createDrawService: (
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,124 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
|
||||
const LETTER_FACE =
|
||||
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
type CasePairingData = Extract<LetterTracingData, { mode: 'case-pairing' }>;
|
||||
|
||||
export default class CasePairingDraw extends BaseDrawService {
|
||||
async draw(data: CasePairingData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawContent(data.leftVisualRows, data.rightVisualRows);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawContent(
|
||||
leftVisualRows: TracingRow[][],
|
||||
rightVisualRows: TracingRow[][],
|
||||
) {
|
||||
const M = 24;
|
||||
const y0 = this.currentY + 6;
|
||||
const bottom = this.canvasHeight - M;
|
||||
const gapCol = 18;
|
||||
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||
const maxVisual = Math.max(
|
||||
leftVisualRows.length,
|
||||
rightVisualRows.length,
|
||||
);
|
||||
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
|
||||
|
||||
const drawSide = (
|
||||
blocks: TracingRow[][],
|
||||
startX: number,
|
||||
width: number,
|
||||
) => {
|
||||
let y = y0;
|
||||
for (const visualRow of blocks) {
|
||||
const nSeg = visualRow.length;
|
||||
const segGap = 6;
|
||||
const segW = (width - (nSeg - 1) * segGap) / nSeg;
|
||||
for (let s = 0; s < nSeg; s++) {
|
||||
this.drawTracingRow(
|
||||
visualRow[s],
|
||||
startX + s * (segW + segGap),
|
||||
y,
|
||||
rowSlotH,
|
||||
segW,
|
||||
);
|
||||
}
|
||||
y += rowSlotH + 8;
|
||||
}
|
||||
};
|
||||
|
||||
drawSide(leftVisualRows, M, colW);
|
||||
drawSide(rightVisualRows, M + colW + gapCol, colW);
|
||||
}
|
||||
|
||||
private drawTracingRow(
|
||||
row: TracingRow,
|
||||
startX: number,
|
||||
y: number,
|
||||
rowH: number,
|
||||
totalWidth: number,
|
||||
) {
|
||||
const gap = 4;
|
||||
const n = row.cells.length;
|
||||
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = startX + i * (cellW + gap);
|
||||
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
|
||||
const cell = row.cells[i];
|
||||
this.drawCharInCell(
|
||||
cell.char,
|
||||
cx,
|
||||
y,
|
||||
cellW,
|
||||
rowH,
|
||||
cell.opacity,
|
||||
cell.isGuide,
|
||||
cell.pairChar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private drawCharInCell(
|
||||
ch: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
opacity: number,
|
||||
bold: boolean,
|
||||
pairChar?: string,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
if (opacity <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const baselineY = y + h * 0.72;
|
||||
|
||||
if (pairChar) {
|
||||
const fs = Math.min(h * 0.5, w * 0.22);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fs}px ${LETTER_FACE}`;
|
||||
const gap = fs * 0.2;
|
||||
const totalW = fs * 2 + gap;
|
||||
const x0 = x + w / 2 - totalW / 2;
|
||||
ctx.fillText(ch, x0 + fs * 0.5, baselineY);
|
||||
ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY);
|
||||
} else {
|
||||
const fontSize = Math.min(h * 0.72, w * 0.85);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${LETTER_FACE}`;
|
||||
ctx.fillText(ch, x + w / 2, baselineY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
|
||||
const LETTER_FACE =
|
||||
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
type ColumnCompareData = Extract<
|
||||
LetterTracingData,
|
||||
{ mode: 'column-compare' }
|
||||
>;
|
||||
|
||||
export default class ColumnCompareDraw extends BaseDrawService {
|
||||
async draw(data: ColumnCompareData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawContent(data.leftRows, data.rightRows);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawContent(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
||||
const M = 24;
|
||||
const y0 = this.currentY + 6;
|
||||
const bottom = this.canvasHeight - M;
|
||||
const maxR = Math.max(leftRows.length, rightRows.length);
|
||||
const rowH = Math.max(36, (bottom - y0) / maxR - 6);
|
||||
const gapCol = 16;
|
||||
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||
|
||||
for (let i = 0; i < leftRows.length; i++) {
|
||||
const y = y0 + i * (rowH + 6);
|
||||
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
|
||||
}
|
||||
const xR = M + colW + gapCol;
|
||||
for (let i = 0; i < rightRows.length; i++) {
|
||||
const y = y0 + i * (rowH + 6);
|
||||
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
|
||||
}
|
||||
}
|
||||
|
||||
private drawTracingRow(
|
||||
row: TracingRow,
|
||||
startX: number,
|
||||
y: number,
|
||||
rowH: number,
|
||||
totalWidth: number,
|
||||
) {
|
||||
const gap = 4;
|
||||
const n = row.cells.length;
|
||||
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = startX + i * (cellW + gap);
|
||||
drawFourLineGrid(this.ctx, cx, y, cellW, rowH);
|
||||
const cell = row.cells[i];
|
||||
this.drawCharInCell(
|
||||
cell.char,
|
||||
cx,
|
||||
y,
|
||||
cellW,
|
||||
rowH,
|
||||
cell.opacity,
|
||||
cell.isGuide,
|
||||
cell.pairChar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private drawCharInCell(
|
||||
ch: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
opacity: number,
|
||||
bold: boolean,
|
||||
pairChar?: string,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
if (opacity <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const baselineY = y + h * 0.72;
|
||||
|
||||
if (pairChar) {
|
||||
const fs = Math.min(h * 0.5, w * 0.22);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fs}px ${LETTER_FACE}`;
|
||||
const gap = fs * 0.2;
|
||||
const totalW = fs * 2 + gap;
|
||||
const x0 = x + w / 2 - totalW / 2;
|
||||
ctx.fillText(ch, x0 + fs * 0.5, baselineY);
|
||||
ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY);
|
||||
} else {
|
||||
const fontSize = Math.min(h * 0.72, w * 0.85);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${LETTER_FACE}`;
|
||||
ctx.fillText(ch, x + w / 2, baselineY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 绘制工具集合(后续可继续扩展其他通用绘制方法)
|
||||
*/
|
||||
|
||||
export type FourLineGridStyle = {
|
||||
/** 顶线/底线颜色 */
|
||||
ink?: string;
|
||||
/** 中间两条虚线颜色 */
|
||||
middleInk?: string;
|
||||
/** 线宽 */
|
||||
lineWidth?: number;
|
||||
/** 虚线样式 */
|
||||
dash?: number[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 四线三格(通用)
|
||||
* - 顶/底:实线
|
||||
* - 中间两条:虚线
|
||||
*/
|
||||
export function drawFourLineGrid(
|
||||
ctx: RenderingContext,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
style?: FourLineGridStyle,
|
||||
) {
|
||||
const ink = style?.ink ?? '#322E25';
|
||||
const middleInk = style?.middleInk ?? 'rgba(50, 46, 37, 0.3)';
|
||||
const lineWidth = style?.lineWidth ?? 1;
|
||||
const dash = style?.dash ?? [4, 4];
|
||||
|
||||
const yTop = y;
|
||||
const y1 = y + h / 3;
|
||||
const y2 = y + (h * 2) / 3;
|
||||
const yBot = y + h;
|
||||
|
||||
ctx.strokeStyle = ink;
|
||||
ctx.lineWidth = lineWidth;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yTop);
|
||||
ctx.lineTo(x + w, yTop);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = middleInk;
|
||||
ctx.setLineDash(dash);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y1);
|
||||
ctx.lineTo(x + w, y1);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y2);
|
||||
ctx.lineTo(x + w, y2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.strokeStyle = ink;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yBot);
|
||||
ctx.lineTo(x + w, yBot);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
@@ -1,330 +1,24 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
TracingRow,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
|
||||
const M = 24;
|
||||
/** 四线三格线色:偏黑,实线与虚线统一色相,仅以线型区分 */
|
||||
const LINE_INK = '#2a2a2a';
|
||||
import type { LetterTracingData } from '../generators/letter-tracing-generator';
|
||||
import PictureTracingDraw from './pictureTracingDraw';
|
||||
import CasePairingDraw from './casePairingDraw';
|
||||
import ColumnCompareDraw from './columnCompareDraw';
|
||||
import OverviewDraw from './overviewDraw';
|
||||
|
||||
/**
|
||||
* 字母描红绘制
|
||||
* 四线三格:顶线、上中线、下中线为格内三线间距;最上最下为实线,中间两虚线(产品文档)
|
||||
* 字母描红绘制分发层。
|
||||
* 根据 data.mode 委托给对应的独立 DrawService 完成实际绘制。
|
||||
*/
|
||||
export default class LetterTracingDraw extends BaseDrawService {
|
||||
constructor(
|
||||
canvas: Canvas,
|
||||
ctx: RenderingContext,
|
||||
options?: Record<string, unknown>,
|
||||
) {
|
||||
super(canvas, ctx, options);
|
||||
}
|
||||
|
||||
async draw(data: LetterTracingData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
const DrawClass = {
|
||||
'picture-tracing': PictureTracingDraw,
|
||||
'case-pairing': CasePairingDraw,
|
||||
'column-compare': ColumnCompareDraw,
|
||||
overview: OverviewDraw,
|
||||
}[data.mode];
|
||||
|
||||
switch (data.mode) {
|
||||
case 'picture-tracing':
|
||||
this.drawPictureTracing(data);
|
||||
this.drawPrintFooter();
|
||||
break;
|
||||
case 'case-pairing':
|
||||
this.drawCasePairing(data.leftVisualRows, data.rightVisualRows);
|
||||
this.drawPrintFooter();
|
||||
break;
|
||||
case 'column-compare':
|
||||
this.drawColumnCompare(data.leftRows, data.rightRows);
|
||||
this.drawPrintFooter();
|
||||
break;
|
||||
case 'overview':
|
||||
this.drawOverview(data.upperRows, data.lowerRows);
|
||||
this.drawPrintFooter();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 四线三格:y 顶 → y+h 底,中间两条虚线
|
||||
*/
|
||||
private drawFourLines(x: number, y: number, w: number, h: number) {
|
||||
const ctx = this.ctx;
|
||||
const yTop = y;
|
||||
const yMid1 = y + h * 0.28;
|
||||
const yMid2 = y + h * 0.58;
|
||||
const yBot = y + h;
|
||||
|
||||
const strokeSolid = () => {
|
||||
ctx.strokeStyle = LINE_INK;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
};
|
||||
const strokeDash = () => {
|
||||
ctx.strokeStyle = LINE_INK;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([5, 5]);
|
||||
};
|
||||
|
||||
strokeSolid();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yTop);
|
||||
ctx.lineTo(x + w, yTop);
|
||||
ctx.stroke();
|
||||
|
||||
strokeDash();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yMid1);
|
||||
ctx.lineTo(x + w, yMid1);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yMid2);
|
||||
ctx.lineTo(x + w, yMid2);
|
||||
ctx.stroke();
|
||||
|
||||
strokeSolid();
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yBot);
|
||||
ctx.lineTo(x + w, yBot);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
private drawCharInCell(
|
||||
ch: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
opacity: number,
|
||||
bold: boolean,
|
||||
pairChar?: string,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
if (opacity <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const baselineY = y + h * 0.72;
|
||||
|
||||
if (pairChar) {
|
||||
const fs = Math.min(h * 0.5, w * 0.22);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fs}px sans-serif`;
|
||||
const gap = fs * 0.2;
|
||||
const totalW = fs * 2 + gap;
|
||||
const x0 = x + w / 2 - totalW / 2;
|
||||
ctx.fillText(ch, x0 + fs * 0.5, baselineY);
|
||||
ctx.fillText(pairChar, x0 + fs * 1.5 + gap, baselineY);
|
||||
} else {
|
||||
const fontSize = Math.min(h * 0.72, w * 0.85);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px sans-serif`;
|
||||
ctx.fillText(ch, x + w / 2, baselineY);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawTracingRow(
|
||||
row: TracingRow,
|
||||
startX: number,
|
||||
y: number,
|
||||
rowH: number,
|
||||
totalWidth: number,
|
||||
) {
|
||||
const gap = 4;
|
||||
const n = row.cells.length;
|
||||
const cellW = n > 0 ? (totalWidth - gap * (n - 1)) / n : totalWidth;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = startX + i * (cellW + gap);
|
||||
this.drawFourLines(cx, y, cellW, rowH);
|
||||
const cell = row.cells[i];
|
||||
this.drawCharInCell(
|
||||
cell.char,
|
||||
cx,
|
||||
y,
|
||||
cellW,
|
||||
rowH,
|
||||
cell.opacity,
|
||||
cell.isGuide,
|
||||
cell.pairChar,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 看图描红:上左超大字母+例句,上右「配图」大 emoji;下 6 行四线三格
|
||||
*/
|
||||
private drawPictureTracing(
|
||||
data: Extract<LetterTracingData, { mode: 'picture-tracing' }>,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const { teaching, rows } = data;
|
||||
const cw = this.canvasWidth;
|
||||
let y = this.currentY + 8;
|
||||
const teachH = Math.min(240, (this.canvasHeight - y) * 0.38);
|
||||
const innerW = cw - M * 2;
|
||||
const splitX = M + innerW * 0.52;
|
||||
|
||||
ctx.fillStyle = '#f8fafc';
|
||||
ctx.fillRect(M, y, innerW, teachH);
|
||||
|
||||
// 左侧:大小写 + 例句(短句在字母下方)
|
||||
const leftPad = M + 20;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = '#111';
|
||||
const bigFs = Math.min(56, teachH * 0.26);
|
||||
ctx.font = `bold ${bigFs}px sans-serif`;
|
||||
ctx.fillText(
|
||||
`${teaching.upper} ${teaching.lower}`,
|
||||
leftPad,
|
||||
y + teachH * 0.12,
|
||||
);
|
||||
ctx.font = `${Math.min(22, teachH * 0.1)}px sans-serif`;
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillText(teaching.sentence, leftPad, y + teachH * 0.48);
|
||||
|
||||
// 右侧:配图区(以 emoji 作为插图占位)
|
||||
ctx.font = `${Math.min(120, teachH * 0.55)}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
teaching.emoji,
|
||||
splitX + (cw - M - splitX) / 2,
|
||||
y + teachH / 2,
|
||||
);
|
||||
|
||||
y += teachH + 12;
|
||||
this.drawDashedDivider(y, M, '#ccc');
|
||||
y += 14;
|
||||
|
||||
const rest = this.canvasHeight - y - M;
|
||||
const rowH = Math.max(34, (rest - 10) / rows.length);
|
||||
const rowWidth = innerW;
|
||||
for (let r = 0; r < rows.length; r++) {
|
||||
this.drawTracingRow(
|
||||
rows[r],
|
||||
M,
|
||||
y + r * (rowH + 8),
|
||||
rowH,
|
||||
rowWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 大小写配对:左右两栏,每行最多 4 组子行并排
|
||||
*/
|
||||
private drawCasePairing(
|
||||
leftVisualRows: TracingRow[][],
|
||||
rightVisualRows: TracingRow[][],
|
||||
) {
|
||||
const y0 = this.currentY + 6;
|
||||
const bottom = this.canvasHeight - M;
|
||||
const gapCol = 18;
|
||||
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||
const maxVisual = Math.max(
|
||||
leftVisualRows.length,
|
||||
rightVisualRows.length,
|
||||
);
|
||||
const rowSlotH = Math.max(40, (bottom - y0) / maxVisual - 6);
|
||||
|
||||
const drawSide = (
|
||||
blocks: TracingRow[][],
|
||||
startX: number,
|
||||
width: number,
|
||||
) => {
|
||||
let y = y0;
|
||||
for (const visualRow of blocks) {
|
||||
const nSeg = visualRow.length;
|
||||
const segGap = 6;
|
||||
const segW = (width - (nSeg - 1) * segGap) / nSeg;
|
||||
for (let s = 0; s < nSeg; s++) {
|
||||
this.drawTracingRow(
|
||||
visualRow[s],
|
||||
startX + s * (segW + segGap),
|
||||
y,
|
||||
rowSlotH,
|
||||
segW,
|
||||
);
|
||||
}
|
||||
y += rowSlotH + 8;
|
||||
}
|
||||
};
|
||||
|
||||
drawSide(leftVisualRows, M, colW);
|
||||
drawSide(rightVisualRows, M + colW + gapCol, colW);
|
||||
}
|
||||
|
||||
/** 分栏对照:左大写、右小写,逐行对齐 */
|
||||
private drawColumnCompare(leftRows: TracingRow[], rightRows: TracingRow[]) {
|
||||
const y0 = this.currentY + 6;
|
||||
const bottom = this.canvasHeight - M;
|
||||
const maxR = Math.max(leftRows.length, rightRows.length);
|
||||
const rowH = Math.max(36, (bottom - y0) / maxR - 6);
|
||||
const gapCol = 16;
|
||||
const colW = (this.canvasWidth - M * 2 - gapCol) / 2;
|
||||
|
||||
for (let i = 0; i < leftRows.length; i++) {
|
||||
const y = y0 + i * (rowH + 6);
|
||||
this.drawTracingRow(leftRows[i], M, y, rowH, colW);
|
||||
}
|
||||
const xR = M + colW + gapCol;
|
||||
for (let i = 0; i < rightRows.length; i++) {
|
||||
const y = y0 + i * (rowH + 6);
|
||||
this.drawTracingRow(rightRows[i], xR, y, rowH, colW);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 字母总览:Uppercase Letters / lowercase Letters,各行 9+9+8,四线三格
|
||||
*/
|
||||
private drawOverview(upperRows: string[][], lowerRows: string[][]) {
|
||||
const ctx = this.ctx;
|
||||
let y = this.currentY + 6;
|
||||
const innerW = this.canvasWidth - M * 2;
|
||||
|
||||
const title = (t: string) => {
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.font = 'bold 17px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(t, M, y);
|
||||
y += 24;
|
||||
};
|
||||
|
||||
const drawBand = (rows: string[][]) => {
|
||||
for (const line of rows) {
|
||||
const n = line.length;
|
||||
const gap = 4;
|
||||
const cellW = (innerW - (n - 1) * gap) / n;
|
||||
const cellH = 30;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = M + i * (cellW + gap);
|
||||
this.drawFourLines(cx, y, cellW, cellH);
|
||||
this.drawCharInCell(
|
||||
line[i],
|
||||
cx,
|
||||
y,
|
||||
cellW,
|
||||
cellH,
|
||||
0.9,
|
||||
true,
|
||||
);
|
||||
}
|
||||
y += cellH + 8;
|
||||
}
|
||||
};
|
||||
|
||||
title('Uppercase Letters');
|
||||
drawBand(upperRows);
|
||||
y += 6;
|
||||
this.drawDashedDivider(y, M, '#bbb');
|
||||
y += 12;
|
||||
title('lowercase Letters');
|
||||
drawBand(lowerRows);
|
||||
const delegate = new DrawClass(this.canvas, this.ctx, this.options);
|
||||
await delegate.draw(data as any);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import type { LetterTracingData } from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
|
||||
const LETTER_FACE =
|
||||
'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
|
||||
|
||||
type OverviewData = Extract<LetterTracingData, { mode: 'overview' }>;
|
||||
|
||||
export default class OverviewDraw extends BaseDrawService {
|
||||
async draw(data: OverviewData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
this.drawContent(data.upperRows, data.lowerRows);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private drawContent(upperRows: string[][], lowerRows: string[][]) {
|
||||
const ctx = this.ctx;
|
||||
const M = 24;
|
||||
let y = this.currentY + 6;
|
||||
const innerW = this.canvasWidth - M * 2;
|
||||
|
||||
const drawTitle = (t: string) => {
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.font = 'bold 17px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(t, M, y);
|
||||
y += 24;
|
||||
};
|
||||
|
||||
const drawBand = (rows: string[][]) => {
|
||||
for (const line of rows) {
|
||||
const n = line.length;
|
||||
const gap = 4;
|
||||
const cellW = (innerW - (n - 1) * gap) / n;
|
||||
const cellH = 30;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const cx = M + i * (cellW + gap);
|
||||
drawFourLineGrid(ctx, cx, y, cellW, cellH);
|
||||
this.drawCharInCell(
|
||||
line[i],
|
||||
cx,
|
||||
y,
|
||||
cellW,
|
||||
cellH,
|
||||
0.9,
|
||||
true,
|
||||
);
|
||||
}
|
||||
y += cellH + 8;
|
||||
}
|
||||
};
|
||||
|
||||
drawTitle('Uppercase Letters');
|
||||
drawBand(upperRows);
|
||||
y += 6;
|
||||
this.drawDashedDivider(y, M, '#bbb');
|
||||
y += 12;
|
||||
drawTitle('lowercase Letters');
|
||||
drawBand(lowerRows);
|
||||
}
|
||||
|
||||
private drawCharInCell(
|
||||
ch: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
opacity: number,
|
||||
bold: boolean,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
if (opacity <= 0) return;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = bold ? '#1a1a1a' : '#4a4a4a';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
const baselineY = y + h * 0.72;
|
||||
const fontSize = Math.min(h * 0.72, w * 0.85);
|
||||
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px ${LETTER_FACE}`;
|
||||
ctx.fillText(ch, x + w / 2, baselineY);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import { BaseDrawService } from '../../../core/draw/baseDraw';
|
||||
import { getImage } from '../../../utils/index';
|
||||
import type {
|
||||
LetterTracingData,
|
||||
HighlightGrid,
|
||||
} from '../generators/letter-tracing-generator';
|
||||
import { drawFourLineGrid } from './drawTools';
|
||||
|
||||
const LINE_INK_ALPHA = 'rgba(50, 46, 37, 1)';
|
||||
const ACCENT_RED = 'rgba(252, 46, 0, 1)';
|
||||
//'Helvetica, Arial, "Segoe UI", Roboto, sans-serif';
|
||||
const LETTER_FACE = 'Roboto, sans-serif';
|
||||
const TITLE_EMOJI_FONT =
|
||||
"16px 'Apple Color Emoji','Segoe UI Emoji','Noto Color Emoji',sans-serif";
|
||||
const TITLE_TEXT_FONT = '14px sans-serif';
|
||||
const TITLE_ICON_GAP = 4;
|
||||
|
||||
type PictureData = Extract<LetterTracingData, { mode: 'picture-tracing' }>;
|
||||
|
||||
export default class PictureTracingDraw extends BaseDrawService {
|
||||
async draw(data: PictureData) {
|
||||
this.prepareDraw();
|
||||
await this.drawHeaderAndDivider();
|
||||
await this.drawContent(data);
|
||||
this.drawPrintFooter();
|
||||
}
|
||||
|
||||
private async drawContent(data: PictureData) {
|
||||
const { teaching, highlightGrid, traceImgPath } = data;
|
||||
const ctx = this.ctx;
|
||||
|
||||
// ── 上区:left:78 top:120 w:451 h:180 ──
|
||||
const TOP_X = 78;
|
||||
const TOP_Y = 120;
|
||||
|
||||
const letterImgX = TOP_X + 15;
|
||||
const letterImgY = TOP_Y + 9;
|
||||
const letterImgW = 180;
|
||||
const letterImgH = 103.5;
|
||||
|
||||
try {
|
||||
const letterImg = (await getImage(
|
||||
this.canvas,
|
||||
teaching.letterImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (letterImg.width || 1) / (letterImg.height || 1);
|
||||
let dw = letterImgW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > letterImgH) {
|
||||
dh = letterImgH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
letterImg,
|
||||
letterImgX + (letterImgW - dw) / 2,
|
||||
letterImgY + (letterImgH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
ctx.font = `bold 60px ${LETTER_FACE}`;
|
||||
ctx.fillStyle = '#111';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
`${teaching.upper} ${teaching.lower}`,
|
||||
letterImgX + letterImgW / 2,
|
||||
letterImgY + letterImgH / 2,
|
||||
);
|
||||
}
|
||||
|
||||
this.drawSentence(
|
||||
teaching.sentence,
|
||||
teaching.upper,
|
||||
TOP_X,
|
||||
TOP_Y + 9 + 120,
|
||||
);
|
||||
|
||||
// 右侧配图
|
||||
const appleX = TOP_X + 271;
|
||||
const appleY = TOP_Y;
|
||||
const appleW = 180;
|
||||
const appleH = 180;
|
||||
|
||||
try {
|
||||
const wordImg = (await getImage(
|
||||
this.canvas,
|
||||
teaching.wordImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (wordImg.width || 1) / (wordImg.height || 1);
|
||||
let dw = appleW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > appleH) {
|
||||
dh = appleH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
wordImg,
|
||||
appleX + (appleW - dw) / 2,
|
||||
appleY + (appleH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
ctx.font = `${Math.min(80, appleH * 0.5)}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
teaching.emoji,
|
||||
appleX + appleW / 2,
|
||||
appleY + appleH / 2,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 中区 ──
|
||||
const HL_X = 26,
|
||||
HL_Y = 320,
|
||||
HL_W = 272,
|
||||
HL_H = 181;
|
||||
const TR_X = 298,
|
||||
TR_Y = 320,
|
||||
TR_W = 272,
|
||||
TR_H = 181;
|
||||
|
||||
ctx.strokeStyle = LINE_INK_ALPHA;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(HL_X, HL_Y, HL_W, HL_H);
|
||||
ctx.strokeRect(TR_X, TR_Y, TR_W, TR_H);
|
||||
|
||||
this.drawHighlightSection(highlightGrid, HL_X, HL_Y);
|
||||
await this.drawTraceItSection(
|
||||
traceImgPath,
|
||||
teaching.upper,
|
||||
teaching.lower,
|
||||
TR_X,
|
||||
TR_Y,
|
||||
);
|
||||
|
||||
// ── 下区:4 行四线三格 + 字母临摹(倒置直角三角形)──
|
||||
// 每行:left:24 w:547 h:43,top 分别 524.5 / 590.5 / 656.5 / 722.5
|
||||
const lineX = 24;
|
||||
const lineW = 547;
|
||||
const lineH = 43;
|
||||
const lineTops = [524.5, 590.5, 656.5, 722.5];
|
||||
|
||||
// 8 → 6 → 3 → 1 组(每组为 Upper+Lower),左对齐,形成倒置直角三角形
|
||||
const groupsPerLine = [8, 6, 3, 1];
|
||||
const slotW = lineW / groupsPerLine[0]; // 以第一行的 8 组为基准
|
||||
|
||||
for (let r = 0; r < lineTops.length; r++) {
|
||||
const y = lineTops[r];
|
||||
drawFourLineGrid(ctx, lineX, y, lineW, lineH);
|
||||
|
||||
const n = groupsPerLine[r] ?? 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
// 第一组不透明,后面逐次更透明
|
||||
const alpha = n <= 1 ? 1 : 1 - (i / (n - 1)) * 0.7; // 1 → 0.3
|
||||
this.drawUpperLowerPairInFourLines(
|
||||
teaching.upper,
|
||||
teaching.lower,
|
||||
lineX + i * slotW,
|
||||
y,
|
||||
slotW,
|
||||
lineH,
|
||||
alpha,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在四线三格中绘制 Upper+Lower(遵循书写基线规范)
|
||||
*
|
||||
* 四线三格的 4 条线(从上到下):
|
||||
* Top = y ── 顶线
|
||||
* Midline = y + h/3 ── 中线(虚线)
|
||||
* Baseline = y + 2h/3 ── 基线(虚线)
|
||||
* Bottom = y + h ── 底线
|
||||
*
|
||||
* 大写字母:高度占满 Top → Baseline(即第一格 + 第二格),字号 ≈ h * 2/3
|
||||
* 小写字母:高度占满 Midline → Baseline(即第二格),字号 ≈ h * 1/3
|
||||
* 两个字母共享 Baseline,间距远小于组间距
|
||||
*/
|
||||
private drawUpperLowerPairInFourLines(
|
||||
upper: string,
|
||||
lower: string,
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
alpha: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
ctx.save();
|
||||
ctx.globalAlpha = Math.max(0, Math.min(1, alpha));
|
||||
ctx.fillStyle = 'rgba(26, 26, 26, 1)';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
const baselineY = y + (h * 2) / 3;
|
||||
|
||||
// 大写占满 Top→Baseline(2 格高),小写占满 Midline→Baseline(1 格高)
|
||||
const upperFs = h * (2 / 3);
|
||||
const lowerFs = h * (1 / 3);
|
||||
|
||||
// 字母间距:固定 3px,远小于组间距(slotW ≈ 68)
|
||||
const pairGap = 3;
|
||||
|
||||
ctx.font = `${upperFs}px ${LETTER_FACE}`;
|
||||
const upperW = ctx.measureText(upper).width;
|
||||
|
||||
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
|
||||
const lowerW = ctx.measureText(lower).width;
|
||||
|
||||
const totalPairW = upperW + pairGap + lowerW;
|
||||
const pairStartX = x + (w - totalPairW) / 2;
|
||||
|
||||
ctx.font = `${upperFs}px ${LETTER_FACE}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(upper, pairStartX, baselineY);
|
||||
|
||||
ctx.font = `${lowerFs}px ${LETTER_FACE}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(lower, pairStartX + upperW + pairGap, baselineY);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private drawSentence(
|
||||
sentence: string,
|
||||
letter: string,
|
||||
x: number,
|
||||
y: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
ctx.font = `36px ${LETTER_FACE}`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
let cx = x;
|
||||
for (let i = 0; i < sentence.length; i++) {
|
||||
const ch = sentence[i];
|
||||
ctx.fillStyle =
|
||||
ch.toUpperCase() === letter ? ACCENT_RED : 'rgba(0, 0, 0, 1)';
|
||||
ctx.fillText(ch, cx, y);
|
||||
cx += ctx.measureText(ch).width;
|
||||
}
|
||||
}
|
||||
|
||||
private drawHighlightSection(grid: HighlightGrid, sx: number, sy: number) {
|
||||
const ctx = this.ctx;
|
||||
const titleX = sx + 16;
|
||||
const titleY = sy + 12;
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const hlIcon = '\u{1F50D}';
|
||||
ctx.font = TITLE_EMOJI_FONT;
|
||||
ctx.fillText(hlIcon, titleX, titleY + 1);
|
||||
const hlIconW = ctx.measureText(hlIcon).width;
|
||||
|
||||
ctx.font = TITLE_TEXT_FONT;
|
||||
ctx.fillText(
|
||||
'Highlight it !',
|
||||
titleX + hlIconW + TITLE_ICON_GAP,
|
||||
titleY,
|
||||
);
|
||||
|
||||
const gridX = sx + 16;
|
||||
const gridY = sy + 45;
|
||||
const cellSize = 40;
|
||||
const cols = grid.rows[0]?.length ?? 6;
|
||||
const rows = grid.rows.length;
|
||||
|
||||
for (let r = 0; r < rows; r++) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
const cx = gridX + c * cellSize;
|
||||
const cy = gridY + r * cellSize;
|
||||
ctx.strokeStyle = 'rgba(0, 0, 0, 1)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.strokeRect(cx, cy, cellSize, cellSize);
|
||||
|
||||
ctx.font = `18px ${LETTER_FACE}`;
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(
|
||||
grid.rows[r][c],
|
||||
cx + cellSize / 2,
|
||||
cy + cellSize / 2 + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async drawTraceItSection(
|
||||
traceImgPath: string,
|
||||
upper: string,
|
||||
lower: string,
|
||||
sx: number,
|
||||
sy: number,
|
||||
) {
|
||||
const ctx = this.ctx;
|
||||
const titleX = sx + 25;
|
||||
const titleY = sy + 12;
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const traceIcon = '\u{1F58A}\u{FE0F}';
|
||||
ctx.font = TITLE_EMOJI_FONT;
|
||||
ctx.fillText(traceIcon, titleX, titleY);
|
||||
const traceIconW = ctx.measureText(traceIcon).width;
|
||||
|
||||
ctx.font = TITLE_TEXT_FONT;
|
||||
ctx.fillText(
|
||||
'Trace It !',
|
||||
titleX + traceIconW + TITLE_ICON_GAP,
|
||||
titleY,
|
||||
);
|
||||
|
||||
// 四线三格(设计稿绝对坐标)
|
||||
const lx = 316,
|
||||
lw = 236;
|
||||
const lines = [364.5, 404.5, 444.5, 484.5];
|
||||
|
||||
ctx.strokeStyle = LINE_INK_ALPHA;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, lines[0]);
|
||||
ctx.lineTo(lx + lw, lines[0]);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([4, 4]);
|
||||
for (const ly of [lines[1], lines[2]]) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, ly);
|
||||
ctx.lineTo(lx + lw, ly);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lx, lines[3]);
|
||||
ctx.lineTo(lx + lw, lines[3]);
|
||||
ctx.stroke();
|
||||
|
||||
// 笔顺图
|
||||
const imgX = 337,
|
||||
imgY = 365,
|
||||
imgW = 159,
|
||||
imgH = 80;
|
||||
try {
|
||||
const img = (await getImage(
|
||||
this.canvas,
|
||||
traceImgPath,
|
||||
)) as WechatMiniprogram.Image;
|
||||
const aspect = (img.width || 1) / (img.height || 1);
|
||||
let dw = imgW;
|
||||
let dh = dw / aspect;
|
||||
if (dh > imgH) {
|
||||
dh = imgH;
|
||||
dw = dh * aspect;
|
||||
}
|
||||
ctx.drawImage(
|
||||
img,
|
||||
imgX + (imgW - dw) / 2,
|
||||
imgY + (imgH - dh) / 2,
|
||||
dw,
|
||||
dh,
|
||||
);
|
||||
} catch {
|
||||
const fs = Math.min(imgH * 0.6, imgW * 0.18);
|
||||
ctx.font = `bold ${fs}px ${LETTER_FACE}`;
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(`${upper} ${lower}`, imgX + imgW / 2, imgY + imgH / 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../../../core/data/alphabet';
|
||||
import { ALPHABET_BY_LETTER, LETTERS_UPPER } from '../data/alphabet';
|
||||
|
||||
/** 字母描红的布局模式(与产品文档四类练习一致) */
|
||||
export type LetterTracingMode =
|
||||
@@ -43,6 +43,12 @@ export interface TracingRow {
|
||||
cells: TracingCell[];
|
||||
}
|
||||
|
||||
/** Highlight It 格子数据(3×6 网格,混入目标字母) */
|
||||
export interface HighlightGrid {
|
||||
rows: string[][];
|
||||
targetLetter: string;
|
||||
}
|
||||
|
||||
/** 看图描红教学区 */
|
||||
export interface TeachingBlock {
|
||||
upper: string;
|
||||
@@ -50,13 +56,21 @@ export interface TeachingBlock {
|
||||
word: string;
|
||||
sentence: string;
|
||||
emoji: string;
|
||||
/** 字母笔顺图路径(/englishPages/shared/assets/letter/X.png) */
|
||||
letterImgPath: string;
|
||||
/** 配图路径(/englishPages/shared/assets/letterImgs/Word.png) */
|
||||
wordImgPath: string;
|
||||
}
|
||||
|
||||
export type LetterTracingData =
|
||||
| {
|
||||
mode: 'picture-tracing';
|
||||
teaching: TeachingBlock;
|
||||
rows: TracingRow[];
|
||||
highlightGrid: HighlightGrid;
|
||||
/** Trace It 区域使用的字母笔顺图路径(同 teaching.letterImgPath) */
|
||||
traceImgPath: string;
|
||||
/** 下部 4 行四线三格(无字符填充,纯空行) */
|
||||
tracingLineCount: number;
|
||||
}
|
||||
| {
|
||||
mode: 'case-pairing';
|
||||
@@ -80,6 +94,33 @@ export function normalizeLetter(c: string): string {
|
||||
return u.length ? u[0] : 'A';
|
||||
}
|
||||
|
||||
/** 构建 Highlight It 的 3×6 随机字母网格,保证混入 2~5 个目标字母 */
|
||||
function buildHighlightGrid(
|
||||
target: string,
|
||||
gridRows: number,
|
||||
gridCols: number,
|
||||
): HighlightGrid {
|
||||
const total = gridRows * gridCols;
|
||||
const targetCount = 2 + Math.floor(Math.random() * 4); // 2~5
|
||||
const others = LETTERS_UPPER.filter((l) => l !== target);
|
||||
|
||||
const chars: string[] = [];
|
||||
for (let i = 0; i < targetCount; i++) chars.push(target);
|
||||
for (let i = targetCount; i < total; i++) {
|
||||
chars.push(others[Math.floor(Math.random() * others.length)]);
|
||||
}
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
|
||||
const rows: string[][] = [];
|
||||
for (let r = 0; r < gridRows; r++) {
|
||||
rows.push(chars.slice(r * gridCols, (r + 1) * gridCols));
|
||||
}
|
||||
return { rows, targetLetter: target };
|
||||
}
|
||||
|
||||
function buildCells(
|
||||
char: string,
|
||||
count: number,
|
||||
@@ -165,22 +206,32 @@ export function generateLetterTracing(
|
||||
const meta = ALPHABET_BY_LETTER[key] ?? ALPHABET_BY_LETTER.A;
|
||||
const upper = key;
|
||||
const lower = key.toLowerCase();
|
||||
|
||||
const hasLetterAsset = key === 'A' || key === 'B';
|
||||
const letterFile = hasLetterAsset ? key : 'A';
|
||||
const letterImgPath = `/englishPages/shared/assets/letter/${letterFile}.png`;
|
||||
|
||||
const wordImgPath = `/englishPages/shared/assets/letterImgs/Apple.png`;
|
||||
|
||||
const teaching: TeachingBlock = {
|
||||
upper,
|
||||
lower,
|
||||
word: meta.word,
|
||||
sentence: `${upper} is for ${meta.word}`,
|
||||
emoji: meta.emoji,
|
||||
letterImgPath,
|
||||
wordImgPath,
|
||||
};
|
||||
|
||||
const highlightGrid = buildHighlightGrid(upper, 3, 6);
|
||||
|
||||
return {
|
||||
mode: 'picture-tracing',
|
||||
teaching,
|
||||
highlightGrid,
|
||||
traceImgPath: letterImgPath,
|
||||
tracingLineCount: 8,
|
||||
};
|
||||
const rows: TracingRow[] = [
|
||||
rowForChar(upper, repetitions, fadePattern),
|
||||
rowForChar(upper, repetitions, fadePattern),
|
||||
rowForChar(upper, repetitions, fadePattern),
|
||||
rowForChar(lower, repetitions, fadePattern),
|
||||
rowForChar(lower, repetitions, fadePattern),
|
||||
rowForChar(lower, repetitions, fadePattern),
|
||||
];
|
||||
return { mode: 'picture-tracing', teaching, rows };
|
||||
}
|
||||
|
||||
if (mode === 'case-pairing') {
|
||||
@@ -189,12 +240,7 @@ export function generateLetterTracing(
|
||||
const pack = (letters: string[]) =>
|
||||
chunk(letters, 4).map((group) =>
|
||||
group.map((L) =>
|
||||
rowForChar(
|
||||
L,
|
||||
repetitions,
|
||||
fadePattern,
|
||||
L.toLowerCase(),
|
||||
),
|
||||
rowForChar(L, repetitions, fadePattern, L.toLowerCase()),
|
||||
),
|
||||
);
|
||||
return {
|
||||
@@ -227,9 +273,7 @@ export function generateLetterTracing(
|
||||
return {
|
||||
mode: 'overview',
|
||||
upperRows: overviewRowsFrom([...LETTERS_UPPER]),
|
||||
lowerRows: overviewRowsFrom(
|
||||
LETTERS_UPPER.map((L) => L.toLowerCase()),
|
||||
),
|
||||
lowerRows: overviewRowsFrom(LETTERS_UPPER.map((L) => L.toLowerCase())),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user