refactor: 收口 BangDream 文本画布规格
This commit is contained in:
parent
ef1140bd22
commit
3969d3e4e2
@ -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 和时间/档线规则
|
||||
|
||||
|
||||
121
src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts
Normal file
121
src/qqbot/plugins/bangDream/tsugu/canvas/text-spec.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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<Array<string | Image | Canvas>> = [[]];
|
||||
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');
|
||||
}
|
||||
|
||||
52
test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts
Normal file
52
test/qqbot/plugins/bangDream/tsugu/text-spec.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user