From 3969d3e4e2b1d919278efca6f3caae6d11010413 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sun, 7 Jun 2026 00:00:53 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=20BangDream=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E7=94=BB=E5=B8=83=E8=A7=84=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ot-bangdream-tsugu-global-refactor-plan.md | 4 +- .../bangDream/tsugu/canvas/text-spec.ts | 121 +++++++++++++++++ .../plugins/bangDream/tsugu/canvas/text.ts | 122 ++++++++++-------- .../plugins/bangDream/tsugu/text-spec.spec.ts | 52 ++++++++ 4 files changed, 245 insertions(+), 54 deletions(-) create mode 100644 src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts create mode 100644 test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts diff --git a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md index 758610d..cea8ccf 100644 --- a/docs/qqbot-bangdream-tsugu-global-refactor-plan.md +++ b/docs/qqbot-bangdream-tsugu-global-refactor-plan.md @@ -14,7 +14,7 @@ ## 当前事实 - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` -- TS 文件:初始基线 92;当前 `tsugu` 源码 127 +- TS 文件:初始基线 92;当前 `tsugu` 源码 128 - 函数节点:481,其中稳定函数 410,匿名/内联回调 71 - 源码 JSDoc:稳定函数 410/410 已覆盖 - 变量声明:1896 @@ -358,6 +358,8 @@ export interface TsuguHook { - 已新增 `detail-block-spec.spec.ts`,覆盖歌曲详情尺寸、角色/玩家详情尺寸和 meta 相对百分比舍入;本地生成 `detail-spec-song-136.jpg`、`detail-spec-event-50.jpg`、`detail-spec-character-1.jpg`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。 - 已新增 `render-blocks/list-frame-spec.ts`,收口通用列表行、tips、横向合并列、居中图片列表和左侧竖线的字号、间距、兜底尺寸与布局计算;`list-frame.ts` 改为消费 spec,服务器分组、换行和绘制顺序保持不变。 - 已新增 `list-frame-spec.spec.ts`,覆盖列表正文宽度、标签/tips 偏移、合并列宽、居中图片换行和左侧竖线尺寸;本地生成 `list-frame-spec-song-136.jpg`、`list-frame-spec-event-50.jpg`、`list-frame-spec-character-1.jpg`,验证列表框架规格收口后查曲/查活动/查角色图片输出正常。 +- 已新增 `canvas/text-spec.ts`,收口文本默认字号、行高比例、baseline、空画布尺寸、混排间距和内联图片缩放计算;`canvas/text.ts` 改为消费 spec,原有文本换行和混排拆分逻辑保持不变。 +- 已新增 `text-spec.spec.ts`,覆盖行高/间距比例、baseline、内联图片缩放和空/单行/多行画布尺寸;本地生成 `text-spec-song-136.jpg`、`text-spec-event-50.jpg`、`text-spec-character-1.jpg`,验证文本规格收口后查曲/查活动/查角色图片输出正常。 ### Phase 6:策略 policy 和时间/档线规则 diff --git a/src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts b/src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts new file mode 100644 index 0000000..c62196d --- /dev/null +++ b/src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts @@ -0,0 +1,121 @@ +interface ImageLike { + height: number; + width: number; +} + +interface TextCanvasSizeOptions { + lineHeight: number; + maxWidth: number; + numberOfLines: number; + singleLineWidth?: number; +} + +export const BANGDREAM_TEXT_SPEC = { + canvas: { + emptyWidth: 1, + measureHeight: 1, + measureWidth: 1, + }, + font: { + baseline: 'alphabetic', + defaultSize: 40, + }, + inlineImage: { + baselineTextOffsetRatio: 1 / 3, + baselineVerticalCenterRatio: 1 / 2, + }, + line: { + baselineLineRatio: 1 / 2, + baselineTextRatio: 1 / 3, + heightRatio: 4 / 3, + spacingRatio: 1 / 3, + }, +} as const; + +export type BangDreamTextFont = 'FangZhengHeiTi' | 'old' | 'default'; + +export type BangDreamTextWithImageFont = 'default' | 'old'; + +/** + * 计算默认文本行高。 + * + * @param textSize - 文本字号。 + */ +export function getTextLineHeight(textSize: number) { + return textSize * BANGDREAM_TEXT_SPEC.line.heightRatio; +} + +/** + * 计算文本和图片混排默认间距。 + * + * @param textSize - 文本字号。 + */ +export function getTextInlineSpacing(textSize: number) { + return textSize * BANGDREAM_TEXT_SPEC.line.spacingRatio; +} + +/** + * 计算文本绘制 baseline。 + * + * @param lineHeight - 行高。 + * @param textSize - 文本字号。 + */ +export function getTextBaselineY(lineHeight: number, textSize: number) { + return ( + lineHeight * BANGDREAM_TEXT_SPEC.line.baselineLineRatio + + textSize * BANGDREAM_TEXT_SPEC.line.baselineTextRatio + ); +} + +/** + * 计算内联图片按文本字号缩放后的宽度。 + * + * @param image - 图片尺寸。 + * @param textSize - 文本字号。 + */ +export function getInlineImageWidth(image: ImageLike, textSize: number) { + return (textSize * image.width) / image.height; +} + +/** + * 计算内联图片绘制 Y 坐标。 + * + * @param baselineY - 当前文本 baseline。 + * @param textSize - 文本字号。 + */ +export function getInlineImageY(baselineY: number, textSize: number) { + return ( + baselineY - + textSize * BANGDREAM_TEXT_SPEC.inlineImage.baselineTextOffsetRatio - + textSize * BANGDREAM_TEXT_SPEC.inlineImage.baselineVerticalCenterRatio + ); +} + +/** + * 计算文本画布尺寸。 + * + * @param options - 行数、行高、最大宽度和单行宽度。 + */ +export function createTextCanvasSize({ + lineHeight, + maxWidth, + numberOfLines, + singleLineWidth = BANGDREAM_TEXT_SPEC.canvas.emptyWidth, +}: TextCanvasSizeOptions) { + if (numberOfLines === 0) { + return { + height: lineHeight, + width: BANGDREAM_TEXT_SPEC.canvas.emptyWidth, + }; + } + if (numberOfLines === 1) { + return { + height: lineHeight, + width: singleLineWidth, + }; + } + return { + height: lineHeight * numberOfLines, + width: maxWidth, + }; +} diff --git a/src/qqbot/plugins/bangDream/tsugu/canvas/text.ts b/src/qqbot/plugins/bangDream/tsugu/canvas/text.ts index 971b494..9aac4e7 100644 --- a/src/qqbot/plugins/bangDream/tsugu/canvas/text.ts +++ b/src/qqbot/plugins/bangDream/tsugu/canvas/text.ts @@ -6,6 +6,17 @@ import { } from 'skia-canvas'; import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest'; import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; +import { + BANGDREAM_TEXT_SPEC, + BangDreamTextFont, + BangDreamTextWithImageFont, + createTextCanvasSize, + getInlineImageWidth, + getInlineImageY, + getTextBaselineY, + getTextInlineSpacing, + getTextLineHeight, +} from '@/qqbot/plugins/bangDream/tsugu/canvas/text-spec'; FontLibrary.use('old', [getBangDreamAssetPath('fontOld')]); FontLibrary.use('FangZhengHeiTi', [ @@ -18,7 +29,7 @@ interface WrapTextOptions { maxWidth: number; lineHeight?: number; color?: string; - font?: 'FangZhengHeiTi' | 'old' | 'default'; + font?: BangDreamTextFont; } //画文字,自动换行 @@ -30,30 +41,29 @@ interface WrapTextOptions { */ export function drawText({ text, - textSize = 40, + textSize = BANGDREAM_TEXT_SPEC.font.defaultSize, maxWidth, - lineHeight = (textSize * 4) / 3, + lineHeight = getTextLineHeight(textSize), color = BANGDREAM_RENDER_THEME.color.primaryText, font = BANGDREAM_RENDER_THEME.font.body, }: WrapTextOptions): Canvas { const wrappedTextData = wrapText({ text, maxWidth, lineHeight, textSize }); - let canvas: Canvas; - if (wrappedTextData.numberOfLines == 0) { - canvas = new Canvas(1, lineHeight); - } else if (wrappedTextData.numberOfLines == 1) { - canvas = new Canvas(1, 1); - const ctx = canvas.getContext('2d'); + let singleLineWidth: number | undefined; + if (wrappedTextData.numberOfLines == 1) { + const ctx = createMeasureContext(); setFontStyle(ctx, textSize, font); - const width = (maxWidth = ctx.measureText( - wrappedTextData.wrappedText[0], - ).width); - canvas = new Canvas(width, lineHeight); - } else { - canvas = new Canvas(maxWidth, lineHeight * wrappedTextData.numberOfLines); + singleLineWidth = ctx.measureText(wrappedTextData.wrappedText[0]).width; } + const canvasSize = createTextCanvasSize({ + lineHeight, + maxWidth, + numberOfLines: wrappedTextData.numberOfLines, + singleLineWidth, + }); + const canvas = new Canvas(canvasSize.width, canvasSize.height); const ctx = canvas.getContext('2d'); - let y = lineHeight / 2 + textSize / 3; - ctx.textBaseline = 'alphabetic'; + let y = getTextBaselineY(lineHeight, textSize); + ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline; setFontStyle(ctx, textSize, font); ctx.fillStyle = color; const wrappedText = wrappedTextData.wrappedText; @@ -71,14 +81,13 @@ export function drawText({ */ export function wrapText({ text, - textSize, + textSize = BANGDREAM_TEXT_SPEC.font.defaultSize, maxWidth, font = 'old', }: WrapTextOptions) { - const canvas = new Canvas(1, 1); - const ctx = canvas.getContext('2d'); + const ctx = createMeasureContext(); const temp = text.split('\n'); - ctx.textBaseline = 'alphabetic'; + ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline; setFontStyle(ctx, textSize, font); for (let i = 0; i < temp.length; i++) { @@ -118,7 +127,7 @@ interface TextWithImagesOptions { content: (string | Canvas | Image)[]; spacing?: number; color?: string; - font?: 'default' | 'old'; + font?: BangDreamTextWithImageFont; } // 画文字包含图片 @@ -128,11 +137,11 @@ interface TextWithImagesOptions { * @param options1 - options1参数。 */ export function drawTextWithImages({ - textSize = 40, + textSize = BANGDREAM_TEXT_SPEC.font.defaultSize, maxWidth, - lineHeight = (textSize * 4) / 3, + lineHeight = getTextLineHeight(textSize), content, - spacing = textSize / 3, + spacing = getTextInlineSpacing(textSize), color = BANGDREAM_RENDER_THEME.color.primaryText, font = BANGDREAM_RENDER_THEME.font.body, }: TextWithImagesOptions) { @@ -144,36 +153,33 @@ export function drawTextWithImages({ spacing, }); const wrappedText = wrappedTextData.wrappedText; - let canvas: Canvas; - if (wrappedTextData.numberOfLines == 0) { - canvas = new Canvas(1, lineHeight); - } - //单行文字,宽度为第一行的宽度 - else if (wrappedTextData.numberOfLines == 1) { - canvas = new Canvas(1, 1); - const ctx = canvas.getContext('2d'); + let singleLineWidth: number | undefined; + if (wrappedTextData.numberOfLines == 1) { + const ctx = createMeasureContext(); setFontStyle(ctx, textSize, font); - let Width = 0; + let width = 0; for (let n = 0; n < wrappedText[0].length; n++) { if (typeof wrappedText[0][n] === 'string') { - Width += ctx.measureText(wrappedText[0][n] as string).width; + width += ctx.measureText(wrappedText[0][n] as string).width; } else { //等比例缩放图片,至高度与textSize相同 const tempImage = wrappedText[0][n] as Canvas | Image; - const tempWidth = (textSize * tempImage.width) / tempImage.height; //等比例缩放到高度与字体大小相同后,图片宽度 - Width += tempWidth; + width += getInlineImageWidth(tempImage, textSize); } - Width += spacing; + width += spacing; } - canvas = new Canvas(Width - spacing, lineHeight); - } - //多行文字 - else { - canvas = new Canvas(maxWidth, lineHeight * wrappedTextData.numberOfLines); + singleLineWidth = width - spacing; } + const canvasSize = createTextCanvasSize({ + lineHeight, + maxWidth, + numberOfLines: wrappedTextData.numberOfLines, + singleLineWidth, + }); + const canvas = new Canvas(canvasSize.width, canvasSize.height); const ctx = canvas.getContext('2d'); - let y = lineHeight / 2 + textSize / 3; - ctx.textBaseline = 'alphabetic'; + let y = getTextBaselineY(lineHeight, textSize); + ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline; setFontStyle(ctx, textSize, font); ctx.fillStyle = color; for (let i = 0; i < wrappedText.length; i++) { @@ -185,11 +191,11 @@ export function drawTextWithImages({ } else { //等比例缩放图片,至高度与textSize相同 const tempImage = wrappedText[i][n] as Canvas | Image; - const tempWidth = (textSize * tempImage.width) / tempImage.height; //等比例缩放到高度与字体大小相同后,图片宽度 + const tempWidth = getInlineImageWidth(tempImage, textSize); ctx.drawImage( tempImage, tempX, - y - textSize / 3 - textSize / 2, + getInlineImageY(y, textSize), tempWidth, textSize, ); @@ -211,15 +217,14 @@ export function drawTextWithImages({ * @param options1 - options1参数。 */ function wrapTextWithImages({ - textSize = 40, + textSize = BANGDREAM_TEXT_SPEC.font.defaultSize, maxWidth, content, - spacing = textSize / 3, + spacing = getTextInlineSpacing(textSize), font = 'old', }: TextWithImagesOptions) { - const canvas = new Canvas(1, 1); - const ctx = canvas.getContext('2d'); - ctx.textBaseline = 'alphabetic'; + const ctx = createMeasureContext(); + ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline; setFontStyle(ctx, textSize, font); const temp: Array> = [[]]; let lineNumber = 0; @@ -274,7 +279,7 @@ function wrapTextWithImages({ } } else if (content[i] instanceof Canvas || content[i] instanceof Image) { const tempImage = content[i] as Image; - const tempWidth = tempImage.width * (textSize / tempImage.height); + const tempWidth = getInlineImageWidth(tempImage, textSize); if (tempX + tempWidth > maxWidth) { newLine(); } @@ -309,3 +314,14 @@ export const setFontStyle = function ( //设置字体大小 ctx.font = `${textSize}px ${font},${BANGDREAM_RENDER_THEME.font.fallback}`; }; + +/** + * 创建文本测量上下文。 + */ +function createMeasureContext() { + const canvas = new Canvas( + BANGDREAM_TEXT_SPEC.canvas.measureWidth, + BANGDREAM_TEXT_SPEC.canvas.measureHeight, + ); + return canvas.getContext('2d'); +} diff --git a/test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts b/test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts new file mode 100644 index 0000000..2e3a33c --- /dev/null +++ b/test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts @@ -0,0 +1,52 @@ +import { + BANGDREAM_TEXT_SPEC, + createTextCanvasSize, + getInlineImageWidth, + getInlineImageY, + getTextBaselineY, + getTextInlineSpacing, + getTextLineHeight, +} from '@/qqbot/plugins/bangDream/tsugu/canvas/text-spec'; + +describe('BangDream text spec', () => { + it('keeps text line and spacing ratios stable', () => { + expect(BANGDREAM_TEXT_SPEC.font.defaultSize).toBe(40); + expect(getTextLineHeight(30)).toBe(40); + expect(getTextInlineSpacing(30)).toBe(10); + expect(getTextBaselineY(60, 30)).toBe(40); + }); + + it('keeps inline image scaling stable', () => { + const image = { height: 20, width: 40 }; + + expect(getInlineImageWidth(image, 30)).toBe(60); + expect(getInlineImageY(40, 30)).toBe(15); + }); + + it('creates canvas sizes with historical empty, single and multiline rules', () => { + expect( + createTextCanvasSize({ + lineHeight: 50, + maxWidth: 200, + numberOfLines: 0, + }), + ).toEqual({ height: 50, width: 1 }); + + expect( + createTextCanvasSize({ + lineHeight: 50, + maxWidth: 200, + numberOfLines: 1, + singleLineWidth: 123, + }), + ).toEqual({ height: 50, width: 123 }); + + expect( + createTextCanvasSize({ + lineHeight: 50, + maxWidth: 200, + numberOfLines: 3, + }), + ).toEqual({ height: 150, width: 200 }); + }); +});