fix(renderer): tighten macOS invisible overlay multiline line height

This commit is contained in:
2026-02-25 00:31:39 -08:00
parent 6eda768261
commit 058d359553
2 changed files with 291 additions and 57 deletions

View File

@@ -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<string, string> = {}): CSSStyleDeclaration {
const values: Record<string, string> = { ...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);
});

View File

@@ -1,10 +1,7 @@
import type { MpvSubtitleRenderMetrics } from '../../types'; import type { MpvSubtitleRenderMetrics } from '../../types';
import type { RendererContext } from '../context'; import type { RendererContext } from '../context';
const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5; let fontMetricsCanvas: HTMLCanvasElement | null = null;
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';
export function applyContainerBaseLayout( export function applyContainerBaseLayout(
ctx: RendererContext, ctx: RendererContext,
@@ -53,14 +50,17 @@ export function applyVerticalPosition(
topInset: number; topInset: number;
bottomInset: number; bottomInset: number;
marginY: number; marginY: number;
effectiveFontSize: number;
borderPx: number; borderPx: number;
shadowPx: number; shadowPx: number;
measuredDescentPx: number | null;
vAlign: 0 | 1 | 2; vAlign: 0 | 1 | 2;
}, },
): void { ): void {
const usableHeight = Math.max(1, params.renderAreaHeight - params.topInset - params.bottomInset); const baselineCompensationPx = resolveBaselineCompensationPx(
const baselineCompensationPx = Math.max(0, (params.borderPx + params.shadowPx) * 5); params.measuredDescentPx,
params.borderPx,
params.shadowPx,
);
if (params.vAlign === 2) { if (params.vAlign === 2) {
ctx.dom.subtitleContainer.style.top = `${Math.max( ctx.dom.subtitleContainer.style.top = `${Math.max(
@@ -78,14 +78,27 @@ export function applyVerticalPosition(
return; return;
} }
const anchorY = const subPosMargin = ((100 - params.metrics.subPos) / 100) * params.renderAreaHeight;
params.topInset + (usableHeight * params.metrics.subPos) / 100 - params.marginY + baselineCompensationPx; const effectiveMargin = Math.max(params.marginY, subPosMargin);
const bottomPx = Math.max(0, params.renderAreaHeight - anchorY); const bottomPx = Math.max(0, params.bottomInset + effectiveMargin + baselineCompensationPx);
ctx.dom.subtitleContainer.style.top = ''; ctx.dom.subtitleContainer.style.top = '';
ctx.dom.subtitleContainer.style.bottom = `${bottomPx}px`; 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 { function resolveFontFamily(rawFont: string): string {
const strippedFont = rawFont const strippedFont = rawFont
.replace( .replace(
@@ -99,59 +112,40 @@ function resolveFontFamily(rawFont: string): string {
: `"${rawFont}", sans-serif`; : `"${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( function resolveLetterSpacing(
spacing: number, spacing: number,
pxPerScaledPixel: number, pxPerScaledPixel: number,
isMacOSPlatform: boolean,
): string { ): string {
if (Math.abs(spacing) > 0.0001) { 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( function resolveInvisibleLineHeight(isMacOSPlatform: boolean): string {
ctx: RendererContext, return isMacOSPlatform ? '0.96' : '1';
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 applyMacOSAdjustments(ctx: RendererContext): void { function measureFontDescentPx(ctx: RendererContext): number | null {
const isMacOSPlatform = ctx.platform.isMacOSPlatform; if (typeof document === 'undefined') return null;
if (!isMacOSPlatform) return; const computedStyle = getComputedStyle(ctx.dom.subtitleRoot);
const font = computedStyle.font?.trim();
if (!font) return null;
const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); if (!fontMetricsCanvas) {
if (!Number.isFinite(currentBottom)) return; fontMetricsCanvas = document.createElement('canvas');
}
ctx.dom.subtitleContainer.style.bottom = `${Math.max( const context = fontMetricsCanvas.getContext('2d');
0, if (!context) return null;
currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX,
)}px`; context.font = font;
const metrics = context.measureText('Hg漢あ');
if (!Number.isFinite(metrics.actualBoundingBoxDescent) || metrics.actualBoundingBoxDescent <= 0) {
return null;
}
return metrics.actualBoundingBoxDescent;
} }
export function applyTypography( export function applyTypography(
@@ -162,18 +156,17 @@ export function applyTypography(
effectiveFontSize: number; effectiveFontSize: number;
}, },
): void { ): void {
const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount);
const isMacOSPlatform = ctx.platform.isMacOSPlatform; const isMacOSPlatform = ctx.platform.isMacOSPlatform;
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
'line-height', 'line-height',
resolveLineHeight(lineCount, isMacOSPlatform), resolveInvisibleLineHeight(isMacOSPlatform),
isMacOSPlatform ? 'important' : '', 'important',
); );
ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont);
ctx.dom.subtitleRoot.style.setProperty( ctx.dom.subtitleRoot.style.setProperty(
'letter-spacing', 'letter-spacing',
resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel, isMacOSPlatform), resolveLetterSpacing(params.metrics.subSpacing, params.pxPerScaledPixel),
isMacOSPlatform ? 'important' : '', isMacOSPlatform ? 'important' : '',
); );
ctx.dom.subtitleRoot.style.fontKerning = isMacOSPlatform ? 'auto' : 'none'; 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.fontStyle = params.metrics.subItalic ? 'italic' : 'normal';
ctx.dom.subtitleRoot.style.transform = ''; ctx.dom.subtitleRoot.style.transform = '';
ctx.dom.subtitleRoot.style.transformOrigin = ''; ctx.dom.subtitleRoot.style.transformOrigin = '';
ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx);
applyComputedLineHeightCompensation(ctx, params.effectiveFontSize);
applyMacOSAdjustments(ctx);
} }