diff --git a/backend/apps/exports/utils.py b/backend/apps/exports/utils.py
index eed68d2..471a534 100644
--- a/backend/apps/exports/utils.py
+++ b/backend/apps/exports/utils.py
@@ -32,7 +32,7 @@ LAYOUT_POSITIONS = {
7: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.35), (0.30, 0.55), (0.70, 0.55), (0.30, 0.85), (0.70, 0.85)],
8: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.32), (0.30, 0.50), (0.70, 0.50), (0.50, 0.68), (0.30, 0.85), (0.70, 0.85)],
9: [(0.30, 0.15), (0.70, 0.15), (0.50, 0.30), (0.22, 0.50), (0.50, 0.50), (0.78, 0.50), (0.50, 0.70), (0.30, 0.85), (0.70, 0.85)],
- 10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.50), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)],
+ 10: [(0.30, 0.15), (0.70, 0.15), (0.30, 0.35), (0.70, 0.35), (0.50, 0.45), (0.50, 0.55), (0.30, 0.65), (0.70, 0.65), (0.30, 0.85), (0.70, 0.85)],
}
@@ -300,10 +300,25 @@ def draw_joker(canvas, design, which, project, card_key, asset):
body_h = h - body_pad_y_top - body_pad_y_bot
if asset:
try:
- img = asset.copy()
- img.thumbnail((body_w, body_h), Image.LANCZOS)
- canvas.alpha_composite(img, (body_pad_x + (body_w - img.width) // 2,
- body_pad_y_top + (body_h - img.height) // 2))
+ half_h = body_h // 2
+ image_dx = float(design.get('image_dx', 0))
+ image_dy = float(design.get('image_dy', 0))
+ image_scale = float(design.get('image_scale', 1))
+ offset_x = int(body_w * image_dx)
+ offset_y = int(body_h * image_dy)
+
+ img_copy = asset.copy()
+ if image_scale != 1:
+ sw = max(1, int(img_copy.width * image_scale))
+ sh = max(1, int(img_copy.height * image_scale))
+ img_copy = img_copy.resize((sw, sh), Image.LANCZOS)
+ img_copy.thumbnail((body_w, half_h), Image.LANCZOS)
+ 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))
except Exception:
pass
else:
@@ -339,6 +354,44 @@ def draw_joker(canvas, design, which, project, card_key, asset):
canvas.alpha_composite(block, (w - tw - 4 - pad, h - 2 * th - 6 - pad))
+def draw_back(canvas, design, asset):
+ """绘制背面:素材图 + 位置微调,无素材时退化为文字"""
+ w, h = canvas.size
+ body_pad_x = int(w * 0.15)
+ body_pad_y_top = int(h * 0.18)
+ body_pad_y_bot = int(h * 0.22)
+ body_w = w - 2 * body_pad_x
+ body_h = h - body_pad_y_top - body_pad_y_bot
+
+ image_dx = float(design.get('image_dx', 0))
+ image_dy = float(design.get('image_dy', 0))
+ image_scale = float(design.get('image_scale', 1))
+ offset_x = int(body_w * image_dx)
+ offset_y = int(body_h * image_dy)
+
+ if asset:
+ try:
+ img = asset.copy()
+ if image_scale != 1:
+ sw = max(1, int(img.width * image_scale))
+ sh = max(1, int(img.height * image_scale))
+ img = img.resize((sw, sh), Image.LANCZOS)
+ img.thumbnail((body_w, body_h), Image.LANCZOS)
+ x = body_pad_x + offset_x + (body_w - img.width) // 2
+ y = body_pad_y_top + offset_y + (body_h - img.height) // 2
+ canvas.alpha_composite(img, (x, y))
+ except Exception:
+ pass
+ else:
+ draw = ImageDraw.Draw(canvas)
+ fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True)
+ text = 'CARD BACK'
+ color = hex_to_rgba(design.get('border_color', '#333333'), 255)
+ bb = draw.textbbox((0, 0), text, font=fnt)
+ tw, th = bb[2] - bb[0], bb[3] - bb[1]
+ draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color)
+
+
def generate_card_png(project, card_key, resolution='standard'):
"""根据项目配置生成单张牌 PNG"""
scale_map = {'standard': 1, 'hd': 2, 'ultra-hd': 4}
@@ -366,14 +419,12 @@ def generate_card_png(project, card_key, resolution='standard'):
break
draw_joker(canvas, design, which, project, card_key, asset)
elif card_key in ('back', 'card-back'):
- # 简化:背面同整体背景 + 一行文字
- draw = ImageDraw.Draw(canvas)
- fnt = make_text_font('Times New Roman', max(40, int(h * 0.08)), bold=True)
- text = 'CARD BACK'
- color = hex_to_rgba(design.get('border_color', '#333333'), 255)
- bb = draw.textbbox((0, 0), text, font=fnt)
- tw, th = bb[2] - bb[0], bb[3] - bb[1]
- draw.text(((w - tw) // 2, (h - th) // 2), text, font=fnt, fill=color)
+ back_asset = None
+ for a in project.assets.filter(asset_type='back'):
+ p = os.path.join('media', a.file_path) if a.file_path else None
+ back_asset = load_image_safe(p) if p else None
+ break
+ draw_back(canvas, design, back_asset)
else:
# 'suit-rank'
parts = card_key.split('-')
diff --git a/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md b/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md
new file mode 100644
index 0000000..450a6df
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-01-back-image-joker-offset.md
@@ -0,0 +1,75 @@
+# 背面图案渲染 + 大小王素材位置微调
+
+## 背景
+
+当前背面牌(back)虽有 `'back'` 素材类型的上传 UI,但渲染时完全不使用素材,仅显示纯色 + "CARD BACK" 文字。大小王素材已渲染但无位置微调功能。
+
+## 需求
+
+### 1. 背面图案渲染
+- 优先从 `project.assets` 中读取 `asset_type='back'` 的素材图片
+- 有图时:在躯干区域居中绘制(contain 模式等比适配)
+- 无图时:保持现有回退(纯色背景 + "CARD BACK" 文字 + 边框)
+- 支持位置微调
+
+### 2. 大小王素材位置微调
+- 在 DesignPanel 选中大小王时,显示 dx/dy/缩放滑块
+- 数据存储在 `card_overrides[joker-key].image_dx, image_dy, image_scale`
+- `drawJokerBody()` 绘制时应用偏移
+
+## 数据模型
+
+扩展 `card_overrides` JSON:
+
+```json
+{
+ "joker-big": { "image_dx": 0, "image_dy": 0, "image_scale": 1 },
+ "joker-small": { "image_dx": 0, "image_dy": 0, "image_scale": 1 },
+ "back": { "image_dx": 0, "image_dy": 0, "image_scale": 1 }
+}
+```
+
+- `image_dx` / `image_dy`:相对躯体区域的比例偏移,范围 -0.05 ~ +0.05
+- `image_scale`:缩放系数,范围 0.6 ~ 1.4
+
+## 前端实现
+
+### cardRenderer.js
+
+#### `drawBackSide()` 改动
+- 新增 `project` 参数以读取素材和 card_overrides
+- 调用 `assetByType(project, 'back', 'back')` 获取素材
+- 有素材时绘制图片(contain 适配 + 偏移应用)
+- `renderCard()` 调用处传入 `project`
+
+#### `drawJokerBody()` 改动
+- 读取 `override = design.image_dx/dy/scale`(由 `getEffectiveDesign()` 合并后传入的 design 包含 card_overrides 字段)
+- 在图片绘制坐标上叠加 `bodyW * image_dx`、`bodyH * image_dy`
+- 缩放叠加 `drawW * image_scale`、`drawH * image_scale`
+
+### projectStore.js
+- 开放 `canHaveOverride` 覆盖 joker 和 back
+- 添加图片偏移的 patch 方法(复用已有 `patchCardOverride` 或新增)
+
+### DesignPanel.vue
+- 移除 `canHaveOverride` 对 joker/back 的限制
+- 当当前牌为 joker 或 back 时,显示图片位置微调滑块(dx/dy/缩放)
+
+## 后端实现
+
+### exports/utils.py
+
+#### `generate_card_png()` back 分支
+- 查找 `project.assets.filter(asset_type='back')` 获取素材
+- `load_image_safe()` 加载图片
+- `draw_back()` 函数中用 `thumbnail` contain 适配 + 偏移
+
+#### `draw_joker()` 改动
+- 读取 `card_overrides` 的 image_dx/dy/scale
+- 在坐标计算中叠加偏移和缩放
+
+## UI 滑块参数
+- dx: range -0.05 ~ +0.05, step 0.005
+- dy: range -0.05 ~ +0.05, step 0.005
+- scale: range 0.6 ~ 1.4, step 0.05
+(与现有 number_layout 一致)
diff --git a/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md b/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md
new file mode 100644
index 0000000..ec11f49
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-01-joker-symmetry-rendering.md
@@ -0,0 +1,75 @@
+# 大小王对称渲染
+
+## 背景
+
+当前大小王在有素材图片时,将整张图居中适配绘制在躯体区域,无对称处理。需要改为:上半区显示原图,下半区显示原图的垂直镜像,实现类似 JQK 人像的对称效果。
+
+## 需求
+
+- 上传的素材图片作为**上半部分**的源图
+- 躯体区域纵向平分:上半区绘制原图(居中适配),下半区绘制垂直翻转后的同一张图
+- 角标 JOKER + BIG/SMALL 不变
+- 无素材时回退到白字 "JOKER" 居中(不变)
+
+## 修改范围
+
+| 文件 | 函数 |
+|------|------|
+| `frontend/src/utils/cardRenderer.js` | `drawJokerBody()` |
+| `backend/apps/exports/utils.py` | `draw_joker()` |
+
+## 前端实现 (Canvas2D)
+
+在 `drawJokerBody` 中,当有素材图片时:
+
+```js
+// 躯体区域上下平分
+const halfH = bodyH / 2
+
+// 上半区:原图居中适配
+const fit = fitSize(imgW, imgH, bodyW, halfH)
+ctx.drawImage(img, bodyX + (bodyW - fit.w) / 2, bodyY + (halfH - fit.h) / 2, fit.w, fit.h)
+
+// 下半区:垂直翻转后居中适配
+ctx.save()
+ctx.translate(0, bodyY + halfH) // 移动到下半区中线
+ctx.scale(1, -1) // 垂直翻转
+ctx.drawImage(img, bodyX + (bodyW - fit.w) / 2, -(halfH - fit.h) / 2, fit.w, fit.h)
+ctx.restore()
+```
+
+## 后端实现 (PIL)
+
+在 `draw_joker` 中,当有素材图片时:
+
+```python
+half_h = body_h // 2
+
+# 上半区:原图居中适配
+img.thumbnail((body_w, half_h), Image.LANCZOS)
+x1 = body_x + (body_w - img.width) // 2
+y1 = body_y + (half_h - img.height) // 2
+canvas.paste(img, (x1, y1), img)
+
+# 下半区:垂直翻转后居中适配
+img_flipped = img.transpose(Image.FLIP_TOP_BOTTOM)
+y2 = body_y + half_h + (half_h - img_flipped.height) // 2
+canvas.paste(img_flipped, (x1, y2), img_flipped)
+```
+
+Note: `thumbnail` 是原地修改,所以上半区粘贴完成后需要重新加载原图再做翻转,或者先复制一份。
+
+修正:
+```python
+img_copy = img.copy()
+img_copy.thumbnail((body_w, half_h), Image.LANCZOS)
+# 上半区粘贴 img_copy
+img_flipped = img_copy.transpose(Image.FLIP_TOP_BOTTOM)
+# 下半区粘贴 img_flipped
+```
+
+## 注意事项
+
+- 前端当前使用 `Math.max` (fill) 适配策略,改为对称后每个半区内图片尺寸一致
+- 后端当前使用 `thumbnail` (contain) 适配,保持一致即可
+- 躯体 padding 前端 18%/22%,后端 15%/20%,各自保持不变
diff --git a/frontend/src/components/DesignPanel.vue b/frontend/src/components/DesignPanel.vue
index 7c5ce32..5893966 100644
--- a/frontend/src/components/DesignPanel.vue
+++ b/frontend/src/components/DesignPanel.vue
@@ -79,6 +79,24 @@
+
+
+
数字牌花色位置微调
@@ -141,9 +159,7 @@ function setBorder(path, v) { store.patchDesign(path, v) }
function setSuit(suit, path, v) { store.patchDesign(`suit_symbols.${suit}.${path}`, v) }
function setDesign(path, v) { store.patchDesign(path, v) }
-const canHaveOverride = computed(() => {
- return !isJoker(store.currentCard) && store.currentCard !== 'back'
-})
+const canHaveOverride = computed(() => true)
function setOverrideBg() {
if (!canHaveOverride.value) return
store.patchCardOverride(store.currentCard, 'background_color', design.value.background_color)
@@ -161,6 +177,25 @@ const isNumberCard = computed(() => {
return /^[0-9]+$/.test(r) || r === 'A'
})
+const showImageOffset = computed(() => {
+ return isJoker(store.currentCard) || store.currentCard === 'back'
+})
+
+function imageOffsetVal(key) {
+ if (!override.value) return key === 'image_scale' ? 1 : 0
+ const v = Number(override.value[key])
+ if (Number.isNaN(v)) return key === 'image_scale' ? 1 : 0
+ return v
+}
+function setImageOffset(key, val) {
+ store.patchCardOverride(store.currentCard, key, parseFloat(val))
+}
+function resetImageOffset() {
+ store.patchCardOverride(store.currentCard, 'image_dx', 0)
+ store.patchCardOverride(store.currentCard, 'image_dy', 0)
+ store.patchCardOverride(store.currentCard, 'image_scale', 1)
+}
+
const selectedRank = ref(1)
watch(() => store.currentCard, () => {
if (isNumberCard.value) {
diff --git a/frontend/src/utils/cardLayout.js b/frontend/src/utils/cardLayout.js
index 7990aa2..b7ec2bd 100644
--- a/frontend/src/utils/cardLayout.js
+++ b/frontend/src/utils/cardLayout.js
@@ -16,7 +16,7 @@ export const LAYOUT_POSITIONS = {
7: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.35 }, { x: 0.30, y: 0.55 }, { x: 0.70, y: 0.55 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
8: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.32 }, { x: 0.30, y: 0.50 }, { x: 0.70, y: 0.50 }, { x: 0.50, y: 0.68 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
9: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.50, y: 0.30 }, { x: 0.22, y: 0.50 }, { x: 0.50, y: 0.50 }, { x: 0.78, y: 0.50 }, { x: 0.50, y: 0.70 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
- 10: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.30, y: 0.35 }, { x: 0.70, y: 0.35 }, { x: 0.50, y: 0.50 }, { x: 0.30, y: 0.65 }, { x: 0.70, y: 0.65 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
+ 10: [{ x: 0.30, y: 0.15 }, { x: 0.70, y: 0.15 }, { x: 0.30, y: 0.35 }, { x: 0.70, y: 0.35 }, { x: 0.50, y: 0.45 }, { x: 0.50, y: 0.55 }, { x: 0.30, y: 0.65 }, { x: 0.70, y: 0.65 }, { x: 0.30, y: 0.85 }, { x: 0.70, y: 0.85 }],
}
export const SUIT_TEXT = {
diff --git a/frontend/src/utils/cardRenderer.js b/frontend/src/utils/cardRenderer.js
index 18e06ec..756fa9c 100644
--- a/frontend/src/utils/cardRenderer.js
+++ b/frontend/src/utils/cardRenderer.js
@@ -271,15 +271,30 @@ async function drawJokerBody(ctx, w, h, which, design, project) {
}
if (img && img.complete && img.naturalWidth) {
- const ratio = img.naturalWidth / img.naturalHeight
- const target = bodyW / bodyH
+ const halfH = bodyH / 2
+ const imgRatio = img.naturalWidth / img.naturalHeight
+ const target = bodyW / halfH
let drawW, drawH
- if (ratio > target) {
- drawW = bodyW; drawH = bodyW / ratio
+ if (imgRatio > target) {
+ drawW = bodyW; drawH = bodyW / imgRatio
} else {
- drawH = bodyH; drawW = bodyH * ratio
+ drawH = halfH; drawW = halfH * imgRatio
}
- ctx.drawImage(img, padX + (bodyW - drawW) / 2, padTop + (bodyH - drawH) / 2, drawW, drawH)
+ const imageDx = Number(design.image_dx) || 0
+ const imageDy = Number(design.image_dy) || 0
+ const imageScale = Number(design.image_scale) || 1
+ const finalW = Math.max(1, drawW * imageScale)
+ const finalH = Math.max(1, drawH * imageScale)
+ const offsetX = bodyW * imageDx
+ const offsetY = bodyH * imageDy
+ 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()
} else {
// 退化
const big = Math.round(h * 0.25)
@@ -313,21 +328,57 @@ async function drawJokerBody(ctx, w, h, which, design, project) {
ctx.restore()
}
-function drawBackSide(ctx, w, h, design) {
+async function drawBackSide(ctx, w, h, design, project) {
ctx.fillStyle = design.background_color || '#1A237E'
ctx.fillRect(0, 0, w, h)
- ctx.save()
- ctx.strokeStyle = design.border_color || '#FFFFFF'
- ctx.lineWidth = 6
- const m = w * 0.06
- drawRoundedRect(ctx, m, m, w - 2 * m, h - 2 * m, 16)
- ctx.stroke()
- ctx.fillStyle = design.border_color || '#FFFFFF'
- ctx.textAlign = 'center'
- ctx.textBaseline = 'middle'
- ctx.font = `bold ${Math.round(w * 0.1)}px ${design.font_family || 'Times New Roman'}, serif`
- ctx.fillText('CARD BACK', w / 2, h / 2)
- ctx.restore()
+
+ const padX = Math.round(w * 0.15)
+ const padTop = Math.round(h * 0.18)
+ const padBot = Math.round(h * 0.22)
+ const bodyW = w - 2 * padX
+ const bodyH = h - padTop - padBot
+
+ const imageDx = Number(design.image_dx) || 0
+ const imageDy = Number(design.image_dy) || 0
+ const imageScale = Number(design.image_scale) || 1
+ const offsetX = bodyW * imageDx
+ const offsetY = bodyH * imageDy
+
+ const asset = assetByType(project, 'back', 'back')
+ let img = null
+ if (asset?.file_url) {
+ img = imageCache.get(asset.file_url) || null
+ if (img) await loadImage(asset.file_url)
+ }
+
+ if (img && img.complete && img.naturalWidth) {
+ const ratio = img.naturalWidth / img.naturalHeight
+ const target = bodyW / bodyH
+ let drawW, drawH
+ if (ratio > target) {
+ drawW = bodyW; drawH = bodyW / ratio
+ } else {
+ drawH = bodyH; drawW = bodyH * ratio
+ }
+ const finalW = Math.max(1, drawW * imageScale)
+ const finalH = Math.max(1, drawH * imageScale)
+ const drawX = padX + offsetX + (bodyW - finalW) / 2
+ const drawY = padTop + offsetY + (bodyH - finalH) / 2
+ ctx.drawImage(img, drawX, drawY, finalW, finalH)
+ } else {
+ ctx.save()
+ ctx.strokeStyle = design.border_color || '#FFFFFF'
+ ctx.lineWidth = 6
+ const m = w * 0.06
+ drawRoundedRect(ctx, m, m, w - 2 * m, h - 2 * m, 16)
+ ctx.stroke()
+ ctx.fillStyle = design.border_color || '#FFFFFF'
+ ctx.textAlign = 'center'
+ ctx.textBaseline = 'middle'
+ ctx.font = `bold ${Math.round(w * 0.1)}px ${design.font_family || 'Times New Roman'}, serif`
+ ctx.fillText('CARD BACK', w / 2, h / 2)
+ ctx.restore()
+ }
}
/* ---------- 公开 API ---------- */
@@ -365,7 +416,7 @@ export async function renderCard(canvas, project, cardKey) {
await drawJokerBody(ctx, w, h, which, design, project)
drawBorder(ctx, w, h, design)
} else if (cardKey === 'back') {
- drawBackSide(ctx, w, h, design)
+ await drawBackSide(ctx, w, h, design, project)
} else {
const [suit, rank] = cardKey.split('-')
drawBackground(ctx, w, h, design)