diff --git a/backend/apps/exports/utils.py b/backend/apps/exports/utils.py index 2e4df09..cba0f5a 100644 --- a/backend/apps/exports/utils.py +++ b/backend/apps/exports/utils.py @@ -248,16 +248,24 @@ def draw_face_card(canvas, design, suit, rank, project, card_key, asset): new_w = int(body_h * img_ratio) img = img.resize((new_w, new_h), Image.LANCZOS) - # 取上半部分 + 上下翻转的下半部分(取上半 = 下半 = 整个图按上下中线翻转) + # 取上半部分 + 下半部分 top = img.crop((0, 0, img.width, img.height // 2)) - bot = img.crop((0, img.height // 2, img.width, img.height)).transpose(Image.FLIP_TOP_BOTTOM) + bot_src = img.crop((0, img.height // 2, img.width, img.height)) + # symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转 + sym_mode = design.get('symmetry_mode', 'flip') + if sym_mode == 'rotate': + # 180° 旋转 = 先水平翻转再垂直翻转(PIL 没有一步到位的 180 旋转 transpose) + bot_src = bot_src.transpose(Image.FLIP_LEFT_RIGHT) + bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM) + else: # 'flip'(默认) + bot_src = bot_src.transpose(Image.FLIP_TOP_BOTTOM) # 拼接成 body_h 高的新图 full = Image.new('RGBA', (img.width, body_h), (0, 0, 0, 0)) top = top.resize((img.width, body_h // 2), Image.LANCZOS) - bot = bot.resize((img.width, body_h - body_h // 2), Image.LANCZOS) + bot_src = bot_src.resize((img.width, body_h - body_h // 2), Image.LANCZOS) full.paste(top, (0, 0), top) - full.paste(bot, (0, body_h // 2), bot) + full.paste(bot_src, (0, body_h // 2), bot_src) full = full.resize((body_w, body_h), Image.LANCZOS) canvas.alpha_composite(full, (body_pad_x, body_pad_y_top)) @@ -316,9 +324,14 @@ def draw_joker(canvas, design, which, project, card_key, asset): x = body_pad_x + offset_x + (body_w - img_copy.width) // 2 y_top = body_pad_y_top + offset_y + (half_h - img_copy.height) // 2 canvas.alpha_composite(img_copy, (x, y_top)) - img_flipped = img_copy.transpose(Image.FLIP_TOP_BOTTOM) - y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_flipped.height) // 2 - canvas.alpha_composite(img_flipped, (x, y_bot)) + # symmetry_mode: 'flip' = 垂直翻转;'rotate' = 180° 旋转 + sym_mode = design.get('symmetry_mode', 'flip') + if sym_mode == 'rotate': + img_bot = img_copy.transpose(Image.ROTATE_180) + else: + img_bot = img_copy.transpose(Image.FLIP_TOP_BOTTOM) + y_bot = body_pad_y_top + half_h + offset_y + (half_h - img_bot.height) // 2 + canvas.alpha_composite(img_bot, (x, y_bot)) except Exception: pass else: diff --git a/frontend/src/components/DesignPanel.vue b/frontend/src/components/DesignPanel.vue index 5893966..0460a9f 100644 --- a/frontend/src/components/DesignPanel.vue +++ b/frontend/src/components/DesignPanel.vue @@ -79,6 +79,18 @@ + +
+

对称方式

+

下半身素材的生成方式

+
+ +
+
+

图片位置微调

@@ -181,6 +193,24 @@ const showImageOffset = computed(() => { return isJoker(store.currentCard) || store.currentCard === 'back' }) +const isFaceCard = computed(() => { + if (!store.currentCard || !store.currentCard.includes('-')) return false + const r = store.currentCard.split('-')[1] + return r === 'J' || r === 'Q' || r === 'K' +}) + +const showSymmetryMode = computed(() => { + return isFaceCard.value || isJoker(store.currentCard) +}) + +const symmetryMode = computed(() => design.value.symmetry_mode || 'flip') + +function setSymmetryMode(v) { + // JQK 和大小王都存到 design 里(因为它们是单牌设置) + // 但 design 是合并后的,所以用 patchCardOverride 写到当前牌更干净 + store.patchCardOverride(store.currentCard, 'symmetry_mode', v) +} + function imageOffsetVal(key) { if (!override.value) return key === 'image_scale' ? 1 : 0 const v = Number(override.value[key]) diff --git a/frontend/src/utils/cardRenderer.js b/frontend/src/utils/cardRenderer.js index 90d0d47..aac996c 100644 --- a/frontend/src/utils/cardRenderer.js +++ b/frontend/src/utils/cardRenderer.js @@ -212,27 +212,32 @@ async function drawFaceCardBody(ctx, w, h, suit, rank, design, project) { const drawX = padX + (bodyW - drawW) / 2 const drawY = padTop + (bodyH - drawH) / 2 - // 上下对称:先画上半,再把上半翻转贴到下半 + // 上下对称:先画上半,再把下半用指定方式生成 const halfH = drawH / 2 + // 上半:用 clip 限定为上半 ctx.save() - // 上半(先画完整图,用 clip 限定为上半) ctx.beginPath() ctx.rect(drawX, drawY, drawW, halfH) ctx.clip() ctx.drawImage(img, drawX, drawY, drawW, drawH) ctx.restore() - // 下半:把原图翻转 180° 贴到下半区域 - ctx.save() - ctx.translate(drawX + drawW, drawY + drawH) - ctx.rotate(Math.PI) - // 现在画完整图(经过旋转坐标系),让它 fill bodyW x halfH - const tmpW = drawW - const tmpH = halfH - // 由于旋转,drawImage 时 x/y 是反向的;目标 = (0,0) 到 (drawW, halfH) - // 经过 180° 旋转,相当于在 (drawX, drawY) + (drawW, drawH) 处映射为 (-drawW, -drawH) 到 (0, 0) - // 所以让图片左上角对应 (0,0) 即可 - ctx.drawImage(img, 0, 0, tmpW, tmpH * 2) // 0,0 -> drawW,halfH (after 180 rotation) - ctx.restore() + // 下半:symmetry_mode = 'flip' 垂直翻转,'rotate' 180° 旋转 + const symMode = design.symmetry_mode || 'flip' + if (symMode === 'rotate') { + // 180° 旋转:translate 到 (x+w, y+h) + 旋转 PI + ctx.save() + ctx.translate(drawX + drawW, drawY + drawH) + ctx.rotate(Math.PI) + ctx.drawImage(img, 0, 0, drawW, drawH) // 旋转后图片左上角在 (0,0),覆盖 (-drawW,-drawH)~(0,0) + ctx.restore() + } else { + // 垂直翻转:translate 到 (x, y+halfH) + scale(1, -1) + ctx.save() + ctx.translate(drawX, drawY + halfH) + ctx.scale(1, -1) + ctx.drawImage(img, 0, 0, drawW, halfH) + ctx.restore() + } } else { // 退化:绘制大花色 + 字母 const big = Math.round(h * 0.30) @@ -290,11 +295,21 @@ async function drawJokerBody(ctx, w, h, which, design, project) { const topX = padX + offsetX + (bodyW - finalW) / 2 const topY = padTop + offsetY + (halfH - finalH) / 2 ctx.drawImage(img, topX, topY, finalW, finalH) - ctx.save() - ctx.translate(0, padTop + bodyH) - ctx.scale(1, -1) - ctx.drawImage(img, topX, (halfH - finalH) / 2 - offsetY, finalW, finalH) - ctx.restore() + // symmetry_mode: 'flip' 垂直翻转,'rotate' 180° 旋转 + const symMode = design.symmetry_mode || 'flip' + if (symMode === 'rotate') { + ctx.save() + ctx.translate(topX + finalW, topY + finalH) + ctx.rotate(Math.PI) + ctx.drawImage(img, 0, 0, finalW, finalH) + ctx.restore() + } else { + ctx.save() + ctx.translate(topX, topY + finalH) + ctx.scale(1, -1) + ctx.drawImage(img, 0, 0, finalW, finalH) + ctx.restore() + } } else { // 退化 const big = Math.round(h * 0.25)