refactor: 收口 BangDream 文本画布规格

This commit is contained in:
sunlei 2026-06-07 00:00:53 +08:00
parent ef1140bd22
commit 3969d3e4e2
4 changed files with 245 additions and 54 deletions

View File

@ -14,7 +14,7 @@
## 当前事实 ## 当前事实
- Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu` - Tsugu 源码目录:`src/qqbot/plugins/bangDream/tsugu`
- TS 文件:初始基线 92当前 `tsugu` 源码 127 - TS 文件:初始基线 92当前 `tsugu` 源码 128
- 函数节点481其中稳定函数 410匿名/内联回调 71 - 函数节点481其中稳定函数 410匿名/内联回调 71
- 源码 JSDoc稳定函数 410/410 已覆盖 - 源码 JSDoc稳定函数 410/410 已覆盖
- 变量声明1896 - 变量声明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`,验证详情区块规格收口后查曲/查活动/查角色图片输出正常。 - 已新增 `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服务器分组、换行和绘制顺序保持不变。 - 已新增 `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`,验证列表框架规格收口后查曲/查活动/查角色图片输出正常。 - 已新增 `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 和时间/档线规则 ### Phase 6策略 policy 和时间/档线规则

View File

@ -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,
};
}

View File

@ -6,6 +6,17 @@ import {
} from 'skia-canvas'; } from 'skia-canvas';
import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest'; import { getBangDreamAssetPath } from '@/qqbot/plugins/bangDream/tsugu/runtime/asset-manifest';
import { BANGDREAM_RENDER_THEME } from '@/qqbot/plugins/bangDream/tsugu/render-blocks/theme'; 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('old', [getBangDreamAssetPath('fontOld')]);
FontLibrary.use('FangZhengHeiTi', [ FontLibrary.use('FangZhengHeiTi', [
@ -18,7 +29,7 @@ interface WrapTextOptions {
maxWidth: number; maxWidth: number;
lineHeight?: number; lineHeight?: number;
color?: string; color?: string;
font?: 'FangZhengHeiTi' | 'old' | 'default'; font?: BangDreamTextFont;
} }
//画文字,自动换行 //画文字,自动换行
@ -30,30 +41,29 @@ interface WrapTextOptions {
*/ */
export function drawText({ export function drawText({
text, text,
textSize = 40, textSize = BANGDREAM_TEXT_SPEC.font.defaultSize,
maxWidth, maxWidth,
lineHeight = (textSize * 4) / 3, lineHeight = getTextLineHeight(textSize),
color = BANGDREAM_RENDER_THEME.color.primaryText, color = BANGDREAM_RENDER_THEME.color.primaryText,
font = BANGDREAM_RENDER_THEME.font.body, font = BANGDREAM_RENDER_THEME.font.body,
}: WrapTextOptions): Canvas { }: WrapTextOptions): Canvas {
const wrappedTextData = wrapText({ text, maxWidth, lineHeight, textSize }); const wrappedTextData = wrapText({ text, maxWidth, lineHeight, textSize });
let canvas: Canvas; let singleLineWidth: number | undefined;
if (wrappedTextData.numberOfLines == 0) { if (wrappedTextData.numberOfLines == 1) {
canvas = new Canvas(1, lineHeight); const ctx = createMeasureContext();
} else if (wrappedTextData.numberOfLines == 1) {
canvas = new Canvas(1, 1);
const ctx = canvas.getContext('2d');
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
const width = (maxWidth = ctx.measureText( singleLineWidth = ctx.measureText(wrappedTextData.wrappedText[0]).width;
wrappedTextData.wrappedText[0],
).width);
canvas = new Canvas(width, lineHeight);
} else {
canvas = new Canvas(maxWidth, lineHeight * wrappedTextData.numberOfLines);
} }
const canvasSize = createTextCanvasSize({
lineHeight,
maxWidth,
numberOfLines: wrappedTextData.numberOfLines,
singleLineWidth,
});
const canvas = new Canvas(canvasSize.width, canvasSize.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let y = lineHeight / 2 + textSize / 3; let y = getTextBaselineY(lineHeight, textSize);
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline;
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
ctx.fillStyle = color; ctx.fillStyle = color;
const wrappedText = wrappedTextData.wrappedText; const wrappedText = wrappedTextData.wrappedText;
@ -71,14 +81,13 @@ export function drawText({
*/ */
export function wrapText({ export function wrapText({
text, text,
textSize, textSize = BANGDREAM_TEXT_SPEC.font.defaultSize,
maxWidth, maxWidth,
font = 'old', font = 'old',
}: WrapTextOptions) { }: WrapTextOptions) {
const canvas = new Canvas(1, 1); const ctx = createMeasureContext();
const ctx = canvas.getContext('2d');
const temp = text.split('\n'); const temp = text.split('\n');
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline;
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
for (let i = 0; i < temp.length; i++) { for (let i = 0; i < temp.length; i++) {
@ -118,7 +127,7 @@ interface TextWithImagesOptions {
content: (string | Canvas | Image)[]; content: (string | Canvas | Image)[];
spacing?: number; spacing?: number;
color?: string; color?: string;
font?: 'default' | 'old'; font?: BangDreamTextWithImageFont;
} }
// 画文字包含图片 // 画文字包含图片
@ -128,11 +137,11 @@ interface TextWithImagesOptions {
* @param options1 - options1参数 * @param options1 - options1参数
*/ */
export function drawTextWithImages({ export function drawTextWithImages({
textSize = 40, textSize = BANGDREAM_TEXT_SPEC.font.defaultSize,
maxWidth, maxWidth,
lineHeight = (textSize * 4) / 3, lineHeight = getTextLineHeight(textSize),
content, content,
spacing = textSize / 3, spacing = getTextInlineSpacing(textSize),
color = BANGDREAM_RENDER_THEME.color.primaryText, color = BANGDREAM_RENDER_THEME.color.primaryText,
font = BANGDREAM_RENDER_THEME.font.body, font = BANGDREAM_RENDER_THEME.font.body,
}: TextWithImagesOptions) { }: TextWithImagesOptions) {
@ -144,36 +153,33 @@ export function drawTextWithImages({
spacing, spacing,
}); });
const wrappedText = wrappedTextData.wrappedText; const wrappedText = wrappedTextData.wrappedText;
let canvas: Canvas; let singleLineWidth: number | undefined;
if (wrappedTextData.numberOfLines == 0) { if (wrappedTextData.numberOfLines == 1) {
canvas = new Canvas(1, lineHeight); const ctx = createMeasureContext();
}
//单行文字,宽度为第一行的宽度
else if (wrappedTextData.numberOfLines == 1) {
canvas = new Canvas(1, 1);
const ctx = canvas.getContext('2d');
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
let Width = 0; let width = 0;
for (let n = 0; n < wrappedText[0].length; n++) { for (let n = 0; n < wrappedText[0].length; n++) {
if (typeof wrappedText[0][n] === 'string') { 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 { } else {
//等比例缩放图片至高度与textSize相同 //等比例缩放图片至高度与textSize相同
const tempImage = wrappedText[0][n] as Canvas | Image; const tempImage = wrappedText[0][n] as Canvas | Image;
const tempWidth = (textSize * tempImage.width) / tempImage.height; //等比例缩放到高度与字体大小相同后,图片宽度 width += getInlineImageWidth(tempImage, textSize);
Width += tempWidth;
} }
Width += spacing; width += spacing;
} }
canvas = new Canvas(Width - spacing, lineHeight); singleLineWidth = width - spacing;
}
//多行文字
else {
canvas = new Canvas(maxWidth, lineHeight * wrappedTextData.numberOfLines);
} }
const canvasSize = createTextCanvasSize({
lineHeight,
maxWidth,
numberOfLines: wrappedTextData.numberOfLines,
singleLineWidth,
});
const canvas = new Canvas(canvasSize.width, canvasSize.height);
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let y = lineHeight / 2 + textSize / 3; let y = getTextBaselineY(lineHeight, textSize);
ctx.textBaseline = 'alphabetic'; ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline;
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
ctx.fillStyle = color; ctx.fillStyle = color;
for (let i = 0; i < wrappedText.length; i++) { for (let i = 0; i < wrappedText.length; i++) {
@ -185,11 +191,11 @@ export function drawTextWithImages({
} else { } else {
//等比例缩放图片至高度与textSize相同 //等比例缩放图片至高度与textSize相同
const tempImage = wrappedText[i][n] as Canvas | Image; const tempImage = wrappedText[i][n] as Canvas | Image;
const tempWidth = (textSize * tempImage.width) / tempImage.height; //等比例缩放到高度与字体大小相同后,图片宽度 const tempWidth = getInlineImageWidth(tempImage, textSize);
ctx.drawImage( ctx.drawImage(
tempImage, tempImage,
tempX, tempX,
y - textSize / 3 - textSize / 2, getInlineImageY(y, textSize),
tempWidth, tempWidth,
textSize, textSize,
); );
@ -211,15 +217,14 @@ export function drawTextWithImages({
* @param options1 - options1参数 * @param options1 - options1参数
*/ */
function wrapTextWithImages({ function wrapTextWithImages({
textSize = 40, textSize = BANGDREAM_TEXT_SPEC.font.defaultSize,
maxWidth, maxWidth,
content, content,
spacing = textSize / 3, spacing = getTextInlineSpacing(textSize),
font = 'old', font = 'old',
}: TextWithImagesOptions) { }: TextWithImagesOptions) {
const canvas = new Canvas(1, 1); const ctx = createMeasureContext();
const ctx = canvas.getContext('2d'); ctx.textBaseline = BANGDREAM_TEXT_SPEC.font.baseline;
ctx.textBaseline = 'alphabetic';
setFontStyle(ctx, textSize, font); setFontStyle(ctx, textSize, font);
const temp: Array<Array<string | Image | Canvas>> = [[]]; const temp: Array<Array<string | Image | Canvas>> = [[]];
let lineNumber = 0; let lineNumber = 0;
@ -274,7 +279,7 @@ function wrapTextWithImages({
} }
} else if (content[i] instanceof Canvas || content[i] instanceof Image) { } else if (content[i] instanceof Canvas || content[i] instanceof Image) {
const tempImage = content[i] as Image; const tempImage = content[i] as Image;
const tempWidth = tempImage.width * (textSize / tempImage.height); const tempWidth = getInlineImageWidth(tempImage, textSize);
if (tempX + tempWidth > maxWidth) { if (tempX + tempWidth > maxWidth) {
newLine(); newLine();
} }
@ -309,3 +314,14 @@ export const setFontStyle = function (
//设置字体大小 //设置字体大小
ctx.font = `${textSize}px ${font},${BANGDREAM_RENDER_THEME.font.fallback}`; 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');
}

View File

@ -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 });
});
});