From 058d359553176d579e95545400cb7e15b19ad951 Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 25 Feb 2026 00:31:39 -0800 Subject: [PATCH] fix(renderer): tighten macOS invisible overlay multiline line height --- .../invisible-layout-helpers.test.ts | 243 ++++++++++++++++++ .../positioning/invisible-layout-helpers.ts | 105 ++++---- 2 files changed, 291 insertions(+), 57 deletions(-) create mode 100644 src/renderer/positioning/invisible-layout-helpers.test.ts diff --git a/src/renderer/positioning/invisible-layout-helpers.test.ts b/src/renderer/positioning/invisible-layout-helpers.test.ts new file mode 100644 index 0000000..d392821 --- /dev/null +++ b/src/renderer/positioning/invisible-layout-helpers.test.ts @@ -0,0 +1,243 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import type { MpvSubtitleRenderMetrics } from '../../types'; +import { + applyTypography, + applyVerticalPosition, + resolveBaselineCompensationPx, +} from './invisible-layout-helpers.js'; + +const METRICS: MpvSubtitleRenderMetrics = { + subPos: 100, + subFontSize: 38, + subScale: 1, + subMarginY: 34, + subMarginX: 19, + subFont: 'sans-serif', + subSpacing: 0, + subBold: false, + subItalic: false, + subBorderSize: 2.5, + subShadowOffset: 0, + subAssOverride: 'yes', + subScaleByWindow: true, + subUseMargins: true, + osdHeight: 720, + osdDimensions: null, +}; + +type TypographyTestContext = { + dom: { + subtitleRoot: { style: CSSStyleDeclaration }; + subtitleContainer: { style: CSSStyleDeclaration }; + }; + state: { + currentInvisibleSubtitleLineCount: number; + invisibleMeasuredDescentPx: number | null; + }; + platform: { + isMacOSPlatform: boolean; + }; +}; + +function withMockedComputedLineHeight(lineHeightPx: number, callback: () => void): void { + const originalGetComputedStyle = (globalThis as { getComputedStyle?: unknown }).getComputedStyle; + Object.defineProperty(globalThis, 'getComputedStyle', { + configurable: true, + value: () => + ({ + lineHeight: `${lineHeightPx}px`, + }) as CSSStyleDeclaration, + }); + try { + callback(); + } finally { + if (typeof originalGetComputedStyle === 'function') { + Object.defineProperty(globalThis, 'getComputedStyle', { + configurable: true, + value: originalGetComputedStyle, + }); + } else { + Reflect.deleteProperty(globalThis, 'getComputedStyle'); + } + } +} + +function createStyle(initial: Record = {}): CSSStyleDeclaration { + const values: Record = { ...initial }; + const target = { + setProperty: (name: string, value: string) => { + values[name] = value; + }, + getPropertyValue: (name: string) => values[name] ?? '', + } as unknown as CSSStyleDeclaration; + + return new Proxy(target, { + get(obj, prop) { + if (typeof prop === 'string') { + if (prop in obj) return obj[prop as keyof CSSStyleDeclaration]; + return values[prop] ?? ''; + } + return obj[prop as keyof CSSStyleDeclaration]; + }, + set(_obj, prop, value) { + if (typeof prop === 'string') { + values[prop] = String(value); + return true; + } + return false; + }, + }); +} + +function createContext(options: { + isMacOSPlatform: boolean; + lineCount: number; + bottomPx?: number; + topPx?: number; +}): TypographyTestContext { + const subtitleRoot = { style: createStyle() }; + const subtitleContainer = { + style: createStyle({ + bottom: typeof options.bottomPx === 'number' ? `${options.bottomPx}px` : '', + top: typeof options.topPx === 'number' ? `${options.topPx}px` : '', + }), + }; + + return { + dom: { subtitleRoot, subtitleContainer }, + state: { + currentInvisibleSubtitleLineCount: options.lineCount, + invisibleMeasuredDescentPx: null, + }, + platform: { + isMacOSPlatform: options.isMacOSPlatform, + }, + }; +} + +test('resolveBaselineCompensationPx uses measured descent when present', () => { + const compensation = resolveBaselineCompensationPx(10, 2.5, 1); + assert.equal(compensation, 16); +}); + +test('resolveBaselineCompensationPx falls back to border and shadow compensation when descent missing', () => { + const compensation = resolveBaselineCompensationPx(null, 2.5, 1); + assert.equal(compensation, 17.5); +}); + +test('applyTypography keeps macOS default letter spacing neutral when mpv spacing is zero', () => { + const ctx = createContext({ + isMacOSPlatform: true, + lineCount: 1, + bottomPx: 120, + }); + + withMockedComputedLineHeight(34, () => { + applyTypography(ctx as never, { + metrics: { ...METRICS, subSpacing: 0 }, + pxPerScaledPixel: 1, + effectiveFontSize: 34, + }); + }); + + assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '0px'); +}); + +test('applyTypography applies full mpv letter spacing scale on macOS', () => { + const ctx = createContext({ + isMacOSPlatform: true, + lineCount: 1, + bottomPx: 120, + }); + + withMockedComputedLineHeight(34, () => { + applyTypography(ctx as never, { + metrics: { ...METRICS, subSpacing: 1.5 }, + pxPerScaledPixel: 2, + effectiveFontSize: 34, + }); + }); + + assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px'); +}); + +test('applyTypography uses tighter macOS line-height for invisible multiline alignment', () => { + const ctx = createContext({ + isMacOSPlatform: true, + lineCount: 3, + bottomPx: 120, + }); + + withMockedComputedLineHeight(34, () => { + applyTypography(ctx as never, { + metrics: METRICS, + pxPerScaledPixel: 1, + effectiveFontSize: 34, + }); + }); + + assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '0.96'); +}); + +test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => { + const ctx = createContext({ + isMacOSPlatform: true, + lineCount: 1, + }); + + applyVerticalPosition(ctx as never, { + metrics: { ...METRICS, subPos: 90 }, + renderAreaHeight: 720, + topInset: 0, + bottomInset: 10, + marginY: 34, + borderPx: 2.5, + shadowPx: 0, + measuredDescentPx: null, + vAlign: 0, + }); + + const bottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); + assert.ok(Number.isFinite(bottom)); + assert.ok(bottom > 90 && bottom < 105); +}); + +test('applyVerticalPosition uses measured descent consistently across line counts', () => { + const single = createContext({ + isMacOSPlatform: true, + lineCount: 1, + }); + const dense = createContext({ + isMacOSPlatform: true, + lineCount: 3, + }); + + applyVerticalPosition(single as never, { + metrics: METRICS, + renderAreaHeight: 720, + topInset: 0, + bottomInset: 0, + marginY: 34, + borderPx: 2.5, + shadowPx: 0, + measuredDescentPx: 12, + vAlign: 0, + }); + applyVerticalPosition(dense as never, { + metrics: METRICS, + renderAreaHeight: 720, + topInset: 0, + bottomInset: 0, + marginY: 34, + borderPx: 2.5, + shadowPx: 0, + measuredDescentPx: 12, + vAlign: 0, + }); + + const singleBottom = parseFloat(single.dom.subtitleContainer.style.bottom); + const denseBottom = parseFloat(dense.dom.subtitleContainer.style.bottom); + assert.equal(singleBottom, denseBottom); +}); diff --git a/src/renderer/positioning/invisible-layout-helpers.ts b/src/renderer/positioning/invisible-layout-helpers.ts index 8179fb9..19bbeb1 100644 --- a/src/renderer/positioning/invisible-layout-helpers.ts +++ b/src/renderer/positioning/invisible-layout-helpers.ts @@ -1,10 +1,7 @@ import type { MpvSubtitleRenderMetrics } from '../../types'; import type { RendererContext } from '../context'; -const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5; -const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '0.92'; -const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.2'; -const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.3'; +let fontMetricsCanvas: HTMLCanvasElement | null = null; export function applyContainerBaseLayout( ctx: RendererContext, @@ -53,14 +50,17 @@ export function applyVerticalPosition( topInset: number; bottomInset: number; marginY: number; - effectiveFontSize: number; borderPx: number; shadowPx: number; + measuredDescentPx: number | null; vAlign: 0 | 1 | 2; }, ): void { - const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset); - const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5); + const baselineCompensationPx = resolveBaselineCompensationPx( + params.measuredDescentPx, + params.borderPx, + params.shadowPx, + ); if (params.vAlign === 2) { ctx.dom.subtitleContainer.style.top = `${Math.max( @@ -78,14 +78,27 @@ export function applyVerticalPosition( return; } - const anchorY = - params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx; - const bottomPx = Math.max(0, params.renderAreaHeight - anchorY); + const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight; + const effectiveMargin = Math.max(params.marginY, subPosMargin); + const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx); ctx.dom.subtitleContainer.style.top = ''; ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`; } +export function resolveBaselineCompensationPx( + measuredDescentPx: number | null, + borderPx: number, + shadowPx: number, +): number { + const outlineCompensationPx = Math.max(0, borderPx * 2 + shadowPx); + if (typeof measuredDescentPx === 'number' && Number.isFinite(measuredDescentPx) && measuredDescentPx > 0) { + return Math.max(0, measuredDescentPx + outlineCompensationPx); + } + + return Math.max(0, (borderPx + shadowPx) * 5); +} + function resolveFontFamily(rawFont: string): string { const strippedFont = rawFont .replace( @@ -99,59 +112,40 @@ function resolveFontFamily(rawFont: string): string { : `"${rawFont}", sans-serif`; } -function resolveLineHeight(lineCount: number, isMacOSPlatform: boolean): string { - if (!isMacOSPlatform) return 'normal'; - if (lineCount >= 3) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE; - if (lineCount >= 2) return INVISIBLE_MACOS_LINE_HEIGHT_MULTI; - return INVISIBLE_MACOS_LINE_HEIGHT_SINGLE; -} - function resolveLetterSpacing( spacing: number, pxPerScaledPixel: number, - isMacOSPlatform: boolean, ): string { if (Math.abs(spacing) > 0.0001) { - return `${spacing * pxPerScaledPixel * (isMacOSPlatform ? 0.7 : 1)}px`; + return `${spacing * pxPerScaledPixel}px`; } - return isMacOSPlatform ? '-0.02em' : '0px'; + return '0px'; } -function applyComputedLineHeightCompensation( - ctx: RendererContext, - effectiveFontSize: number, -): void { - const computedLineHeight = parseFloat(getComputedStyle(ctx.dom.subtitleRoot).lineHeight); - if (!Number.isFinite(computedLineHeight) || computedLineHeight <= effectiveFontSize) { - return; - } - - const halfLeading = (computedLineHeight - effectiveFontSize) / 2; - if (halfLeading <= 0.5) return; - - const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); - if (Number.isFinite(currentBottom)) { - ctx.dom.subtitleContainer.style.bottom = `${Math.max(0, currentBottom - halfLeading)}px`; - } - - const currentTop = parseFloat(ctx.dom.subtitleContainer.style.top); - if (Number.isFinite(currentTop)) { - ctx.dom.subtitleContainer.style.top = `${Math.max(0, currentTop - halfLeading)}px`; - } +function resolveInvisibleLineHeight(isMacOSPlatform: boolean): string { + return isMacOSPlatform ? '0.96' : '1'; } -function applyMacOSAdjustments(ctx: RendererContext): void { - const isMacOSPlatform = ctx.platform.isMacOSPlatform; - if (!isMacOSPlatform) return; +function measureFontDescentPx(ctx: RendererContext): number | null { + if (typeof document === 'undefined') return null; + const computedStyle = getComputedStyle(ctx.dom.subtitleRoot); + const font = computedStyle.font?.trim(); + if (!font) return null; - const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); - if (!Number.isFinite(currentBottom)) return; + if (!fontMetricsCanvas) { + fontMetricsCanvas = document.createElement('canvas'); + } - ctx.dom.subtitleContainer.style.bottom = `${Math.max( - 0, - currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX, - )}px`; + const context = fontMetricsCanvas.getContext('2d'); + if (!context) return null; + + context.font = font; + const metrics = context.measureText('Hg漢あ'); + if (!Number.isFinite(metrics.actualBoundingBoxDescent) || metrics.actualBoundingBoxDescent <= 0) { + return null; + } + return metrics.actualBoundingBoxDescent; } export function applyTypography( @@ -162,18 +156,17 @@ export function applyTypography( effectiveFontSize: number; }, ): void { - const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount); const isMacOSPlatform = ctx.platform.isMacOSPlatform; ctx.dom.subtitleRoot.style.setProperty( 'line-height', - resolveLineHeight(lineCount, isMacOSPlatform), - isMacOSPlatform ? 'important' : '', + resolveInvisibleLineHeight(isMacOSPlatform), + 'important', ); ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); ctx.dom.subtitleRoot.style.setProperty( 'letter-spacing', - resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform), + resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel), isMacOSPlatform ? 'important' : '', ); ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none'; @@ -181,7 +174,5 @@ export function applyTypography( ctx.dom.subtitleRoot.style.fontStyle = params.metrics.subItalic ? 'italic' : 'normal'; ctx.dom.subtitleRoot.style.transform = ''; ctx.dom.subtitleRoot.style.transformOrigin = ''; - - applyComputedLineHeightCompensation(ctx, params.effectiveFontSize); - applyMacOSAdjustments(ctx); + ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx); }