From f71a2a5e82d7b407571628c742b4cc634c32956a Mon Sep 17 00:00:00 2001 From: MoYingJi Date: Fri, 30 Oct 2026 04:38:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20poster=20=E6=94=AF=E6=8C=81=E8=83=8C?= =?UTF-8?q?=E6=99=AF=E8=A1=8C=EF=BC=8C=E5=A4=8D=E5=88=B6=E6=AD=8C=E8=AF=8D?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E6=A1=86=E6=94=AF=E6=8C=81=E8=83=8C=E6=99=AF?= =?UTF-8?q?=E8=A1=8C=E5=92=8C=E5=AF=B9=E5=94=B1=E8=A1=8C=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/modals/CopyLyricsDialog.vue | 273 +++++++++++++++++---- src/components/ui/SCheckbox.vue | 2 +- src/i18n/locales/en-US.json | 1 + src/i18n/locales/zh-CN.json | 1 + src/utils/lyric/poster.spec.ts | 53 +++- src/utils/lyric/poster.ts | 239 +++++++++++++----- 6 files changed, 449 insertions(+), 120 deletions(-) diff --git a/src/components/modals/CopyLyricsDialog.vue b/src/components/modals/CopyLyricsDialog.vue index a80a78468..11ebd1b6d 100644 --- a/src/components/modals/CopyLyricsDialog.vue +++ b/src/components/modals/CopyLyricsDialog.vue @@ -5,6 +5,7 @@ import { useThemeStore } from "@/stores/theme"; import { useCopyText } from "@/composables/useCopyText"; import { toast } from "@/composables/useToast"; import { createLyricPoster } from "@/utils/lyric/poster"; +import type { LyricLanguage, LyricLine } from "lyric-kit"; const props = defineProps<{ open: boolean }>(); @@ -17,30 +18,85 @@ const settings = useSettingsStore(); const { copy } = useCopyText(); /** 复制内容过滤项 */ -type CopyFilter = "translation" | "romaji" | "emptyLine" | "songName" | "artist"; +type CopyFilter = "translation" | "romaji" | "background" | "emptyLine" | "songName" | "artist"; -/** 选中的过滤项(默认开启翻译/音译/歌名/歌手,空行默认关闭) */ -const selectedFilters = ref(["translation", "romaji", "songName", "artist"]); +/** 选中的过滤项(默认开启翻译/音译/背景行/歌名/歌手,空行默认关闭) */ +const selectedFilters = ref([ + "translation", + "romaji", + "background", + "songName", + "artist", +]); /** 选中的歌词行索引 */ const selectedLines = ref([]); -/** 展示用歌词行 */ -const displayLyrics = computed(() => - media.parsedLyric - .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(""), - duet: line.isDuet, - })), +/** 选中背景行的所属主行索引,背景行仅随主行一起输出 */ +const selectedBackgrounds = ref([]); + +/** 单段文本的展示内容 */ +interface DisplayText { + language?: LyricLanguage; + text: string; + translation: string; + romaji: string; +} + +/** 展示用歌词行,背景行附着在所属主行下 */ +interface DisplayLyric extends DisplayText { + index: number; + duet: boolean; + background?: DisplayText; + /** 背景行起始早于主行(前置背景行),输出与展示时置于主行之前 */ + backgroundFirst?: boolean; +} + +/** + * 将歌词行转换为展示文本 + * @param line - 解析后的歌词行 + * @returns 展示文本 + */ +const toDisplayText = (line: LyricLine): DisplayText => ({ + language: line.language, + text: line.words.map((word) => word.word).join(""), + translation: line.translatedLyric || "", + romaji: line.romanLyric || line.words.map((word) => word.romanWord ?? "").join(""), +}); + +/** + * 获取歌词行的起始时间,取首个逐字时间以保留主行与背景行的原始先后 + * @param line - 歌词行 + * @returns 起始时间(毫秒) + */ +const lineStart = (line: LyricLine): number => line.words[0]?.startTime ?? line.startTime; + +/** 展示用歌词行,紧随主行的背景行附着到该主行 */ +const displayLyrics = computed(() => { + const result: DisplayLyric[] = []; + const lines = media.parsedLyric; + for (let idx = 0; idx < lines.length; idx++) { + const line = lines[idx]; + const previous = result[result.length - 1]; + if (line.isBG && previous && !previous.background) { + previous.background = toDisplayText(line); + previous.backgroundFirst = + !settings.lyric.bgAlwaysBelow && lineStart(line) < lineStart(lines[idx - 1]); + continue; + } + result.push({ ...toDisplayText(line), index: result.length, duet: line.isDuet }); + } + return result; +}); + +/** 带背景行的主行索引 */ +const backgroundIndexes = computed(() => + displayLyrics.value.filter((line) => line.background).map((line) => line.index), ); const showTranslation = computed(() => selectedFilters.value.includes("translation")); const showRomaji = computed(() => selectedFilters.value.includes("romaji")); +const showBackground = computed(() => selectedFilters.value.includes("background")); /** 歌名 / 歌手后缀 */ const suffix = computed(() => { @@ -58,48 +114,115 @@ const suffix = computed(() => { /** 已选行集合,加速勾选判断 */ const pickedSet = computed(() => new Set(selectedLines.value)); -const isAllSelected = computed( - () => displayLyrics.value.length > 0 && selectedLines.value.length === displayLyrics.value.length, -); +const backgroundPickedSet = computed(() => new Set(selectedBackgrounds.value)); + +/** 主行与已展示的背景行全部勾选时视为全选 */ +const isAllSelected = computed(() => { + if (displayLyrics.value.length === 0) return false; + if (selectedLines.value.length !== displayLyrics.value.length) return false; + return ( + !showBackground.value || + backgroundIndexes.value.every((index) => backgroundPickedSet.value.has(index)) + ); +}); /** 打开时默认全选 */ watch( () => props.open, (open) => { - if (open) selectedLines.value = displayLyrics.value.map((line) => line.index); + if (!open) return; + selectedLines.value = displayLyrics.value.map((line) => line.index); + selectedBackgrounds.value = backgroundIndexes.value; }, ); -/** 勾选 / 取消某一行 */ +/** + * 勾选 / 取消某一行,取消主行时一并取消其背景行,重新勾选时恢复背景行的默认选中 + * @param index - 行索引 + * @param checked - 是否勾选 + */ const toggleLine = (index: number, checked: boolean): void => { if (!checked) { selectedLines.value = selectedLines.value.filter((item) => item !== index); + selectedBackgrounds.value = selectedBackgrounds.value.filter((item) => item !== index); } else if (!selectedLines.value.includes(index)) { selectedLines.value = [...selectedLines.value, index]; + const hasBackground = displayLyrics.value.some( + (line) => line.index === index && line.background, + ); + if (hasBackground) toggleBackground(index, true); + } +}; + +/** + * 勾选 / 取消某一行的背景行 + * @param index - 所属主行索引 + * @param checked - 是否勾选 + */ +const toggleBackground = (index: number, checked: boolean): void => { + if (!checked) { + selectedBackgrounds.value = selectedBackgrounds.value.filter((item) => item !== index); + } else if (!selectedBackgrounds.value.includes(index)) { + selectedBackgrounds.value = [...selectedBackgrounds.value, index]; } }; const toggleSelectAll = (): void => { - selectedLines.value = isAllSelected.value ? [] : displayLyrics.value.map((line) => line.index); + const selectAll = !isAllSelected.value; + selectedLines.value = selectAll ? displayLyrics.value.map((line) => line.index) : []; + selectedBackgrounds.value = selectAll ? backgroundIndexes.value : []; }; -/** 反选 */ +/** 反选,背景行仅在所属主行被选中时参与反选 */ const invertSelection = (): void => { - const picked = pickedSet.value; - selectedLines.value = displayLyrics.value + const nextLines = displayLyrics.value .map((line) => line.index) - .filter((index) => !picked.has(index)); + .filter((index) => !pickedSet.value.has(index)); + selectedLines.value = nextLines; + const nextLineSet = new Set(nextLines); + selectedBackgrounds.value = backgroundIndexes.value.filter( + (index) => nextLineSet.has(index) && !backgroundPickedSet.value.has(index), + ); }; +/** + * 为背景行补全括号,已带括号的文本保持不变 + * @param text - 背景行文本 + * @returns 带括号的文本 + */ +const wrapBackground = (text: string): string => (/^[((].*[))]$/.test(text) ? text : `(${text})`); + +/** + * 获取随主行输出的背景行,需同时开启背景行开关且该背景行已勾选 + * @param line - 展示用歌词行 + * @returns 需输出的背景行,无则为 undefined + */ +const outputBackground = (line: DisplayLyric): DisplayText | undefined => + showBackground.value && backgroundPickedSet.value.has(line.index) ? line.background : undefined; + /** 复制歌词 */ const handleCopy = async (): Promise => { const separator = selectedFilters.value.includes("emptyLine") ? "\n\n" : "\n"; let content = displayLyrics.value .filter((line) => pickedSet.value.has(line.index)) .map((line) => { - const parts = [line.text]; - if (showTranslation.value && line.translation) parts.push(line.translation); - if (showRomaji.value && line.romaji) parts.push(line.romaji); + const mainParts = [line.text]; + if (showTranslation.value && line.translation) mainParts.push(line.translation); + if (showRomaji.value && line.romaji) mainParts.push(line.romaji); + const backgroundParts: string[] = []; + const background = outputBackground(line); + if (background) { + if (background.text) backgroundParts.push(wrapBackground(background.text)); + if (showTranslation.value && background.translation) { + backgroundParts.push(wrapBackground(background.translation)); + } + if (showRomaji.value && background.romaji) { + backgroundParts.push(wrapBackground(background.romaji)); + } + } + const parts = line.backgroundFirst + ? [...backgroundParts, ...mainParts] + : [...mainParts, ...backgroundParts]; return parts.filter(Boolean).join("\n"); }) .filter(Boolean) @@ -131,13 +254,28 @@ const handleExport = async (): Promise => { track, lines: displayLyrics.value .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, - })), + .map((line) => { + const background = outputBackground(line); + return { + 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, + backgroundFirst: line.backgroundFirst, + background: background + ? { + text: background.text, + language: background.language, + translation: + showTranslation.value && background.translation + ? background.translation + : undefined, + romaji: showRomaji.value && background.romaji ? background.romaji : undefined, + } + : undefined, + }; + }), fallbackColor: theme.coverColor, fonts: { global: settings.appearance.fontFamily, @@ -176,23 +314,59 @@ const handleExport = async (): Promise => {
- -
- {{ line.text }} - - {{ line.translation }} - - - {{ line.romaji }} - -
-
+ +
+ {{ line.text }} + + {{ line.translation }} + + + {{ line.romaji }} + +
+
+ +
+ + {{ line.background.text }} + + + {{ line.background.translation }} + + + {{ line.background.romaji }} + +
+
+
@@ -204,6 +378,7 @@ const handleExport = async (): Promise => { + diff --git a/src/components/ui/SCheckbox.vue b/src/components/ui/SCheckbox.vue index f5505044b..03680e33d 100644 --- a/src/components/ui/SCheckbox.vue +++ b/src/components/ui/SCheckbox.vue @@ -113,7 +113,7 @@ const onKeydown = (event: KeyboardEvent): void => { :class="[iconSizeClassMap[mergedSize], 'pointer-events-none']" /> - + {{ label }} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index c0315f998..beb4f715d 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -508,6 +508,7 @@ "contentLabel": "Content to copy", "translation": "Translation", "romaji": "Romaji", + "background": "Background lines", "emptyLine": "Blank line", "songName": "Title", "artist": "Artist", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index 6c34c79f8..72fdc8770 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -508,6 +508,7 @@ "contentLabel": "要复制的内容", "translation": "翻译", "romaji": "音译", + "background": "背景行", "emptyLine": "空行", "songName": "歌名", "artist": "歌手", diff --git a/src/utils/lyric/poster.spec.ts b/src/utils/lyric/poster.spec.ts index d63f211f4..a6669434a 100644 --- a/src/utils/lyric/poster.spec.ts +++ b/src/utils/lyric/poster.spec.ts @@ -10,19 +10,27 @@ vi.mock("@/utils/color", () => ({ hexToRgb: () => "20, 20, 28" })); */ const setupCanvas = () => { const measured: Array<{ text: string; font: string; lang: string }> = []; - const drawn: Array<{ text: string; font: string; lang: string }> = []; + const drawn: Array<{ text: string; font: string; lang: string; y: number; color: string }> = []; const load = vi.fn().mockResolvedValue([]); Object.defineProperty(document, "fonts", { configurable: true, value: { load } }); const context = { font: "", lang: "inherit", + fillStyle: "", scale: vi.fn(), fillRect: vi.fn(), measureText: (text: string) => { measured.push({ text, font: context.font, lang: context.lang }); return { width: text.length * (context.font.includes('"Wide"') ? 100 : 20) }; }, - fillText: (text: string) => drawn.push({ text, font: context.font, lang: context.lang }), + fillText: (text: string, _x: number, y: number) => + drawn.push({ + text, + font: context.font, + lang: context.lang, + y, + color: String(context.fillStyle), + }), }; vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue( context as unknown as CanvasRenderingContext2D, @@ -103,6 +111,47 @@ describe("歌词海报字体", () => { expect(drawn.find((entry) => entry.text === text)?.font).not.toContain('"Wide"'); }); + it("背景行绘制在所属主行下方,主句加粗且比译文和音译更亮", async () => { + const { drawn } = setupCanvas(); + await createLyricPoster({ + track, + lines: [ + { + text: "主行歌词", + background: { text: "背景歌词", translation: "背景译文", romaji: "haikei romaji" }, + }, + ], + }); + const main = drawn.find((entry) => entry.text === "主行歌词")!; + const background = drawn.find((entry) => entry.text === "背景歌词")!; + const translation = drawn.find((entry) => entry.text === "背景译文")!; + const romaji = drawn.find((entry) => entry.text === "haikei romaji")!; + const alpha = (color: string) => Number(color.match(/([\d.]+)\)$/)?.[1]); + expect(main.font).toMatch(/^bold 34px /); + expect(background.font).toMatch(/^bold 22px /); + expect(translation.font).toMatch(/^18px /); + expect(alpha(background.color)).toBeGreaterThan(alpha(translation.color)); + expect(alpha(background.color)).toBeGreaterThan(alpha(romaji.color)); + expect(background.y).toBeGreaterThan(main.y); + }); + + it("前置背景行绘制在主行上方", async () => { + const { drawn } = setupCanvas(); + await createLyricPoster({ + track, + lines: [ + { + text: "主行歌词", + backgroundFirst: true, + background: { text: "前置背景歌词" }, + }, + ], + }); + const main = drawn.find((entry) => entry.text === "主行歌词")!; + const background = drawn.find((entry) => entry.text === "前置背景歌词")!; + expect(background.y).toBeLessThan(main.y); + }); + it("字体加载完成前不测量或绘制文字", async () => { const { load, measured, drawn } = setupCanvas(); const pending = Promise.withResolvers(); diff --git a/src/utils/lyric/poster.ts b/src/utils/lyric/poster.ts index 41deded3e..c657de801 100644 --- a/src/utils/lyric/poster.ts +++ b/src/utils/lyric/poster.ts @@ -2,8 +2,8 @@ import { applyLyricLanguages, type LyricLanguage, type LyricLine } from "lyric-k import type { Track } from "@shared/types/player"; import { hexToRgb } from "@/utils/color"; -/** 海报中的一行歌词 */ -export interface LyricPosterLine { +/** 海报中的一段文本(原文及其翻译、音译) */ +export interface LyricPosterTextLine { text: string; /** 原文语言,沿用播放器的识别结果 */ language?: LyricLanguage; @@ -11,8 +11,16 @@ export interface LyricPosterLine { translation?: string; /** 音译 */ romaji?: string; +} + +/** 海报中的一行歌词 */ +export interface LyricPosterLine extends LyricPosterTextLine { /** 对唱行 */ duet?: boolean; + /** 所属主行下方的背景行,以弱化样式绘制 */ + background?: LyricPosterTextLine; + /** 背景行起始早于主行(前置背景行),绘制在主行上方 */ + backgroundFirst?: boolean; } export interface LyricPosterOptions { @@ -51,10 +59,65 @@ const THUMB_TEXT_GAP = 18; const HEADER_GAP = 44; const WATERMARK_GAP = 26; const WATERMARK_H = 16; +/** 背景行组与主行组之间的间距 */ +const BG_GROUP_GAP = 10; /** 内容区宽度 */ const CONTENT_WIDTH = WIDTH - PAD_X * 2; +/** 单个文本层级的排版参数,gap 为同组内位于上一层级下方的间距 */ +interface TextStyle { + /** Canvas 字形与字号,不含字体族 */ + style: string; + lineHeight: number; + color: string; + gap: number; +} + +interface StyleSet { + main: TextStyle; + translation: TextStyle; + romaji: TextStyle; +} + +const LYRIC_STYLES: StyleSet = { + main: { style: "bold 34px", lineHeight: LYRIC_LH, color: "#ffffff", gap: 0 }, + translation: { + style: "24px", + lineHeight: TRANS_LH, + color: "rgba(255, 255, 255, 0.6)", + gap: SUBLINE_GAP, + }, + romaji: { + style: "italic 22px", + lineHeight: ROMAJI_LH, + color: "rgba(255, 255, 255, 0.45)", + gap: SUBLINE_GAP, + }, +}; + +/** 背景行整体弱化:字号与透明度低于主行,主句加粗且比译文、音译更亮 */ +const BG_STYLES: StyleSet = { + main: { style: "bold 22px", lineHeight: 36, color: "rgba(255, 255, 255, 0.7)", gap: 0 }, + translation: { style: "18px", lineHeight: 28, color: "rgba(255, 255, 255, 0.4)", gap: 4 }, + romaji: { style: "italic 18px", lineHeight: 26, color: "rgba(255, 255, 255, 0.35)", gap: 4 }, +}; + +/** 待测量的一段文本及其排版参数 */ +interface TextSpec { + text: string; + style: TextStyle; + language?: LyricLanguage; +} + +/** 已折行的一段文本 */ +interface MeasuredRow { + lines: string[]; + font: string; + lang: string; + style: TextStyle; +} + /** Blob 转 dataURL */ const blobToDataUrl = (blob: Blob): Promise => new Promise((resolve, reject) => { @@ -156,6 +219,12 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise [ + { + text: textLine.text, + style: styles.main, + language: languageForText(textLine.text, textLine.language), + }, + ...(textLine.translation ? [{ text: textLine.translation, style: styles.translation }] : []), + ...(textLine.romaji ? [{ text: textLine.romaji, style: styles.romaji }] : []), + ]; + + /** + * 测量单段文本的折行结果,语言与字体规则与绘制保持一致 + * @param spec - 文本规格 + * @returns 已折行的文本段 + */ + const measureRow = ({ text, style, language }: TextSpec): MeasuredRow => { + const font = fontForText(text, style.style, language); + textContext.lang = language ?? "inherit"; + ctx.font = font; + const lines = wrapText(ctx, text, CONTENT_WIDTH); + textContext.lang = "inherit"; + return { lines, font, lang: language ?? "inherit", style }; + }; + + /** + * 测量一组文本,译文与音译折行为空时不占位 + * @param specs - 文本规格,首项为主行 + * @returns 已折行的文本段,首项为主行 + */ + const measureGroup = (specs: TextSpec[]): MeasuredRow[] => + specs.map(measureRow).filter((row, index) => index === 0 || row.lines.length > 0); + + /** + * 计算一组文本高度,主行即使为空也保留一行高度 + * @param rows - 已折行的文本段 + * @returns 高度(像素) + */ + const groupHeight = (rows: MeasuredRow[]): number => + rows.reduce((height, row, index) => { + const lineCount = index === 0 ? Math.max(1, row.lines.length) : row.lines.length; + return height + (index > 0 ? row.style.gap : 0) + lineCount * row.style.lineHeight; + }, 0); + + /** + * 绘制一组文本,返回绘制结束后的纵坐标 + * @param rows - 已折行的文本段 + * @param textX - 文本锚点横坐标 + * @param startY - 起始纵坐标 + * @returns 绘制结束后的纵坐标 + */ + const drawGroup = (rows: MeasuredRow[], textX: number, startY: number): number => { + let y = startY; + rows.forEach((row, index) => { + if (index > 0) y += row.style.gap; + ctx.fillStyle = row.style.color; + textContext.lang = row.lang; + ctx.font = row.font; + for (const text of row.lines) { + ctx.fillText(text, textX, y); + y += row.style.lineHeight; + } + textContext.lang = "inherit"; + }); + return y; + }; + const title = track.title; const artist = track.artists.map((item) => item.name).join(" / "); const coverDataUrl = await resolveCoverDataUrl(track); const coverImg = coverDataUrl ? await loadImage(coverDataUrl) : null; - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - if (!ctx) throw new Error("无法创建画布上下文"); - // 当前 DOM 类型尚未声明 Canvas 文本语言属性 - const textContext = ctx as CanvasRenderingContext2D & { lang: string }; - // 字体就绪后再测量,避免首次导出使用回退字体的宽度 + const groups = lines.map((line) => ({ + rows: specsOf(line, LYRIC_STYLES), + background: line.background?.text ? specsOf(line.background, BG_STYLES) : [], + duet: !!line.duet, + backgroundFirst: !!line.backgroundFirst, + })); 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)] - : []), - ]), + ...groups.flatMap(({ rows, background }) => + [...rows, ...background].map((spec) => + document.fonts.load(fontForText(spec.text, spec.style.style, spec.language), spec.text), + ), + ), ]); // 预折行并量算高度 - const layout = lines.map((line) => { - const mainLanguage = languageForText(line.text, line.language); - const mainFont = fontForText(line.text, "bold 34px", line.language); - const translationFont = fontForText(line.translation ?? "", "24px"); - const romajiFont = fontForText(line.romaji ?? "", "italic 22px"); - textContext.lang = mainLanguage ?? "inherit"; - ctx.font = mainFont; - const main = wrapText(ctx, line.text || " ", CONTENT_WIDTH); - textContext.lang = "inherit"; - ctx.font = translationFont; - const translation = line.translation ? wrapText(ctx, line.translation, CONTENT_WIDTH) : []; - ctx.font = romajiFont; - const romaji = line.romaji ? wrapText(ctx, line.romaji, CONTENT_WIDTH) : []; - return { - main, - translation, - romaji, - mainFont, - translationFont, - romajiFont, - mainLanguage, - duet: !!line.duet, - }; - }); + const layout = groups.map((group) => ({ + rows: measureGroup(group.rows), + background: measureGroup(group.background), + duet: group.duet, + backgroundFirst: group.backgroundFirst, + })); let lyricsHeight = 0; layout.forEach((block, index) => { if (index > 0) lyricsHeight += BLOCK_GAP; - lyricsHeight += Math.max(1, block.main.length) * LYRIC_LH; - if (block.translation.length) lyricsHeight += SUBLINE_GAP + block.translation.length * TRANS_LH; - if (block.romaji.length) lyricsHeight += SUBLINE_GAP + block.romaji.length * ROMAJI_LH; + lyricsHeight += groupHeight(block.rows); + if (block.background.length) { + lyricsHeight += BG_GROUP_GAP + groupHeight(block.background); + } }); const totalHeight = @@ -309,31 +429,14 @@ export const createLyricPoster = async (options: LyricPosterOptions): Promise