1. 修复海报图片

This commit is contained in:
李琦
2026-08-03 15:26:51 +08:00
parent c70e76d109
commit 8543d0bb25
4 changed files with 171 additions and 19 deletions

View File

@@ -0,0 +1,46 @@
/**
* 在 canvas 上绘制圆内「囍」印章。
* 用位图避免 html2canvas 对 CJK + transform/flex 居中错位。
*/
export function drawXiSeal(canvas, {
size = 28,
fontSize = 13,
color = '#d4af37',
borderColor = '#d4af37',
fill = 'rgba(0, 0, 0, 0.15)',
dpr = Math.min(3, window.devicePixelRatio || 2),
} = {}) {
if (!canvas) return
const s = Math.max(12, Number(size) || 28)
const fs = Math.max(8, Number(fontSize) || Math.round(s * 0.46))
const ratio = Math.max(1, Number(dpr) || 2)
canvas.width = Math.round(s * ratio)
canvas.height = Math.round(s * ratio)
canvas.style.width = `${s}px`
canvas.style.height = `${s}px`
const ctx = canvas.getContext('2d')
if (!ctx) return
ctx.setTransform(ratio, 0, 0, ratio, 0, 0)
ctx.clearRect(0, 0, s, s)
const cx = s / 2
const cy = s / 2
const r = s / 2 - 0.75
ctx.beginPath()
ctx.arc(cx, cy, r, 0, Math.PI * 2)
ctx.fillStyle = fill
ctx.fill()
ctx.lineWidth = 1
ctx.strokeStyle = borderColor
ctx.stroke()
ctx.fillStyle = color
ctx.font = `700 ${fs}px "Noto Serif SC", "Source Han Serif SC", "Songti SC", "STSong", "SimSun", serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
// 轻微上移做视觉光学居中(非 CSS transform导出安全
ctx.fillText('囍', cx, cy + fs * 0.04)
}

View File

@@ -129,6 +129,7 @@ import html2canvas from 'html2canvas'
import { getConfig, getPosterConfig } from '@/api/wedding'
import { normalizePosterConfig, posterSideMeta, resolvePosterSide } from '@/composables/posterDefaults'
import { renderFestiveQr } from '@/composables/renderFestiveQr'
import { drawXiSeal } from '@/composables/drawXiSeal'
import { useAudio } from '@/composables/useAudio'
import ClassicPoster from '@/views/hb/templates/ClassicPoster.vue'
import SplitPoster from '@/views/hb/templates/SplitPoster.vue'
@@ -375,6 +376,18 @@ async function savePosterImage() {
try {
cleanupBake = await bakeObjectFitImages(el)
cleanupQr = await mountCaptureQr(el)
// 导出前重绘囍印章位图,避免 html2canvas 对 CJK 文本错位
try { await document.fonts?.ready } catch { /* ignore */ }
el.querySelectorAll('canvas.sp-xi-mid, canvas.hb-xi-circle').forEach((c) => {
const isClassic = c.classList.contains('hb-xi-circle')
drawXiSeal(c, {
size: isClassic ? 22 : 28,
fontSize: isClassic ? 11 : 13,
color: '#d4af37',
borderColor: '#d4af37',
fill: isClassic ? 'rgba(155, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.18)',
})
})
// 按元素实际宽度抬升倍率:目标约 1600px 宽,最高 3x避免手机导出糊成一片
const baseW = Math.max(1, el.getBoundingClientRect().width || el.offsetWidth || 390)
const scale = Math.min(3, Math.max(2.75, 1600 / baseW))

View File

@@ -61,7 +61,7 @@
class-name="hb-name calligraphy"
@animation-complete="namesRightReady = true"
/>
<span class="hb-xi-circle"></span>
<canvas ref="xiSealRef" class="hb-xi-circle" width="44" height="44" aria-hidden="true"></canvas>
<BlurText
v-if="namesRightReady"
:text="nameRight"
@@ -141,10 +141,11 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
import { heroFocusCss } from '@/composables/posterDefaults'
import { drawXiSeal } from '@/composables/drawXiSeal'
const props = defineProps({
cfg: { type: Object, required: true },
@@ -155,12 +156,24 @@ defineEmits(['invite'])
const stage = ref(0)
const lineStage = ref(0)
const namesRightReady = ref(false)
const xiSealRef = ref(null)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
const nameLeft = computed(() => `${props.cfg.sonLabel || ''}${props.cfg.sonName || ''}`)
const nameRight = computed(() => `${props.cfg.daughterInLawLabel || ''}${props.cfg.daughterInLawName || ''}`)
async function paintXiSeal() {
try { await document.fonts?.ready } catch { /* ignore */ }
drawXiSeal(xiSealRef.value, {
size: 22,
fontSize: 11,
color: '#d4af37',
borderColor: '#d4af37',
fill: 'rgba(155, 0, 0, 0.5)',
})
}
function later(fn, ms) {
const id = setTimeout(fn, ms)
timers.push(id)
@@ -194,6 +207,17 @@ watch(
},
{ immediate: true },
)
onMounted(() => {
nextTick(paintXiSeal)
window.addEventListener('resize', paintXiSeal, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', paintXiSeal)
timers.forEach(clearTimeout)
timers = []
})
</script>
<style scoped>
@@ -326,15 +350,38 @@ watch(
.hb-xi-l { left: 8%; }
.hb-xi-r { right: 8%; }
.hb-names {
width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
min-height: 28px; margin-bottom: 8px; opacity: 0; transition: opacity 0.4s ease;
position: relative;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
min-height: 28px;
margin-bottom: 8px;
opacity: 0;
transition: opacity 0.4s ease;
}
.hb-names.is-show { opacity: 1; }
.hb-name { color: #f7efe0 !important; font-size: 12px; letter-spacing: 0.12em; justify-content: center; }
.hb-name {
flex: 1 1 0;
min-width: 0;
color: #f7efe0 !important;
font-size: 12px;
letter-spacing: 0.12em;
justify-content: center;
}
.hb-name:first-child { padding-right: 18px; justify-content: flex-end; }
.hb-name:last-child { padding-left: 18px; justify-content: flex-start; }
.hb-xi-circle {
width: 22px; height: 22px; border-radius: 50%; border: 1px solid var(--hb-gold);
color: var(--hb-gold); font-size: 11px; display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0; background: rgba(155, 0, 0, 0.5);
position: absolute;
left: 50%;
top: 50%;
width: 22px;
height: 22px;
margin-left: -11px;
margin-top: -11px;
display: block;
pointer-events: none;
z-index: 2;
}
.hb-title-wrap { margin: 4px 0 12px; min-height: 48px; opacity: 0; transition: opacity 0.35s ease; }
.hb-title-wrap.is-show { opacity: 1; }

View File

@@ -79,12 +79,12 @@
</div>
<div class="sp-couple" :class="{ 'is-show': stage >= 5 }">
<div class="sp-person">
<div class="sp-person sp-person-l">
<span class="sp-role calligraphy">新郎</span>
<BlurText v-if="stage >= 5" :text="groom" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" />
</div>
<span class="sp-xi-mid"></span>
<div class="sp-person">
<canvas ref="xiSealRef" class="sp-xi-mid" width="56" height="56" aria-hidden="true"></canvas>
<div class="sp-person sp-person-r">
<span class="sp-role calligraphy">新娘</span>
<BlurText v-if="stage >= 5" :text="bride" :active="true" animate-by="letters" :delay="40" class-name="sp-name calligraphy" @animation-complete="bumpStage(6)" />
</div>
@@ -118,10 +118,11 @@
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import BlurText from '@/components/vue-bits/BlurText.vue'
import TextType from '@/components/vue-bits/TextType.vue'
import { heroFocusCss } from '@/composables/posterDefaults'
import { drawXiSeal } from '@/composables/drawXiSeal'
const props = defineProps({
cfg: { type: Object, required: true },
@@ -131,6 +132,7 @@ defineEmits(['invite'])
const stage = ref(0)
const lineStage = ref(0)
const xiSealRef = ref(null)
let timers = []
const heroObjectPosition = computed(() => heroFocusCss(props.cfg))
@@ -147,6 +149,17 @@ const bodyLines = computed(() => {
const groom = computed(() => props.cfg.groomName || props.cfg.sonName || '')
const bride = computed(() => props.cfg.brideName || props.cfg.daughterInLawName || '')
async function paintXiSeal() {
try { await document.fonts?.ready } catch { /* ignore */ }
drawXiSeal(xiSealRef.value, {
size: 28,
fontSize: 13,
color: '#d4af37',
borderColor: '#d4af37',
fill: 'rgba(0, 0, 0, 0.18)',
})
}
function later(fn, ms) {
timers.push(setTimeout(fn, ms))
}
@@ -179,6 +192,17 @@ watch(
},
{ immediate: true },
)
onMounted(() => {
nextTick(paintXiSeal)
window.addEventListener('resize', paintXiSeal, { passive: true })
})
onBeforeUnmount(() => {
window.removeEventListener('resize', paintXiSeal)
timers.forEach(clearTimeout)
timers = []
})
</script>
<style scoped>
@@ -335,18 +359,40 @@ watch(
.sp-cursor { color: var(--hb-gold); }
.sp-couple {
position: relative; z-index: 1;
display: flex; align-items: center; justify-content: center; gap: 14px;
margin: 6px 0 14px; opacity: 0; transition: opacity 0.4s ease;
position: relative;
z-index: 1;
display: flex;
align-items: center;
width: 100%;
margin: 6px 0 14px;
opacity: 0;
transition: opacity 0.4s ease;
}
.sp-couple.is-show { opacity: 1; }
.sp-person { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.sp-person {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.sp-person-l { padding-right: 22px; }
.sp-person-r { padding-left: 22px; }
.sp-role { font-size: 11px; color: rgba(247, 239, 224, 0.7); letter-spacing: 0.3em; }
.sp-name { color: var(--hb-gold-soft) !important; font-size: 18px; letter-spacing: 0.2em; justify-content: center; }
/* canvas 位图印章:不用 CSS transform避免 html2canvas 错位 */
.sp-xi-mid {
width: 28px; height: 28px; border-radius: 50%; border: 1px solid var(--hb-gold);
display: inline-flex; align-items: center; justify-content: center;
font-size: 13px; color: var(--hb-gold); background: rgba(0, 0, 0, 0.15);
position: absolute;
left: 50%;
top: 50%;
width: 28px;
height: 28px;
margin-left: -14px;
margin-top: -14px;
display: block;
pointer-events: none;
z-index: 2;
}
.sp-meta {