/** * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。 * `<` 前缀由模板 config 决定,录入 JSON 不包含 LessThan 字段。 */ import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' import { NUTRITION_FACTS_LAYOUT_ROWS, buildNutritionFactsViewModel, NUTRITION_BODY_FONT_SIZE } from '../nutritionFactsLayout' /** 与 Web nutritionManualEntry.NUTRITION_FIELD_COMPOSITE_SEP 一致 */ export const NUTRITION_FIELD_COMPOSITE_SEP = '###nut###' export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string { return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}` } function defaultLookupCandidates(el: SystemTemplateElementBase): string[] { return [ String(el.id ?? '').trim(), String(el.inputKey ?? '').trim(), String(el.elementName ?? '').trim(), ].filter(Boolean) } /** 从 templateProductDefaultValues 解析营养成分手动录入(JSON 或 composite 列键) */ export function collectNutritionManualFromDefaults( el: SystemTemplateElementBase, defaults: Record, ): Record { if (!defaults || !Object.keys(defaults).length) return {} const candidates = defaultLookupCandidates(el) for (const k of candidates) { if (Object.prototype.hasOwnProperty.call(defaults, k)) { const raw = String(defaults[k] ?? '').trim() if (raw.startsWith('{')) { try { const p = JSON.parse(raw) as Record if (p && typeof p === 'object' && !Array.isArray(p)) return p } catch { /* ignore */ } } } } const lower = Object.entries(defaults).map(([k, v]) => [k.toLowerCase(), v] as const) for (const k of candidates) { const hit = lower.find(([dk]) => dk === k.toLowerCase()) if (hit) { const raw = String(hit[1] ?? '').trim() if (raw.startsWith('{')) { try { return JSON.parse(raw) as Record } catch { /* ignore */ } } } } const id = String(el.id ?? '').trim() if (!id) return {} const prefix = `${id}${NUTRITION_FIELD_COMPOSITE_SEP}` const manual: Record = {} for (const [k, v] of Object.entries(defaults)) { if (k.startsWith(prefix)) manual[k.slice(prefix.length)] = String(v ?? '') } return manual } function parseNutritionJsonFromConfigText(cfg: Record): string | null { const text = String(cfg.text ?? cfg.Text ?? '').trim() return text.startsWith('{') ? text : null } /** 接口 8.2 会把营养成分 JSON 误写入 config.text;解析进 fixedNutrients / calories 等字段 */ export function hydrateNutritionElementsInTemplate( template: SystemLabelTemplate, defaults: Record = {}, ): SystemLabelTemplate { const elements = (template.elements || []).map((el) => { if (String(el.type || '').toUpperCase() !== 'NUTRITION') return el let cfg = { ...(el.config || {}) } as Record const fromText = parseNutritionJsonFromConfigText(cfg) if (fromText) { cfg = applyNutritionDefaultJsonToConfig(cfg, fromText) } const manual = collectNutritionManualFromDefaults(el, defaults) if (Object.keys(manual).some((k) => String(manual[k] ?? '').trim() !== '')) { cfg = applyNutritionDefaultJsonToConfig(cfg, JSON.stringify(manual)) } if (fromText) { delete cfg.text delete cfg.Text } return { ...el, config: cfg } }) return { ...template, elements } } /** 按实际行数估算营养表所需高度,避免矮框裁掉 Vitamin D / 页脚等行 */ export function estimateNutritionPanelHeightPx(cfg: Record): number { const model = buildNutritionFactsViewModel(cfg) const bodySize = NUTRITION_BODY_FONT_SIZE const titleSize = Math.max(11, Math.min(18, model.titleFontSize)) const lh = (fs: number) => fs + 4 let y = 12 y += titleSize + 6 y += lh(bodySize) + 3 + lh(bodySize) + 5 y += bodySize + 8 for (const row of model.rows) { y += lh(bodySize) if (row.dividerAfter === 'double') y += 5 else if (row.dividerAfter === 'thin') y += 3 } y += 24 return Math.ceil(y) } /** * 仅当营养表增高时,把「原底边以下」的元素整体下移相同 delta,保持同行左右布局不变。 * 禁止按 cursor 重排,避免 Consume By 标签/日期、ALLERGENS 等被拆散叠在一起。 */ export function reflowElementsBelowNutritionPanel( template: SystemLabelTemplate, previousNutritionBottom?: number, ): SystemLabelTemplate { const elements = template.elements || [] const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION') if (!nutrition) return template const nutY = Number(nutrition.y) || 0 const nutBottom = nutY + (Number(nutrition.height) || 0) const oldBottom = previousNutritionBottom ?? nutBottom const delta = nutBottom - oldBottom if (delta <= 1) return template const threshold = oldBottom - 2 const next = elements.map((el) => { if (el.id === nutrition.id) return el const y = Number(el.y) || 0 if (y >= threshold) return { ...el, y: y + delta } return el }) return { ...template, elements: next } } /** 仅在内容确实超出模板框时微增高度,并整体下移其下元素(不重排同行) */ export function expandNutritionElementsToFitContent(template: SystemLabelTemplate): SystemLabelTemplate { const elements = [...(template.elements || [])] const nutIdx = elements.findIndex((el) => String(el.type || '').toUpperCase() === 'NUTRITION') if (nutIdx < 0) return template const nutrition = elements[nutIdx] const nutY = Number(nutrition.y) || 0 const oldH = Number(nutrition.height) || 0 const oldBottom = nutY + oldH const need = estimateNutritionPanelHeightPx((nutrition.config || {}) as Record) const maxH = Math.max(oldH, need) if (maxH <= oldH + 2) return template elements[nutIdx] = { ...nutrition, height: maxH } return reflowElementsBelowNutritionPanel({ ...template, elements }, oldBottom) } function fixedLabelForKey(key: string): string { const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key) return hit?.label ?? key } function pickManual(manual: Record, subKey: string): string { return String(manual[subKey] ?? '').trim() } export function applyNutritionDefaultJsonToConfig( baseCfg: Record, jsonStr: string, ): Record { const t = String(jsonStr ?? '').trim() if (!t.startsWith('{')) return baseCfg let manual: Record = {} try { manual = JSON.parse(t) as Record } catch { return baseCfg } const out: Record = { ...baseCfg } const baseFixed = Array.isArray(baseCfg.fixedNutrients) ? (baseCfg.fixedNutrients as Record[]) : [] const cal = pickManual(manual, 'calories') if (cal) out.calories = cal else { delete out.calories delete out.Calories } out.servingsPerContainer = pickManual(manual, 'servingsPerContainer') out.servingSize = pickManual(manual, 'servingSize') const keysInOrder = [...NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key)] for (const row of baseFixed) { const key = String(row.key ?? '').trim() if (key && !keysInOrder.includes(key)) keysInOrder.push(key) } const fixedArr: Record[] = [] for (const key of keysInOrder) { const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === key) const layout = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key) const v = pickManual(manual, key) const pct = pickManual(manual, `${key}Percent`) const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]) const label = String(baseRow?.label ?? layout?.label ?? fixedLabelForKey(key)) fixedArr.push({ key, label, value: v, unit: '', dailyValuePercent: pct, lessThan, }) if (v) out[key] = v else delete out[key] delete out[`${key}Unit`] out[`${key}Percent`] = pct out[`${key}LessThan`] = lessThan } out.fixedNutrients = fixedArr const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = [] for (const k of Object.keys(manual)) { if (!k.startsWith('extra:') || !k.endsWith(':value')) continue const id = k.slice('extra:'.length, -':value'.length) newExtras.push({ id, name: String( (Array.isArray(out.extraNutrients) ? (out.extraNutrients as Record[]).find((row) => String(row.id ?? '') === id) : undefined)?.name ?? 'Other', ), value: pickManual(manual, k), unit: '', }) out[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`) out[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]) } if (newExtras.length > 0) out.extraNutrients = newExtras delete out.ingredientsText delete out.IngredientsText return out }