Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/components/modals/CopyLyricsDialog.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useSettingsStore } from "@/stores/settings";
import { useMediaStore } from "@/stores/media";
import { useThemeStore } from "@/stores/theme";
import { useCopyText } from "@/composables/useCopyText";
Expand All @@ -12,6 +13,7 @@ const emit = defineEmits<{ "update:open": [value: boolean] }>();
const { t } = useI18n();
const media = useMediaStore();
const theme = useThemeStore();
const settings = useSettingsStore();
const { copy } = useCopyText();

/** 复制内容过滤项 */
Expand All @@ -29,6 +31,7 @@ const displayLyrics = computed(() =>
.filter((line) => !line.isBG)
.map((line, index) => ({
index,
language: line.language,
text: line.words.map((word) => word.word).join(""),
translation: line.translatedLyric || "",
romaji: line.romanLyric || line.words.map((word) => word.romanWord ?? "").join(""),
Expand Down Expand Up @@ -130,11 +133,20 @@ const handleExport = async (): Promise<void> => {
.filter((line) => pickedSet.value.has(line.index))
.map((line) => ({
text: line.text,
language: line.language,
translation: showTranslation.value && line.translation ? line.translation : undefined,
romaji: showRomaji.value && line.romaji ? line.romaji : undefined,
duet: line.duet,
})),
fallbackColor: theme.coverColor,
fonts: {
global: settings.appearance.fontFamily,
lyric: settings.lyric.fontFamily,
chinese: settings.lyric.fontFamilyChinese,
japanese: settings.lyric.fontFamilyJapanese,
korean: settings.lyric.fontFamilyKorean,
latin: settings.lyric.fontFamilyLatin,
},
});
const artist = track.artists.map((item) => item.name).join(", ");
const suffixName = t("player.copyLyric.fileSuffix");
Expand Down
104 changes: 104 additions & 0 deletions src/utils/lyric/poster.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Track } from "@shared/types/player";
import { createLyricPoster } from "./poster";

vi.mock("@/utils/color", () => ({ hexToRgb: () => "20, 20, 28" }));

/**
* 捕获文字测量与绘制时的字体,模拟无封面的图片导出
* @returns 字体加载桩、测量记录和绘制记录
*/
const setupCanvas = () => {
const measured: Array<{ text: string; font: string }> = [];
const drawn: Array<{ text: string; font: string }> = [];
const load = vi.fn().mockResolvedValue([]);
Object.defineProperty(document, "fonts", { configurable: true, value: { load } });
const context = {
font: "",
scale: vi.fn(),
fillRect: vi.fn(),
measureText: (text: string) => {
measured.push({ text, font: context.font });
return { width: text.length * (context.font.includes('"Wide"') ? 100 : 20) };
},
fillText: (text: string) => drawn.push({ text, font: context.font }),
};
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
context as unknown as CanvasRenderingContext2D,
);
vi.spyOn(HTMLCanvasElement.prototype, "toBlob").mockImplementation((callback) => {
callback(new Blob(["png"], { type: "image/png" }));
});
return { load, measured, drawn };
};

const track = { title: "歌名", artists: [{ name: "歌手" }], source: "netease" } as Track;

afterEach(() => {
vi.restoreAllMocks();
Reflect.deleteProperty(document, "fonts");
});

describe("歌词海报字体", () => {
it("沿用分语言、歌词和全局字体,并在测量与绘制时保持一致", async () => {
const { measured, drawn } = setupCanvas();
await createLyricPoster({
track,
fonts: {
global: '"Global"',
lyric: '"Lyrics"',
japanese: '"Japanese"',
chinese: '"Chinese"',
latin: '"Latin"',
},
lines: [{ text: "君の歌", language: "ja", translation: "你的歌", romaji: "kimi no uta" }],
});
for (const [text, family] of [
["君の歌", "Japanese"],
["你的歌", "Chinese"],
["kimi no uta", "Latin"],
]) {
const rendered = drawn.find((entry) => entry.text === text)!;
expect(rendered.font).toContain(`"${family}", "Lyrics", "Global"`);
expect(measured.some((entry) => entry.text === text && entry.font === rendered.font)).toBe(
true,
);
}
expect(drawn.find((entry) => entry.text === "歌名")?.font).toContain('"Global"');
});

it("未设置歌词字体时使用全局字体", async () => {
const { drawn } = setupCanvas();
await createLyricPoster({
track,
fonts: { global: '"WenKai"' },
lines: [{ text: "中文歌词" }],
});
expect(drawn.find((entry) => entry.text === "中文歌词")?.font).toMatch(/^bold 34px "WenKai",/);
});

it("修改歌词字体后重新计算长句换行,不沿用上次字体", async () => {
const { drawn } = setupCanvas();
const text = "这是用于验证不同字体换行的长句";
await createLyricPoster({ track, fonts: { lyric: '"Wide"' }, lines: [{ text }] });
const wideLines = drawn.filter((entry) => entry.font.startsWith('bold 34px "Wide"'));
expect(wideLines.length).toBeGreaterThan(1);
expect(wideLines.map((entry) => entry.text).join("")).toBe(text);
drawn.length = 0;
await createLyricPoster({ track, lines: [{ text }] });
expect(drawn.find((entry) => entry.text === text)?.font).not.toContain('"Wide"');
});

it("字体加载完成前不测量或绘制文字", async () => {
const { load, measured, drawn } = setupCanvas();
const pending = Promise.withResolvers<FontFace[]>();
load.mockReturnValue(pending.promise);
const result = createLyricPoster({ track, lines: [{ text: "等待字体" }] });
await vi.waitFor(() => expect(load).toHaveBeenCalled());
expect(measured).toEqual([]);
expect(drawn).toEqual([]);
pending.resolve([]);
expect((await result).type).toBe("image/png");
expect(drawn.some((entry) => entry.text === "等待字体")).toBe(true);
});
});
97 changes: 79 additions & 18 deletions src/utils/lyric/poster.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { applyLyricLanguages, type LyricLanguage, type LyricLine } from "lyric-kit";
import type { Track } from "@shared/types/player";
import { hexToRgb } from "@/utils/color";

/** 海报中的一行歌词 */
export interface LyricPosterLine {
text: string;
/** 原文语言,沿用播放器的识别结果 */
language?: LyricLanguage;
/** 翻译 */
translation?: string;
/** 音译 */
Expand All @@ -17,6 +20,15 @@ export interface LyricPosterOptions {
lines: LyricPosterLine[];
/** 无封面时的背景色 HEX,可空 */
fallbackColor?: string | null;
/** 沿用现有设置的 CSS 字体族列表 */
fonts?: {
global?: string;
lyric?: string;
chinese?: string;
japanese?: string;
korean?: string;
latin?: string;
};
}

const SCALE = 3;
Expand All @@ -27,12 +39,6 @@ const PAD_TOP = 64;
const PAD_BOTTOM = 30;

const FONT_STACK = '-apple-system, "Segoe UI", "Microsoft YaHei", system-ui, sans-serif';
const LYRIC_FONT = `bold 34px ${FONT_STACK}`;
const TRANS_FONT = `24px ${FONT_STACK}`;
const ROMAJI_FONT = `italic 22px ${FONT_STACK}`;
const TITLE_FONT = `bold 28px ${FONT_STACK}`;
const ARTIST_FONT = `22px ${FONT_STACK}`;
const MARK_FONT = `600 15px ${FONT_STACK}`;

const LYRIC_LH = 48;
const TRANS_LH = 34;
Expand Down Expand Up @@ -142,11 +148,47 @@ const drawCovered = (

/**
* 把选中歌词绘制成一张海报 PNG(自动解析当前封面)
* @param options - 曲目、歌词行、无封面时的背景色
* @param options - 曲目、歌词行、背景色和导出字体配置
* @returns PNG Blob
*/
export const createLyricPoster = async (options: LyricPosterOptions): Promise<Blob> => {
const { track, lines, fallbackColor } = options;
const { track, lines, fallbackColor, fonts = {} } = options;
const globalFont = `${fonts.global ? `${fonts.global}, ` : ""}${FONT_STACK}`;
const lyricFont = `${fonts.lyric ? `${fonts.lyric}, ` : ""}${globalFont}`;

/**
* 按文本语言解析字体,保证测量和绘制使用相同字体
* @param text - 原文、翻译或音译
* @param style - Canvas 字号与字形
* @param language - 播放器已识别的原文语言
* @returns 可直接赋给 Canvas 的字体描述
*/
const fontForText = (text: string, style: string, language?: LyricLanguage): string => {
if (!language) {
const detected: LyricLine[] = [
{
words: [{ word: text, startTime: 0, endTime: 0 }],
translatedLyric: "",
romanLyric: "",
startTime: 0,
endTime: 0,
isBG: false,
isDuet: false,
},
];
applyLyricLanguages(detected);
language = detected[0].language;
}
const family = language
? {
"zh-CN": fonts.chinese,
ja: fonts.japanese,
ko: fonts.korean,
"und-Latn": fonts.latin,
}[language]
: undefined;
return `${style} ${family ? `${family}, ` : ""}${lyricFont}`;
};
const title = track.title;
const artist = track.artists.map((item) => item.name).join(" / ");
const coverDataUrl = await resolveCoverDataUrl(track);
Expand All @@ -156,15 +198,34 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise<Bl
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("无法创建画布上下文");

// 字体就绪后再测量,避免首次导出使用回退字体的宽度
await Promise.all([
document.fonts.load(`bold 28px ${globalFont}`, title),
document.fonts.load(`22px ${globalFont}`, artist),
document.fonts.load(`600 15px ${globalFont}`, "Made by SPlayer Next"),
...lines.flatMap((line) => [
document.fonts.load(fontForText(line.text, "bold 34px", line.language), line.text),
...(line.translation
? [document.fonts.load(fontForText(line.translation, "24px"), line.translation)]
: []),
...(line.romaji
? [document.fonts.load(fontForText(line.romaji, "italic 22px"), line.romaji)]
: []),
]),
]);

// 预折行并量算高度
const layout = lines.map((line) => {
ctx.font = LYRIC_FONT;
const mainFont = fontForText(line.text, "bold 34px", line.language);
const translationFont = fontForText(line.translation ?? "", "24px");
const romajiFont = fontForText(line.romaji ?? "", "italic 22px");
ctx.font = mainFont;
const main = wrapText(ctx, line.text || " ", CONTENT_WIDTH);
ctx.font = TRANS_FONT;
ctx.font = translationFont;
const translation = line.translation ? wrapText(ctx, line.translation, CONTENT_WIDTH) : [];
ctx.font = ROMAJI_FONT;
ctx.font = romajiFont;
const romaji = line.romaji ? wrapText(ctx, line.romaji, CONTENT_WIDTH) : [];
return { main, translation, romaji, duet: !!line.duet };
return { main, translation, romaji, mainFont, translationFont, romajiFont, duet: !!line.duet };
});

let lyricsHeight = 0;
Expand Down Expand Up @@ -212,10 +273,10 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise<Bl
titleX = PAD_X + THUMB + THUMB_TEXT_GAP;
}
ctx.fillStyle = "#ffffff";
ctx.font = TITLE_FONT;
ctx.font = `bold 28px ${globalFont}`;
ctx.fillText(title, titleX, PAD_TOP + 10);
ctx.fillStyle = "rgba(255, 255, 255, 0.7)";
ctx.font = ARTIST_FONT;
ctx.font = `22px ${globalFont}`;
ctx.fillText(artist, titleX, PAD_TOP + 44);

// 歌词
Expand All @@ -226,15 +287,15 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise<Bl
const textX = block.duet ? WIDTH - PAD_X : PAD_X;
ctx.textAlign = block.duet ? "right" : "left";
ctx.fillStyle = "#ffffff";
ctx.font = LYRIC_FONT;
ctx.font = block.mainFont;
for (const text of block.main) {
ctx.fillText(text, textX, y);
y += LYRIC_LH;
}
if (block.translation.length) {
y += SUBLINE_GAP;
ctx.fillStyle = "rgba(255, 255, 255, 0.6)";
ctx.font = TRANS_FONT;
ctx.font = block.translationFont;
for (const text of block.translation) {
ctx.fillText(text, textX, y);
y += TRANS_LH;
Expand All @@ -243,7 +304,7 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise<Bl
if (block.romaji.length) {
y += SUBLINE_GAP;
ctx.fillStyle = "rgba(255, 255, 255, 0.45)";
ctx.font = ROMAJI_FONT;
ctx.font = block.romajiFont;
for (const text of block.romaji) {
ctx.fillText(text, textX, y);
y += ROMAJI_LH;
Expand All @@ -253,7 +314,7 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise<Bl

// 底部水印(居中)
ctx.fillStyle = "rgba(255, 255, 255, 0.4)";
ctx.font = MARK_FONT;
ctx.font = `600 15px ${globalFont}`;
ctx.textAlign = "center";
ctx.fillText("Made by SPlayer Next", WIDTH / 2, totalHeight - PAD_BOTTOM - WATERMARK_H);

Expand Down
Loading