mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
fix(renderer): tighten macOS invisible overlay multiline line height
This commit is contained in:
243
src/renderer/positioning/invisible-layout-helpers.test.ts
Normal file
243
src/renderer/positioning/invisible-layout-helpers.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user