Files
Genarrative/src/components/common/publishShareCardImage.ts
T
kdletters 910625d5e1 接入原生壳分享卡图片导出
新增 file.exportImage 宿主能力契约

分享卡下载在原生壳中优先走宿主图片导出

Expo 壳写入缓存图片并调用系统分享保存

Tauri 壳通过保存对话框写入图片字节

补齐能力漂移检查、测试和架构文档
2026-06-18 01:31:28 +08:00

436 lines
11 KiB
TypeScript

import {
readAssetBytes,
shouldResolveAssetReadUrl,
} from '../../services/assetReadUrlService';
import { exportHostImageFile } from '../../services/host-bridge/hostBridge';
import {
buildPublishShareCardFileName,
type PublishShareModalPayload,
} from './publishShareModalModel';
const CARD_WIDTH = 1080;
const CARD_HEIGHT = 1440;
const CARD_RADIUS = 24;
const COVER_X = 0;
const COVER_Y = 0;
const COVER_SIZE = CARD_WIDTH;
const CONTENT_PADDING_X = 48;
const CONTENT_TOP = COVER_Y + COVER_SIZE + 48;
const TYPE_PILL_HEIGHT = 64;
type PublishShareCardTheme = {
background: string;
border: string;
neutralBackground: string;
accentText: string;
titleText: string;
mutedText: string;
};
function resolveCssColor(variableName: string, fallback: string) {
if (typeof document === 'undefined') {
return fallback;
}
const value = getComputedStyle(document.documentElement)
.getPropertyValue(variableName)
.trim();
return value || fallback;
}
function resolvePublishShareCardTheme(): PublishShareCardTheme {
return {
background: '#fffaf4',
border: resolveCssColor('--platform-subpanel-border', '#ead9c7'),
neutralBackground: resolveCssColor('--platform-neutral-bg', '#f2e3d5'),
accentText: resolveCssColor('--platform-accent-text', '#7f5539'),
titleText: resolveCssColor('--platform-text-strong', '#332820'),
mutedText: resolveCssColor('--platform-text-muted', '#a88e7c'),
};
}
function drawRoundedRect(
context: CanvasRenderingContext2D,
x: number,
y: number,
width: number,
height: number,
radius: number,
) {
context.beginPath();
context.moveTo(x + radius, y);
context.lineTo(x + width - radius, y);
context.quadraticCurveTo(x + width, y, x + width, y + radius);
context.lineTo(x + width, y + height - radius);
context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
context.lineTo(x + radius, y + height);
context.quadraticCurveTo(x, y + height, x, y + height - radius);
context.lineTo(x, y + radius);
context.quadraticCurveTo(x, y, x + radius, y);
context.closePath();
}
function drawWrappedText(
context: CanvasRenderingContext2D,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number,
maxLines: number,
) {
const chars = Array.from(text.trim() || '我的作品');
const lines: string[] = [];
let currentLine = '';
for (const char of chars) {
const nextLine = `${currentLine}${char}`;
if (currentLine && context.measureText(nextLine).width > maxWidth) {
lines.push(currentLine);
currentLine = char;
if (lines.length >= maxLines) {
break;
}
} else {
currentLine = nextLine;
}
}
if (lines.length < maxLines && currentLine) {
lines.push(currentLine);
}
lines.slice(0, maxLines).forEach((line, index) => {
const isLast = index === maxLines - 1 && lines.length >= maxLines;
let displayLine = line;
while (
isLast &&
displayLine.length > 1 &&
context.measureText(`${displayLine}...`).width > maxWidth
) {
displayLine = displayLine.slice(0, -1);
}
context.fillText(isLast ? `${displayLine}...` : displayLine, x, y + index * lineHeight);
});
}
function shouldLoadImageWithAnonymousCors(src: string) {
if (
src.startsWith('data:') ||
src.startsWith('blob:') ||
typeof window === 'undefined'
) {
return false;
}
try {
const parsedUrl = new URL(src, window.location.origin);
return (
/^https?:$/u.test(parsedUrl.protocol) &&
parsedUrl.origin !== window.location.origin
);
} catch {
return false;
}
}
export async function resolvePublishShareCardCanvasImageSource(src: string) {
const normalizedSrc = src.trim();
if (!normalizedSrc) {
return {
src: '',
release() {},
};
}
if (!shouldResolveAssetReadUrl(normalizedSrc)) {
return {
src: normalizedSrc,
release() {},
};
}
const response = await readAssetBytes(normalizedSrc, {
expireSeconds: 600,
});
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
return {
src: objectUrl,
release() {
URL.revokeObjectURL(objectUrl);
},
};
}
function loadCanvasImage(src: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
if (shouldLoadImageWithAnonymousCors(src)) {
image.crossOrigin = 'anonymous';
}
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('分享卡封面加载失败'));
image.src = src;
});
}
function drawImageCover(
context: CanvasRenderingContext2D,
image: HTMLImageElement,
x: number,
y: number,
width: number,
height: number,
) {
const sourceWidth = image.naturalWidth || image.width;
const sourceHeight = image.naturalHeight || image.height;
const sourceRatio = sourceWidth / Math.max(1, sourceHeight);
const targetRatio = width / Math.max(1, height);
const cropWidth = sourceRatio > targetRatio ? sourceHeight * targetRatio : sourceWidth;
const cropHeight = sourceRatio > targetRatio ? sourceHeight : sourceWidth / targetRatio;
const cropX = (sourceWidth - cropWidth) / 2;
const cropY = (sourceHeight - cropHeight) / 2;
context.drawImage(
image,
cropX,
cropY,
cropWidth,
cropHeight,
x,
y,
width,
height,
);
}
function drawCoverFallback(
context: CanvasRenderingContext2D,
payload: PublishShareModalPayload,
) {
const gradient = context.createLinearGradient(
COVER_X,
COVER_Y,
COVER_X + COVER_SIZE,
COVER_Y + COVER_SIZE,
);
gradient.addColorStop(0, '#f6c58d');
gradient.addColorStop(0.48, '#e7b7b7');
gradient.addColorStop(1, '#9bbfd1');
context.fillStyle = gradient;
context.fillRect(COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
context.fillStyle = 'rgba(255, 255, 255, 0.82)';
context.font = '900 156px sans-serif';
context.textAlign = 'center';
context.textBaseline = 'middle';
const initial = Array.from(payload.title.trim() || '陶')[0] ?? '陶';
context.fillText(initial, CARD_WIDTH / 2, COVER_Y + COVER_SIZE / 2);
}
function drawCopyIcon(
context: CanvasRenderingContext2D,
x: number,
y: number,
color: string,
) {
context.strokeStyle = color;
context.lineWidth = 4;
drawRoundedRect(context, x + 10, y, 24, 30, 4);
context.stroke();
drawRoundedRect(context, x, y + 10, 24, 30, 4);
context.stroke();
}
async function drawShareCard(
context: CanvasRenderingContext2D,
payload: PublishShareModalPayload,
coverImageSrc: string,
) {
const theme = resolvePublishShareCardTheme();
context.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
context.save();
drawRoundedRect(context, 0, 0, CARD_WIDTH, CARD_HEIGHT, CARD_RADIUS);
context.clip();
context.fillStyle = theme.background;
context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
if (coverImageSrc) {
const canvasImageSource =
await resolvePublishShareCardCanvasImageSource(coverImageSrc);
try {
const image = await loadCanvasImage(canvasImageSource.src);
drawImageCover(context, image, COVER_X, COVER_Y, COVER_SIZE, COVER_SIZE);
} finally {
canvasImageSource.release();
}
} else {
drawCoverFallback(context, payload);
}
const typeLabel = payload.workTypeLabel?.trim() || '互动作品';
context.font = '800 36px sans-serif';
const pillWidth = Math.min(
CARD_WIDTH - CONTENT_PADDING_X * 2,
Math.max(180, context.measureText(typeLabel).width + 72),
);
const pillY = CONTENT_TOP;
context.fillStyle = theme.neutralBackground;
drawRoundedRect(
context,
CONTENT_PADDING_X,
pillY,
pillWidth,
TYPE_PILL_HEIGHT,
TYPE_PILL_HEIGHT / 2,
);
context.fill();
context.fillStyle = theme.accentText;
context.textAlign = 'left';
context.textBaseline = 'middle';
context.fillText(typeLabel, CONTENT_PADDING_X + 36, pillY + TYPE_PILL_HEIGHT / 2);
context.fillStyle = theme.titleText;
context.font = '900 72px sans-serif';
context.textBaseline = 'top';
drawWrappedText(
context,
payload.title,
CONTENT_PADDING_X,
pillY + 92,
CARD_WIDTH - CONTENT_PADDING_X * 2,
84,
2,
);
const code = payload.publicWorkCode.trim();
if (code) {
const codeY = CARD_HEIGHT - 74;
context.fillStyle = theme.mutedText;
context.font = '700 34px sans-serif';
context.textBaseline = 'middle';
drawCopyIcon(context, CONTENT_PADDING_X, codeY - 20, theme.mutedText);
context.fillText(code, CONTENT_PADDING_X + 54, codeY);
}
context.restore();
context.strokeStyle = theme.border;
context.lineWidth = 3;
drawRoundedRect(context, 1.5, 1.5, CARD_WIDTH - 3, CARD_HEIGHT - 3, CARD_RADIUS);
context.stroke();
}
function canvasToBlob(canvas: HTMLCanvasElement) {
return new Promise<Blob>((resolve, reject) => {
if (typeof canvas.toBlob !== 'function') {
try {
const dataUrl = canvas.toDataURL('image/png');
const binary = atob(dataUrl.split(',')[1] ?? '');
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
resolve(new Blob([bytes], { type: 'image/png' }));
} catch (error) {
reject(error);
}
return;
}
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('分享卡导出失败'));
}
}, 'image/png');
});
}
function blobToBase64Data(blob: Blob) {
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = typeof reader.result === 'string' ? reader.result : '';
const base64Data = result.split(',')[1] ?? '';
if (base64Data) {
resolve(base64Data);
} else {
reject(new Error('分享卡图片编码失败'));
}
};
reader.onerror = () => reject(new Error('分享卡图片编码失败'));
reader.readAsDataURL(blob);
});
}
async function exportShareCardBlob(blob: Blob, fileName: string) {
const exported = await exportHostImageFile({
fileName,
base64Data: await blobToBase64Data(blob),
mimeType: 'image/png',
});
if (exported) {
return;
}
triggerDownload(blob, fileName);
}
async function renderShareCardBlob(
payload: PublishShareModalPayload,
coverImageSrc: string,
) {
const canvas = document.createElement('canvas');
canvas.width = CARD_WIDTH;
canvas.height = CARD_HEIGHT;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
await drawShareCard(context, payload, coverImageSrc);
return await canvasToBlob(canvas);
}
function triggerDownload(blob: Blob, fileName: string) {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}
export async function downloadPublishShareCardImage(
payload: PublishShareModalPayload,
coverImageSrc: string,
) {
if (typeof document === 'undefined') {
return false;
}
let blob: Blob | null = null;
try {
blob = await renderShareCardBlob(payload, coverImageSrc);
} catch {
blob = await renderShareCardBlob(payload, '');
}
if (!blob) {
return false;
}
await exportShareCardBlob(blob, buildPublishShareCardFileName(payload));
return true;
}