Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,74 @@ window.__ModuleLoader__.load({
};
}
//#endregion
//#region src/client/background.ts
/** 无主题或亮色时的不透明分享图底色,对应卡片 `var()` 兜底 `#f7f8fa`。 */
const SHARE_LIGHT_BACKGROUND = "rgb(247, 248, 250)";
/** 官方暗色 `--dsw-alias-bg-base` 的实色,避免皮肤把 token 设成 transparent。 */
const SHARE_DARK_BACKGROUND = "rgb(21, 21, 23)";
function shareFallbackBackground(document) {
return document.body.hasAttribute("data-ds-dark-theme") ? SHARE_DARK_BACKGROUND : SHARE_LIGHT_BACKGROUND;
}
function clampByte(value) {
return Math.min(255, Math.max(0, Math.round(value)));
}
function parseAlpha(raw) {
if (raw.endsWith("%")) return Math.min(1, Math.max(0, Number(raw.slice(0, -1)) / 100));
const value = Number(raw);
if (!Number.isFinite(value)) return 1;
return Math.min(1, Math.max(0, value));
}
function parseCssColor(color) {
const value = color.trim().toLowerCase();
if (!value || value === "transparent" || value === "none") return {
r: 0,
g: 0,
b: 0,
a: 0
};
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
if (hex) {
const digits = hex[1].length === 3 ? hex[1].split("").map((digit) => digit + digit).join("") : hex[1];
return {
r: Number.parseInt(digits.slice(0, 2), 16),
g: Number.parseInt(digits.slice(2, 4), 16),
b: Number.parseInt(digits.slice(4, 6), 16),
a: 1
};
}
const comma = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+%?))?\s*\)$/);
if (comma) return {
r: Number(comma[1]),
g: Number(comma[2]),
b: Number(comma[3]),
a: comma[4] === void 0 ? 1 : parseAlpha(comma[4])
};
const space = value.match(/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/);
if (space) return {
r: Number(space[1]),
g: Number(space[2]),
b: Number(space[3]),
a: space[4] === void 0 ? 1 : parseAlpha(space[4])
};
return null;
}
/** 把计算色压成不透明实色。透明走主题兜底;半透明叠在兜底之上。无法解析的颜色原样返回。 */
function flattenOpaqueBackground(color, fallback) {
const parsed = parseCssColor(color);
if (!parsed) {
const normalized = color.trim().toLowerCase();
return !normalized || normalized === "transparent" ? fallback : color;
}
if (parsed.a <= 0) return fallback;
if (parsed.a >= 1) return `rgb(${clampByte(parsed.r)}, ${clampByte(parsed.g)}, ${clampByte(parsed.b)})`;
const base = parseCssColor(fallback) ?? parseCssColor("rgb(247, 248, 250)");
const alpha = parsed.a;
return `rgb(${clampByte(parsed.r * alpha + base.r * (1 - alpha))}, ${clampByte(parsed.g * alpha + base.g * (1 - alpha))}, ${clampByte(parsed.b * alpha + base.b * (1 - alpha))})`;
}
function shareExportBackground(element) {
return flattenOpaqueBackground(element.ownerDocument.defaultView?.getComputedStyle(element).backgroundColor ?? "", shareFallbackBackground(element.ownerDocument));
}
//#endregion
//#region src/client/settings.ts
const WIDTH_PRESETS = {
phone: 375,
Expand Down Expand Up @@ -334,6 +402,7 @@ window.__ModuleLoader__.load({
card.append(footer);
host.append(card);
document.body.append(host);
card.style.backgroundColor = shareExportBackground(card);
return {
element: card,
dispose: () => host.remove()
Expand Down Expand Up @@ -3359,7 +3428,7 @@ window.__ModuleLoader__.load({
const TRANSPARENT_IMAGE_PLACEHOLDER = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
async function renderShareImage(element) {
const blob = await (0, import_lib.toBlob)(element, {
backgroundColor: getComputedStyle(element).backgroundColor,
backgroundColor: shareExportBackground(element),
imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER,
pixelRatio: 2,
skipFonts: true
Expand Down
9 changes: 9 additions & 0 deletions lib/types/client/background.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/** 无主题或亮色时的不透明分享图底色,对应卡片 `var()` 兜底 `#f7f8fa`。 */
export declare const SHARE_LIGHT_BACKGROUND = "rgb(247, 248, 250)";
/** 官方暗色 `--dsw-alias-bg-base` 的实色,避免皮肤把 token 设成 transparent。 */
export declare const SHARE_DARK_BACKGROUND = "rgb(21, 21, 23)";
export declare function shareFallbackBackground(document: Document): string;
/** 把计算色压成不透明实色。透明走主题兜底;半透明叠在兜底之上。无法解析的颜色原样返回。 */
export declare function flattenOpaqueBackground(color: string, fallback: string): string;
export declare function shareExportBackground(element: HTMLElement): string;
//# sourceMappingURL=background.d.ts.map
1 change: 1 addition & 0 deletions lib/types/client/background.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/types/client/card.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion lib/types/client/preview-dialog.d.ts.map

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions src/client/background.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/** 无主题或亮色时的不透明分享图底色,对应卡片 `var()` 兜底 `#f7f8fa`。 */
export const SHARE_LIGHT_BACKGROUND = 'rgb(247, 248, 250)'
/** 官方暗色 `--dsw-alias-bg-base` 的实色,避免皮肤把 token 设成 transparent。 */
export const SHARE_DARK_BACKGROUND = 'rgb(21, 21, 23)'

export function shareFallbackBackground(document: Document): string {
return document.body.hasAttribute('data-ds-dark-theme')
? SHARE_DARK_BACKGROUND
: SHARE_LIGHT_BACKGROUND
}

interface CssColor {
r: number
g: number
b: number
a: number
}

function clampByte(value: number): number {
return Math.min(255, Math.max(0, Math.round(value)))
}

function parseAlpha(raw: string): number {
if (raw.endsWith('%')) return Math.min(1, Math.max(0, Number(raw.slice(0, -1)) / 100))
const value = Number(raw)
if (!Number.isFinite(value)) return 1
return Math.min(1, Math.max(0, value))
}

function parseCssColor(color: string): CssColor | null {
const value = color.trim().toLowerCase()
if (!value || value === 'transparent' || value === 'none') {
return { r: 0, g: 0, b: 0, a: 0 }
}

const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/)
if (hex) {
const digits = hex[1].length === 3
? hex[1].split('').map(digit => digit + digit).join('')
: hex[1]
return {
r: Number.parseInt(digits.slice(0, 2), 16),
g: Number.parseInt(digits.slice(2, 4), 16),
b: Number.parseInt(digits.slice(4, 6), 16),
a: 1,
}
}

const comma = value.match(
/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+%?))?\s*\)$/,
)
if (comma) {
return {
r: Number(comma[1]),
g: Number(comma[2]),
b: Number(comma[3]),
a: comma[4] === undefined ? 1 : parseAlpha(comma[4]),
}
}

const space = value.match(
/^rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+%?))?\s*\)$/,
)
if (space) {
return {
r: Number(space[1]),
g: Number(space[2]),
b: Number(space[3]),
a: space[4] === undefined ? 1 : parseAlpha(space[4]),
}
}

return null
}

/** 把计算色压成不透明实色。透明走主题兜底;半透明叠在兜底之上。无法解析的颜色原样返回。 */
export function flattenOpaqueBackground(color: string, fallback: string): string {
const parsed = parseCssColor(color)
if (!parsed) {
const normalized = color.trim().toLowerCase()
return !normalized || normalized === 'transparent' ? fallback : color
Comment thread
hellodigua marked this conversation as resolved.
}
if (parsed.a <= 0) return fallback
if (parsed.a >= 1) return `rgb(${clampByte(parsed.r)}, ${clampByte(parsed.g)}, ${clampByte(parsed.b)})`

const base = parseCssColor(fallback) ?? parseCssColor(SHARE_LIGHT_BACKGROUND)!
const alpha = parsed.a
return `rgb(${clampByte(parsed.r * alpha + base.r * (1 - alpha))}, ${clampByte(parsed.g * alpha + base.g * (1 - alpha))}, ${clampByte(parsed.b * alpha + base.b * (1 - alpha))})`
}

export function shareExportBackground(element: HTMLElement): string {
const view = element.ownerDocument.defaultView
const computed = view?.getComputedStyle(element).backgroundColor ?? ''
return flattenOpaqueBackground(computed, shareFallbackBackground(element.ownerDocument))
}
3 changes: 3 additions & 0 deletions src/client/card.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { shareExportBackground } from './background.ts'
import type { ShareMessage } from './content.ts'
import {
DEFAULT_SHARE_SETTINGS,
Expand Down Expand Up @@ -261,6 +262,8 @@ export function createShareCard(

host.append(card)
document.body.append(host)
// 皮肤可能把 --dsw-alias-bg-base 设成 transparent;分享图需要不透明底色。
card.style.backgroundColor = shareExportBackground(card)

return {
element: card,
Expand Down
3 changes: 2 additions & 1 deletion src/client/preview-dialog.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toBlob } from 'html-to-image'
import { shareExportBackground } from './background.ts'
import { t, type ShareLocale, type Translation } from './i18n.ts'
import {
loadShareSettings,
Expand All @@ -16,7 +17,7 @@ export type ImageRenderer = (element: HTMLElement) => Promise<Blob>

export async function renderShareImage(element: HTMLElement): Promise<Blob> {
const blob = await toBlob(element, {
backgroundColor: getComputedStyle(element).backgroundColor,
backgroundColor: shareExportBackground(element),
imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER,
pixelRatio: 2,
skipFonts: true,
Expand Down
43 changes: 43 additions & 0 deletions tests/background.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it } from 'vitest'
import {
SHARE_DARK_BACKGROUND,
SHARE_LIGHT_BACKGROUND,
flattenOpaqueBackground,
shareExportBackground,
shareFallbackBackground,
} from '../src/client/background.ts'

afterEach(() => {
document.body.innerHTML = ''
document.body.removeAttribute('data-ds-dark-theme')
})

describe('分享图底色', () => {
it('把透明和缺省色换成不透明兜底,半透明叠在兜底上', () => {
expect(flattenOpaqueBackground('transparent', SHARE_LIGHT_BACKGROUND)).toBe(SHARE_LIGHT_BACKGROUND)
expect(flattenOpaqueBackground('rgba(0, 0, 0, 0)', SHARE_DARK_BACKGROUND)).toBe(SHARE_DARK_BACKGROUND)
expect(flattenOpaqueBackground('rgb(255, 255, 255)', SHARE_LIGHT_BACKGROUND)).toBe('rgb(255, 255, 255)')
expect(flattenOpaqueBackground('#f7f8fa', SHARE_DARK_BACKGROUND)).toBe(SHARE_LIGHT_BACKGROUND)
expect(flattenOpaqueBackground('rgba(13, 16, 30, 0.86)', SHARE_DARK_BACKGROUND)).toBe('rgb(14, 17, 29)')
expect(flattenOpaqueBackground('color(srgb 1 1 1)', SHARE_LIGHT_BACKGROUND)).toBe('color(srgb 1 1 1)')
})

it('亮色和暗色主题使用不同的不透明兜底', () => {
expect(shareFallbackBackground(document)).toBe(SHARE_LIGHT_BACKGROUND)
document.body.setAttribute('data-ds-dark-theme', '')
expect(shareFallbackBackground(document)).toBe(SHARE_DARK_BACKGROUND)
})

it('从元素计算样式得到不透明导出底色', () => {
const element = document.createElement('div')
element.style.backgroundColor = 'transparent'
document.body.append(element)
expect(shareExportBackground(element)).toBe(SHARE_LIGHT_BACKGROUND)

document.body.setAttribute('data-ds-dark-theme', '')
element.style.backgroundColor = 'rgba(0, 0, 0, 0)'
expect(shareExportBackground(element)).toBe(SHARE_DARK_BACKGROUND)
})
})
18 changes: 18 additions & 0 deletions tests/card.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it } from 'vitest'
import { SHARE_DARK_BACKGROUND, SHARE_LIGHT_BACKGROUND } from '../src/client/background.ts'
import { createShareCard } from '../src/client/card.ts'
import type { ShareMessage } from '../src/client/content.ts'

Expand All @@ -17,6 +18,7 @@ function pair(

afterEach(() => {
document.body.innerHTML = ''
document.body.removeAttribute('data-ds-dark-theme')
})

describe('分享图片卡片', () => {
Expand Down Expand Up @@ -207,4 +209,20 @@ describe('分享图片卡片', () => {
.toBe('rgb(250, 250, 250)')
card.dispose()
})

it('卡片导出底色始终是不透明实色', () => {
const prompt = document.createElement('div')
prompt.textContent = '问题'
const answer = document.createElement('div')
answer.textContent = '回答'

const light = createShareCard(document, pair(prompt, [answer]), 'zh')
expect(light.element.style.backgroundColor).toBe(SHARE_LIGHT_BACKGROUND)
light.dispose()

document.body.setAttribute('data-ds-dark-theme', '')
const dark = createShareCard(document, pair(prompt, [answer]), 'zh')
expect(dark.element.style.backgroundColor).toBe(SHARE_DARK_BACKGROUND)
dark.dispose()
})
})
48 changes: 48 additions & 0 deletions tests/preview-dialog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from 'vitest'
import { SHARE_DARK_BACKGROUND, SHARE_LIGHT_BACKGROUND } from '../src/client/background.ts'

const toBlob = vi.hoisted(() => vi.fn())

vi.mock('html-to-image', () => ({ toBlob }))

afterEach(() => {
document.body.innerHTML = ''
document.body.removeAttribute('data-ds-dark-theme')
toBlob.mockReset()
})

describe('分享图渲染', () => {
it('把透明计算底色换成不透明颜色再交给 html-to-image', async () => {
toBlob.mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
const { renderShareImage } = await import('../src/client/preview-dialog.ts')
const element = document.createElement('article')
element.style.backgroundColor = 'transparent'
document.body.append(element)

await renderShareImage(element)

expect(toBlob).toHaveBeenCalledTimes(1)
expect(toBlob.mock.calls[0]?.[1]).toMatchObject({
backgroundColor: SHARE_LIGHT_BACKGROUND,
pixelRatio: 2,
skipFonts: true,
})
})

it('暗色主题下透明底色改用官方暗色实色', async () => {
toBlob.mockResolvedValue(new Blob(['png'], { type: 'image/png' }))
document.body.setAttribute('data-ds-dark-theme', '')
const { renderShareImage } = await import('../src/client/preview-dialog.ts')
const element = document.createElement('article')
element.style.backgroundColor = 'rgba(0, 0, 0, 0)'
document.body.append(element)

await renderShareImage(element)

expect(toBlob.mock.calls[0]?.[1]).toMatchObject({
backgroundColor: SHARE_DARK_BACKGROUND,
})
})
})
Loading