From ad9794806277ef049e889dff94f0397160bdea3f Mon Sep 17 00:00:00 2001 From: sudacode Date: Wed, 25 Feb 2026 20:57:04 -0800 Subject: [PATCH] fix(renderer): calibrate macOS invisible overlay spacing --- .../invisible-layout-helpers.test.ts | 4 +- .../positioning/invisible-layout-helpers.ts | 62 +++++++++++++++++-- src/renderer/positioning/invisible-layout.ts | 4 +- src/renderer/renderer.ts | 5 +- src/renderer/style.css | 11 +++- src/renderer/subtitle-render.test.ts | 29 +++++++++ 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/src/renderer/positioning/invisible-layout-helpers.test.ts b/src/renderer/positioning/invisible-layout-helpers.test.ts index d392821..e9a60d8 100644 --- a/src/renderer/positioning/invisible-layout-helpers.test.ts +++ b/src/renderer/positioning/invisible-layout-helpers.test.ts @@ -163,7 +163,7 @@ test('applyTypography applies full mpv letter spacing scale on macOS', () => { assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('letter-spacing'), '3px'); }); -test('applyTypography uses tighter macOS line-height for invisible multiline alignment', () => { +test('applyTypography uses macOS multiline-tuned line-height for invisible overlay', () => { const ctx = createContext({ isMacOSPlatform: true, lineCount: 3, @@ -178,7 +178,7 @@ test('applyTypography uses tighter macOS line-height for invisible multiline ali }); }); - assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '0.96'); + assert.equal(ctx.dom.subtitleRoot.style.getPropertyValue('line-height'), '1.62'); }); test('applyVerticalPosition uses subtitle position margin and baseline compensation', () => { diff --git a/src/renderer/positioning/invisible-layout-helpers.ts b/src/renderer/positioning/invisible-layout-helpers.ts index 19bbeb1..c8d526a 100644 --- a/src/renderer/positioning/invisible-layout-helpers.ts +++ b/src/renderer/positioning/invisible-layout-helpers.ts @@ -1,6 +1,11 @@ import type { MpvSubtitleRenderMetrics } from '../../types'; import type { RendererContext } from '../context'; +const INVISIBLE_MACOS_VERTICAL_NUDGE_PX = 5; +const INVISIBLE_MACOS_LINE_HEIGHT_SINGLE = '1.08'; +const INVISIBLE_MACOS_LINE_HEIGHT_MULTI = '1.35'; +const INVISIBLE_MACOS_LINE_HEIGHT_MULTI_DENSE = '1.48'; + let fontMetricsCanvas: HTMLCanvasElement | null = null; export function applyContainerBaseLayout( @@ -112,6 +117,13 @@ function resolveFontFamily(rawFont: string): string { : `"${rawFont}", sans-serif`; } +export function resolveInvisibleLineHeight(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, @@ -123,10 +135,6 @@ function resolveLetterSpacing( return '0px'; } -function resolveInvisibleLineHeight(isMacOSPlatform: boolean): string { - return isMacOSPlatform ? '0.96' : '1'; -} - function measureFontDescentPx(ctx: RendererContext): number | null { if (typeof document === 'undefined') return null; const computedStyle = getComputedStyle(ctx.dom.subtitleRoot); @@ -148,6 +156,42 @@ function measureFontDescentPx(ctx: RendererContext): number | null { return metrics.actualBoundingBoxDescent; } +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 applyMacOSAdjustments(ctx: RendererContext): void { + const isMacOSPlatform = ctx.platform.isMacOSPlatform; + if (!isMacOSPlatform) return; + + const currentBottom = parseFloat(ctx.dom.subtitleContainer.style.bottom); + if (!Number.isFinite(currentBottom)) return; + + ctx.dom.subtitleContainer.style.bottom = `${Math.max( + 0, + currentBottom + INVISIBLE_MACOS_VERTICAL_NUDGE_PX, + )}px`; +} + export function applyTypography( ctx: RendererContext, params: { @@ -157,11 +201,14 @@ export function applyTypography( }, ): void { const isMacOSPlatform = ctx.platform.isMacOSPlatform; + const lineCount = Math.max(1, ctx.state.currentInvisibleSubtitleLineCount); + const invisibleLineHeight = resolveInvisibleLineHeight(lineCount, isMacOSPlatform); + ctx.dom.subtitleRoot.style.setProperty('--invisible-sub-line-height', invisibleLineHeight); ctx.dom.subtitleRoot.style.setProperty( 'line-height', - resolveInvisibleLineHeight(isMacOSPlatform), - 'important', + invisibleLineHeight, + isMacOSPlatform ? 'important' : '', ); ctx.dom.subtitleRoot.style.fontFamily = resolveFontFamily(params.metrics.subFont); ctx.dom.subtitleRoot.style.setProperty( @@ -175,4 +222,7 @@ export function applyTypography( ctx.dom.subtitleRoot.style.transform = ''; ctx.dom.subtitleRoot.style.transformOrigin = ''; ctx.state.invisibleMeasuredDescentPx = measureFontDescentPx(ctx); + + applyComputedLineHeightCompensation(ctx, params.effectiveFontSize); + applyMacOSAdjustments(ctx); } diff --git a/src/renderer/positioning/invisible-layout.ts b/src/renderer/positioning/invisible-layout.ts index 2523009..130e046 100644 --- a/src/renderer/positioning/invisible-layout.ts +++ b/src/renderer/positioning/invisible-layout.ts @@ -76,7 +76,9 @@ export function createMpvSubtitleLayoutController( options.applyInvisibleSubtitleOffsetPosition(); options.updateInvisiblePositionEditHud(); - console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source); + if (source !== 'subtitle-change') { + console.log('[invisible-overlay] Applied mpv subtitle render metrics from', source); + } } return { diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 0179901..620f883 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -240,6 +240,9 @@ function runGuardedAsync(action: string, fn: () => Promise | void): void { async function init(): Promise { document.body.classList.add(`layer-${ctx.platform.overlayLayer}`); + if (ctx.platform.isMacOSPlatform) { + document.body.classList.add('platform-macos'); + } window.electronAPI.onSubtitle((data: SubtitleData) => { runGuarded('subtitle:update', () => { @@ -252,7 +255,7 @@ async function init(): Promise { if (ctx.platform.isInvisibleLayer && ctx.state.mpvSubtitleRenderMetrics) { positioning.applyInvisibleSubtitleLayoutFromMpvMetrics( ctx.state.mpvSubtitleRenderMetrics, - 'subtitle', + 'subtitle-change', ); } measurementReporter.schedule(); diff --git a/src/renderer/style.css b/src/renderer/style.css index f1153a6..2aea994 100644 --- a/src/renderer/style.css +++ b/src/renderer/style.css @@ -279,7 +279,7 @@ body { #subtitleRoot { text-align: center; font-size: 35px; - line-height: 1.5; + line-height: var(--visible-sub-line-height, 1.32); color: #cad3f5; --subtitle-known-word-color: #a6da95; --subtitle-n-plus-one-color: #c6a0f6; @@ -426,7 +426,12 @@ body.settings-modal-open #subtitleContainer { #subtitleRoot br { display: block; content: ''; - margin-bottom: 0.3em; + margin-bottom: var(--visible-sub-line-gap, 0.08em); +} + +body.platform-macos.layer-visible #subtitleRoot { + --visible-sub-line-height: 1.64; + --visible-sub-line-gap: 0.54em; } #subtitleRoot.has-selection .word:hover, @@ -452,7 +457,7 @@ body.layer-invisible #subtitleRoot .c { -webkit-text-fill-color: transparent !important; background: transparent !important; caret-color: transparent !important; - line-height: normal !important; + line-height: var(--invisible-sub-line-height, normal) !important; font-kerning: auto; letter-spacing: normal; font-variant-ligatures: normal; diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 940d113..871bef2 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -12,6 +12,7 @@ import { normalizeSubtitle, shouldRenderTokenizedSubtitle, } from './subtitle-render.js'; +import { resolveInvisibleLineHeight } from './positioning/invisible-layout-helpers.js'; function createToken(overrides: Partial): MergedToken { return { @@ -328,4 +329,32 @@ test('JLPT CSS rules use underline-only styling in renderer stylesheet', () => { ); assert.match(block, /color:\s*var\(/); } + + const invisibleBlock = extractClassBlock( + cssText, + 'body.layer-invisible #subtitleRoot', + ); + assert.match( + invisibleBlock, + /line-height:\s*var\(--invisible-sub-line-height,\s*normal\)\s*!important;/, + ); + + const visibleMacBlock = extractClassBlock( + cssText, + 'body.platform-macos.layer-visible #subtitleRoot', + ); + assert.match(visibleMacBlock, /--visible-sub-line-height:\s*1\.64;/); + assert.match(visibleMacBlock, /--visible-sub-line-gap:\s*0\.54em;/); +}); + +test('invisible overlay uses looser line height on macOS for multi-line subtitles', () => { + assert.equal(resolveInvisibleLineHeight(1, true), '1.08'); + assert.equal(resolveInvisibleLineHeight(2, true), '1.5'); + assert.equal(resolveInvisibleLineHeight(3, true), '1.62'); +}); + +test('invisible overlay keeps default line height on non-macOS platforms', () => { + assert.equal(resolveInvisibleLineHeight(1, false), 'normal'); + assert.equal(resolveInvisibleLineHeight(2, false), 'normal'); + assert.equal(resolveInvisibleLineHeight(4, false), 'normal'); });