现在选择“长图海报”后,在“海报尺寸”下方可以切换:

自动高度
自定义高度:1920–30000px,默认 4500px
自定义高度会同步影响画布预览、任务快照、renderDocument、历史恢复和最终 PNG。若内容超过指定高度,系统会阻止导出并提示最低所需高度,不会静默裁掉底部内容。
关键修改:
[PosterCreativePanel.vue (line 51)](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/workspace/PosterCreativePanel.vue:51)
[PosterHtmlCanvas.vue (line 1)](D:/work/code/python/coding/baodanagent/frontend/src/components/poster/long/PosterHtmlCanvas.vue:1)
[poster-exporter.ts (line 95)](D:/work/code/python/coding/baodanagent/frontend/src/utils/poster-exporter.ts:95)
[format_registry.py (line 17)](D:/work/code/python/coding/baodanagent/api/insurance/poster/format_registry.py:17)
[render_validation.py (line 13)](D:/work/code/python/coding/baodanagent/api/insurance/poster/render_validation.py:13)
验证结果:33 项海报测试通过、Vue 类型检查通过、生产构建通过、UI 静态检查通过
This commit is contained in:
wsb1224 2026-07-31 22:05:57 +08:00
parent 31c41ec7e9
commit e1770be4cc
16 changed files with 364 additions and 20 deletions

View File

@ -10,6 +10,10 @@ class PosterFormatError(ValueError):
"""海报格式不受支持。"""
CUSTOM_LONG_HEIGHT_MIN = 1920
CUSTOM_LONG_HEIGHT_MAX = 30000
_FORMATS = (
{
"id": "single_2_3",
@ -61,6 +65,27 @@ def list_poster_formats() -> list[dict]:
return deepcopy(list(_FORMATS))
def resolve_custom_long_height(value, output_mode: str) -> int | None:
"""解析长图自定义输出高度None 表示内容驱动的自动高度。"""
if value is None or value == "" or value == "auto":
return None
if output_mode != "long":
raise PosterFormatError("自定义高度仅长图模式可用")
if isinstance(value, bool):
raise PosterFormatError("长图高度必须是整数")
try:
height = int(value)
except (TypeError, ValueError) as exc:
raise PosterFormatError("长图高度必须是整数") from exc
if isinstance(value, float) and not value.is_integer():
raise PosterFormatError("长图高度必须是整数")
if not CUSTOM_LONG_HEIGHT_MIN <= height <= CUSTOM_LONG_HEIGHT_MAX:
raise PosterFormatError(
f"长图高度必须在 {CUSTOM_LONG_HEIGHT_MIN}{CUSTOM_LONG_HEIGHT_MAX}px 之间"
)
return height
def resolve_poster_format(
format_id: str | None = None,
legacy_size: str | None = None,

View File

@ -23,6 +23,7 @@ def build_render_document(
product_rules: dict,
plan_type: str,
compliance_revision: str,
custom_height: int | None = None,
sections: list[dict] | None = None,
brand: dict | None = None,
) -> dict:
@ -45,6 +46,12 @@ def build_render_document(
theme = _build_theme(template.get("colorScheme") or {})
normalized_sections = _normalize_sections(sections, format_spec["outputMode"], content)
requested_output = deepcopy(format_spec["output"])
export_size = format_spec["exportSize"]
if custom_height is not None:
requested_output["height"] = custom_height
export_size = f"{requested_output['width']}x{custom_height}"
return {
"schemaVersion": 2,
"revision": 1,
@ -55,8 +62,8 @@ def build_render_document(
),
"templateId": template.get("id"),
"layout": deepcopy(format_spec["layout"]),
"requestedOutput": deepcopy(format_spec["output"]),
"exportSize": format_spec["exportSize"],
"requestedOutput": requested_output,
"exportSize": export_size,
"copy": deepcopy(copy_content or {}),
"facts": facts,
"summary": content["summary"],

View File

@ -10,7 +10,11 @@ class PosterRenderValidationError(ValueError):
"""最终海报文件不符合格式契约。"""
def validate_rendered_png(image_bytes: bytes, format_id: str) -> dict:
def validate_rendered_png(
image_bytes: bytes,
format_id: str,
expected_height: int | None = None,
) -> dict:
"""完整解码 PNG并验证其真实像素尺寸。"""
format_spec = next(
(item for item in list_poster_formats() if item["id"] == format_id),
@ -42,6 +46,11 @@ def validate_rendered_png(image_bytes: bytes, format_id: str) -> dict:
f"海报尺寸不匹配,应为 {expected['width']}×{expected['height']}"
f"实际为 {width}×{height}"
)
if expected["height"] is None and expected_height is not None and height != expected_height:
raise PosterRenderValidationError(
f"长图自定义高度不匹配,应为 {expected['width']}×{expected_height}"
f"实际为 {width}×{height}"
)
if expected["height"] is None and not 1 <= height <= 32767:
raise PosterRenderValidationError("长图高度超出浏览器安全范围")

View File

@ -265,17 +265,26 @@ class PosterService:
if not template_id:
return {"code": 1001, "message": "请选择海报模板", "data": None}
from insurance.poster.format_registry import PosterFormatError, resolve_poster_format
from insurance.poster.format_registry import (
PosterFormatError,
resolve_custom_long_height,
resolve_poster_format,
)
try:
format_spec = resolve_poster_format(
format_id=data.get("formatId"),
legacy_size=data.get("size"),
output_mode=output_mode,
)
custom_height = resolve_custom_long_height(data.get("customHeight"), output_mode)
except PosterFormatError as exc:
return {"code": 1002, "message": str(exc), "data": None}
format_id = format_spec["id"]
size = format_spec["exportSize"]
size = (
f"{format_spec['output']['width']}x{custom_height}"
if custom_height is not None
else format_spec["exportSize"]
)
from insurance.poster.compliance import check_copy_compliance
compliance_result = check_copy_compliance(copy_content)
@ -329,6 +338,7 @@ class PosterService:
product_rules=rules,
plan_type=context.get("planType") or "other",
compliance_revision=compliance_result["revision"],
custom_height=custom_height,
sections=data.get("sections"),
brand={
"productName": product_data.get("displayName", ""),
@ -381,6 +391,7 @@ class PosterService:
"referenceImages": ref_images_meta,
"outputMode": output_mode,
"formatId": format_id,
"customHeight": custom_height,
"compliance": compliance_result,
}, ensure_ascii=False),
document_json=json.dumps(render_document, ensure_ascii=False),
@ -495,8 +506,18 @@ class PosterService:
PosterRenderValidationError,
validate_rendered_png,
)
requested_output = document.get("requestedOutput") or {}
expected_height = (
requested_output.get("height")
if format_id == "long_1242_auto"
else None
)
try:
actual_output = validate_rendered_png(image_bytes, format_id)
actual_output = validate_rendered_png(
image_bytes,
format_id,
expected_height=expected_height,
)
except PosterRenderValidationError as exc:
return {"code": 1002, "message": str(exc), "data": None}
@ -524,6 +545,10 @@ class PosterService:
document["validationResult"] = "passed"
record.document_json = json.dumps(document, ensure_ascii=False)
record.document_revision = revision
if format_id == "long_1242_auto":
record.export_size = (
f"1242x{expected_height}" if expected_height is not None else "1242xauto"
)
record.export_url = filepath
record.export_format = "png"
record.task_status = "done"
@ -533,6 +558,7 @@ class PosterService:
"actualOutput": actual_output,
"byteSize": len(image_bytes),
"validationResult": "passed",
"customHeight": expected_height,
})
record.extra_data = json.dumps(extra_data, ensure_ascii=False)
record.draft_revision = (record.draft_revision or 1) + 1

View File

@ -1,5 +1,11 @@
<template>
<div ref="canvasRef" class="poster-html-canvas" :class="modeClass" :style="canvasStyle">
<div
ref="canvasRef"
class="poster-html-canvas"
:class="modeClass"
:style="canvasStyle"
:data-custom-output-height="customOutputHeight || undefined"
>
<template v-for="section in visibleSections" :key="section.id">
<PosterHero
v-if="section.id === 'hero'"
@ -63,6 +69,7 @@ const props = defineProps<{
companyName?: string
outputMode: 'single' | 'long'
formatId: string
customOutputHeight?: number | null
features?: Array<{ title: string; summary?: string; icon?: string }>
benefits?: Array<{
year: number
@ -105,7 +112,13 @@ const canvasStyle = computed(() => {
'--poster-text': theme?.text || '#17231f',
'--poster-muted': theme?.muted || '#64716d',
} as CSSProperties
if (props.formatId === 'long_1242_auto') return { ...variables, width: '414px' }
if (props.formatId === 'long_1242_auto') {
return {
...variables,
width: '414px',
...(props.customOutputHeight ? { height: `${props.customOutputHeight / 3}px` } : {}),
}
}
if (props.formatId === 'single_9_16') return { ...variables, width: '540px', height: '960px' }
return { ...variables, width: '512px', height: '768px' }
})
@ -149,4 +162,8 @@ defineExpose({ canvasRef })
.poster--long {
min-height: 600px;
}
.poster--long > :deep(*) {
flex-shrink: 0;
}
</style>

View File

@ -38,10 +38,13 @@
:scenario="draft.scenario"
:output-mode="draft.outputMode"
:format-id="draft.formatId"
:long-height-mode="draft.longHeightMode"
:custom-long-height="draft.customLongHeight"
:template-id="draft.templateId"
:template-name="draft.templateName"
@update:scenario="$emit('update:draft', { scenario: $event })"
@update:format="$emit('update:draft', { formatId: $event.id, outputMode: $event.outputMode, exportSize: $event.exportSize })"
@update:format="onFormatUpdate"
@update:height="onHeightUpdate"
@update:template="$emit('update:draft', { templateId: $event.id, templateName: $event.name, templateColorScheme: $event.colorScheme || null })"
/>
@ -104,6 +107,24 @@ function onProductSourceUpdate(source: {
})
}
function onFormatUpdate(format: { id: string; outputMode: 'single' | 'long'; exportSize: string }) {
emit('update:draft', {
formatId: format.id,
outputMode: format.outputMode,
exportSize: format.outputMode === 'long' && props.draft.longHeightMode === 'custom'
? `1242x${props.draft.customLongHeight}`
: format.exportSize,
})
}
function onHeightUpdate(value: { mode: 'auto' | 'custom'; height: number }) {
emit('update:draft', {
longHeightMode: value.mode,
customLongHeight: value.height,
exportSize: value.mode === 'custom' ? `1242x${value.height}` : '1242xauto',
})
}
defineExpose({
openProductSelector: () => productPanelRef.value?.openSelector(),
openSourceUpload: () => sourcePanelRef.value?.openUpload(),

View File

@ -65,6 +65,39 @@
<span class="preset-size">{{ formatSizeLabel(preset) }}</span>
</button>
</div>
<div v-if="outputMode === 'long'" class="height-settings">
<div class="height-toggle" role="group" aria-label="长图高度模式">
<button
type="button"
:class="{ active: longHeightMode === 'auto' }"
@click="onHeightModeChange('auto')"
>自动高度</button>
<button
type="button"
:class="{ active: longHeightMode === 'custom' }"
@click="onHeightModeChange('custom')"
>自定义高度</button>
</div>
<div v-if="longHeightMode === 'custom'" class="custom-height-row">
<el-input-number
:model-value="customLongHeight"
:min="1920"
:max="30000"
:step="100"
:precision="0"
controls-position="right"
aria-label="长图输出高度"
@update:model-value="onCustomHeightChange"
/>
<span class="height-unit">px</span>
</div>
<p class="height-hint">
{{ longHeightMode === 'auto'
? '根据实际内容自动延长,适合完整方案。'
: '范围 192030000px高度不足时导出会提示所需最小值不会静默裁切。' }}
</p>
</div>
</div>
<!-- 模板选择 -->
@ -145,6 +178,8 @@ const props = defineProps<{
scenario: string
outputMode: 'single' | 'long'
formatId: string
longHeightMode: 'auto' | 'custom'
customLongHeight: number
templateId: number | null
templateName: string
}>()
@ -152,6 +187,7 @@ const props = defineProps<{
const emit = defineEmits<{
'update:scenario': [value: string]
'update:format': [value: PosterFormatSpec]
'update:height': [value: { mode: 'auto' | 'custom'; height: number }]
'update:template': [template: { id: number; name: string; colorScheme?: any }]
}>()
@ -223,9 +259,20 @@ function selectMode(mode: 'single' | 'long') {
}
function formatSizeLabel(format: PosterFormatSpec) {
if (format.outputMode === 'long' && props.longHeightMode === 'custom') {
return `${format.output.width}×${props.customLongHeight}`
}
return `${format.output.width}×${format.output.height ?? '自动高度'}`
}
function onHeightModeChange(mode: 'auto' | 'custom') {
emit('update:height', { mode, height: props.customLongHeight || 4500 })
}
function onCustomHeightChange(value: number | undefined) {
emit('update:height', { mode: 'custom', height: value || 4500 })
}
//
function onSelectTemplate(t: any) {
emit('update:template', {
@ -390,7 +437,7 @@ watch(() => [props.templateId, props.outputMode, props.formatId], ensureValidTem
/* ── 尺寸预设 ──────────────────────── */
.size-presets {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 6px;
}
@ -428,6 +475,65 @@ watch(() => [props.templateId, props.outputMode, props.formatId], ensureValidTem
font-size: 10px;
}
.height-settings {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--poster-border);
}
.height-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 3px;
border-radius: 8px;
background: #eef3f0;
}
.height-toggle button {
min-height: 30px;
border: 0;
border-radius: 6px;
color: var(--poster-muted);
background: transparent;
cursor: pointer;
font-size: 12px;
}
.height-toggle button.active {
color: var(--poster-accent);
background: #fff;
box-shadow: 0 2px 7px rgb(24 61 44 / 10%);
font-weight: 600;
}
.height-toggle button:focus-visible {
outline: 2px solid var(--poster-accent);
outline-offset: 2px;
}
.custom-height-row {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.custom-height-row :deep(.el-input-number) {
width: 150px;
}
.height-unit {
color: var(--poster-muted);
font-size: 12px;
}
.height-hint {
margin: 7px 0 0;
color: var(--poster-muted);
font-size: 10px;
line-height: 1.5;
}
/* ── 模板缩略图网格 ────────────────── */
.template-grid {
display: grid;

View File

@ -58,7 +58,11 @@ defineEmits<{ 'update:compliance-confirmed': [value: boolean] }>()
const sizeText = computed(() => {
if (props.draft.formatId === 'single_9_16') return '竖屏单图 1080×1920'
if (props.draft.formatId === 'long_1242_auto') return '完整方案长图 1242×自动高度'
if (props.draft.formatId === 'long_1242_auto') {
return props.draft.longHeightMode === 'custom'
? `完整方案长图 1242×${props.draft.customLongHeight}`
: '完整方案长图 1242×自动高度'
}
return '竖版单图 1024×1536'
})

View File

@ -32,6 +32,7 @@
:company-name="renderCompanyName"
:output-mode="draft.outputMode"
:format-id="draft.formatId"
:custom-output-height="draft.longHeightMode === 'custom' ? draft.customLongHeight : null"
:features="renderFeatures"
:benefits="renderBenefits"
:hero-background="draft.backgroundCandidateUrl || draft.posterUrl"

View File

@ -59,6 +59,8 @@ export interface PosterDraft {
outputMode: 'single' | 'long'
formatId: string
exportSize: string
longHeightMode: 'auto' | 'custom'
customLongHeight: number
templateId: number | null
templateName: string
templateColorScheme: PosterTheme | null
@ -112,6 +114,8 @@ function createEmptyDraft(): PosterDraft {
outputMode: 'single',
formatId: 'single_2_3',
exportSize: '1024x1536',
longHeightMode: 'auto',
customLongHeight: 4500,
templateId: null,
templateName: '',
templateColorScheme: null,
@ -146,7 +150,14 @@ function createEmptyDraft(): PosterDraft {
function normalizeFormat(draft: PosterDraft) {
if (draft.outputMode === 'long') {
draft.formatId = 'long_1242_auto'
draft.exportSize = '1242xauto'
const height = Number(draft.customLongHeight)
if (draft.longHeightMode === 'custom' && Number.isInteger(height) && height >= 1920 && height <= 30000) {
draft.customLongHeight = height
draft.exportSize = `1242x${height}`
} else {
draft.longHeightMode = 'auto'
draft.exportSize = '1242xauto'
}
return
}
if (draft.formatId === 'single_9_16' || draft.exportSize === '1080x1920') {
@ -196,6 +207,8 @@ export function usePosterWorkspace() {
outputMode: d.outputMode,
formatId: d.formatId,
exportSize: d.exportSize,
longHeightMode: d.longHeightMode,
customLongHeight: d.customLongHeight,
templateId: d.templateId,
templateName: d.templateName,
templateColorScheme: d.templateColorScheme,
@ -309,6 +322,18 @@ export function usePosterWorkspace() {
draft.value.formatId = record.document.formatId || draft.value.formatId
draft.value.templateColorScheme = record.document.theme || record.document.style || draft.value.templateColorScheme
draft.value.sections = record.document.sections || draft.value.sections
const rawRequestedHeight = record.document.requestedOutput?.height
const requestedHeight = Number(rawRequestedHeight)
if (
record.document.outputMode === 'long'
&& rawRequestedHeight != null
&& Number.isInteger(requestedHeight)
&& requestedHeight >= 1920
&& requestedHeight <= 30000
) {
draft.value.longHeightMode = 'custom'
draft.value.customLongHeight = requestedHeight
}
}
if (record.aiRawContent) {
draft.value.aiRawContent = record.aiRawContent
@ -319,6 +344,10 @@ export function usePosterWorkspace() {
if (record.extraData?.formatId) {
draft.value.formatId = record.extraData.formatId
}
if (record.extraData?.customHeight) {
draft.value.longHeightMode = 'custom'
draft.value.customLongHeight = Number(record.extraData.customHeight)
}
normalizeFormat(draft.value)
if (record.compliance) {
draft.value.complianceStatus = record.compliance.status || 'unchecked'

View File

@ -123,6 +123,7 @@ const stageRef = ref<InstanceType<typeof PosterStage> | null>(null)
const configRailRef = ref<InstanceType<typeof PosterConfigRail> | null>(null)
const inlineInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
const drawerInspectorRef = ref<InstanceType<typeof PosterInspector> | null>(null)
const lastRenderError = ref<string | null>(null)
// productName
const posterTitle = ref('')
@ -164,6 +165,12 @@ function syncRenderDocument() {
outputMode: d.outputMode,
templateId: d.templateId,
exportSize: d.exportSize,
requestedOutput: {
width: d.outputMode === 'long' ? 1242 : (d.formatId === 'single_9_16' ? 1080 : 1024),
height: d.outputMode === 'long'
? (d.longHeightMode === 'custom' ? d.customLongHeight : null)
: (d.formatId === 'single_9_16' ? 1920 : 1536),
},
copy: d.copyContent,
facts: d.parsedFields,
theme: d.templateColorScheme || d.renderDocument.theme,
@ -323,6 +330,9 @@ async function onGenerate() {
formatId: d.formatId,
size: d.exportSize,
outputMode: d.outputMode,
customHeight: d.outputMode === 'long' && d.longHeightMode === 'custom'
? d.customLongHeight
: undefined,
copyMode: d.aiRawContent ? 'ai' : 'template',
referenceImage,
})
@ -411,6 +421,12 @@ function buildPosterDocument() {
outputMode: d.outputMode,
formatId: d.formatId,
exportSize: d.exportSize,
requestedOutput: {
width: d.outputMode === 'long' ? 1242 : (d.formatId === 'single_9_16' ? 1080 : 1024),
height: d.outputMode === 'long'
? (d.longHeightMode === 'custom' ? d.customLongHeight : null)
: (d.formatId === 'single_9_16' ? 1920 : 1536),
},
copy: d.copyContent,
facts: d.parsedFields,
theme: d.templateColorScheme || d.renderDocument?.theme || {},
@ -430,6 +446,8 @@ watch(
ws.draft.value.outputMode,
ws.draft.value.formatId,
ws.draft.value.exportSize,
ws.draft.value.longHeightMode,
ws.draft.value.customLongHeight,
ws.draft.value.templateColorScheme,
ws.draft.value.sections,
],
@ -463,11 +481,17 @@ function loadImage(url: string): Promise<void> {
}
async function renderComposite(): Promise<Blob | null> {
lastRenderError.value = null
const htmlCanvas = stageRef.value?.htmlCanvasRef
if (htmlCanvas?.canvasRef) {
try {
return await renderPosterToBlob(htmlCanvas.canvasRef, ws.draft.value.formatId)
} catch (e) {
const d = ws.draft.value
const expectedHeight = d.outputMode === 'long' && d.longHeightMode === 'custom'
? d.customLongHeight
: null
return await renderPosterToBlob(htmlCanvas.canvasRef, d.formatId, expectedHeight)
} catch (e: any) {
lastRenderError.value = e?.message || '海报合成失败'
console.error('HTML 海报导出失败:', e)
}
}
@ -547,7 +571,7 @@ async function onDownload() {
ElMessage.success('海报已导出')
return
}
ElMessage.warning('合成导出失败,尝试下载已保存的海报')
ElMessage.warning(lastRenderError.value || '合成导出失败,尝试下载已保存的海报')
// 退
const recordId = d.taskRecordId || ws.recordId.value
@ -644,6 +668,19 @@ async function restoreWorkspace() {
d.parsedFields = snap.renderDocument.facts || d.parsedFields
d.templateColorScheme = snap.renderDocument.theme || d.templateColorScheme
d.sections = snap.renderDocument.sections || d.sections
const rawRequestedHeight = snap.renderDocument.requestedOutput?.height
const requestedHeight = Number(rawRequestedHeight)
if (
snap.renderDocument.outputMode === 'long'
&& rawRequestedHeight != null
&& Number.isInteger(requestedHeight)
&& requestedHeight >= 1920
&& requestedHeight <= 30000
) {
d.longHeightMode = 'custom'
d.customLongHeight = requestedHeight
d.exportSize = `1242x${requestedHeight}`
}
}
}

View File

@ -146,6 +146,7 @@ export const posterApi = {
formatId?: string
size?: string
outputMode?: string
customHeight?: number
productId?: string
productSource?: { type: 'library_product' | 'user_material'; id: string }
referenceImage?: string

View File

@ -79,31 +79,43 @@ async function readBlobSize(blob: Blob): Promise<{ width: number; height: number
}
}
async function validateOutput(blob: Blob, formatId: string): Promise<void> {
async function validateOutput(blob: Blob, formatId: string, expectedHeight?: number | null): Promise<void> {
const expected = FORMAT_OUTPUTS[formatId]
if (!expected) throw new Error(`不支持的导出格式: ${formatId}`)
const { width, height } = await readBlobSize(blob)
if (width !== expected.width || (expected.height !== null && height !== expected.height)) {
throw new Error(`导出尺寸错误:期望 ${expected.width}×${expected.height ?? '自动高度'},实际 ${width}×${height}`)
const requiredHeight = expectedHeight ?? expected.height
if (width !== expected.width || (requiredHeight !== null && height !== requiredHeight)) {
throw new Error(`导出尺寸错误:期望 ${expected.width}×${requiredHeight ?? '自动高度'},实际 ${width}×${height}`)
}
if (expected.height === null && (height < 1 || height > 32767)) {
throw new Error(`长图高度 ${height}px 超出浏览器安全范围`)
}
}
export async function renderPosterToBlob(canvas: HTMLElement, formatId: string): Promise<Blob> {
export async function renderPosterToBlob(
canvas: HTMLElement,
formatId: string,
expectedHeight?: number | null,
): Promise<Blob> {
const spec = FORMAT_OUTPUTS[formatId]
if (!spec) throw new Error(`不支持的导出格式: ${formatId}`)
if (expectedHeight != null && formatId !== 'long_1242_auto') {
throw new Error('自定义高度仅长图模式可用')
}
const { toBlob } = await import('html-to-image')
await waitForPosterAssets(canvas)
if (expectedHeight != null && canvas.scrollHeight > canvas.clientHeight + 1) {
const minimumHeight = Math.ceil(canvas.scrollHeight * spec.pixelRatio)
throw new Error(`当前内容超出自定义高度,请将高度调整到至少 ${minimumHeight}px`)
}
const blob = await toBlob(canvas, {
quality: 0.95,
pixelRatio: spec.pixelRatio,
cacheBust: true,
})
if (!blob) throw new Error('浏览器未生成海报文件')
await validateOutput(blob, formatId)
await validateOutput(blob, formatId, expectedHeight)
return blob
}

View File

@ -66,6 +66,17 @@ def test_background_asset_size_is_provider_size_not_final_poster_size():
assert long_format["backgroundAssetSize"] == "1024x1536"
def test_custom_long_height_is_bounded_and_explicit():
from insurance.poster.format_registry import PosterFormatError, resolve_custom_long_height
assert resolve_custom_long_height(4500, "long") == 4500
assert resolve_custom_long_height(None, "long") is None
with pytest.raises(PosterFormatError, match="1920"):
resolve_custom_long_height(1200, "long")
with pytest.raises(PosterFormatError, match="仅长图"):
resolve_custom_long_height(4500, "single")
def test_fallback_background_never_draws_marketing_copy(monkeypatch):
from PIL import ImageDraw
from insurance.poster.image_generator import generate_fallback

View File

@ -62,6 +62,25 @@ def test_render_document_long_mode_keeps_long_content_budget():
assert next(item for item in document["sections"] if item["id"] == "benefits")["visible"] is True
def test_render_document_keeps_custom_long_height():
from insurance.poster.format_registry import resolve_poster_format
from insurance.poster.render_document_builder import build_render_document
document = build_render_document(
format_spec=resolve_poster_format("long_1242_auto", output_mode="long"),
template={},
copy_content={},
case_facts={},
product_rules={},
plan_type="other",
compliance_revision="",
custom_height=4500,
)
assert document["requestedOutput"] == {"width": 1242, "height": 4500}
assert document["exportSize"] == "1242x4500"
def test_template_mode_and_format_compatibility_is_explicit():
from insurance.models.poster_template_model import PosterTemplate

View File

@ -40,6 +40,15 @@ def test_long_rendered_png_requires_exact_width_and_safe_height():
validate_rendered_png(_png(1080, 3600), "long_1242_auto")
def test_custom_long_height_requires_exact_output_height():
from insurance.poster.render_validation import PosterRenderValidationError, validate_rendered_png
result = validate_rendered_png(_png(1242, 4500), "long_1242_auto", expected_height=4500)
assert result["height"] == 4500
with pytest.raises(PosterRenderValidationError, match="自定义高度"):
validate_rendered_png(_png(1242, 4600), "long_1242_auto", expected_height=4500)
def test_single_and_long_use_separate_canvas_components():
stage = (ROOT / "frontend/src/components/poster/workspace/PosterStage.vue").read_text(encoding="utf-8")
single_canvas = ROOT / "frontend/src/components/poster/single/PosterSingleCanvas.vue"
@ -50,6 +59,16 @@ def test_single_and_long_use_separate_canvas_components():
assert "<PosterHtmlCanvas" in stage
def test_long_editor_exposes_custom_height_without_silent_crop():
creative = (ROOT / "frontend/src/components/poster/workspace/PosterCreativePanel.vue").read_text(encoding="utf-8")
stage = (ROOT / "frontend/src/components/poster/workspace/PosterStage.vue").read_text(encoding="utf-8")
exporter = (ROOT / "frontend/src/utils/poster-exporter.ts").read_text(encoding="utf-8")
assert "自定义高度" in creative
assert ':custom-output-height="draft.longHeightMode === \'custom\' ? draft.customLongHeight : null"' in stage
assert "当前内容超出自定义高度" in exporter
def test_background_generation_does_not_claim_final_poster_is_done():
celery_source = (ROOT / "api/insurance/generation/celery_tasks.py").read_text(encoding="utf-8")
service_source = (ROOT / "api/insurance/poster/service.py").read_text(encoding="utf-8")