/** * US Nutrition Facts 面板布局:三列(名称 / 含量 / %DV)、粗细分隔线、可选 "<" 前缀。 */ export type NutritionDivider = 'none' | 'thin' | 'double'; export type NutritionLayoutRowDef = { key: string; label: string; defaultUnit: string; labelBold?: boolean; indent?: boolean; dividerAfter?: NutritionDivider; }; /** 模板固定营养成分行(图2顺序与文案;大类 dividerAfter=double,其他=thin) */ export const NUTRITION_FACTS_LAYOUT_ROWS: readonly NutritionLayoutRowDef[] = [ { key: 'fat', label: 'Total Fat', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g', dividerAfter: 'thin' }, { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' }, { key: 'sodium', label: 'Sodium', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' }, { key: 'carbs', label: 'Total Carbo.', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, { key: 'totalSugar', label: 'Sugars', defaultUnit: 'g', dividerAfter: 'thin' }, { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', dividerAfter: 'thin' }, { key: 'protein', label: 'Protein', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, { key: 'calcium', label: 'Calcium', defaultUnit: 'mg', dividerAfter: 'thin' }, { key: 'potassium', label: 'Potassium', defaultUnit: 'mg', dividerAfter: 'thin' }, { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mg', dividerAfter: 'thin' }, { key: 'vitaminD', label: 'Vitamin D', defaultUnit: 'mg', dividerAfter: 'thin' }, { key: 'iron', label: 'Iron', defaultUnit: 'mg', dividerAfter: 'none' }, ] as const; /** 含量 / %DV 列固定宽度(全表共用,保证各行垂直对齐) */ export const NUTRITION_AMOUNT_COL_WIDTH = '4rem'; export const NUTRITION_PCT_COL_WIDTH = '2.75rem'; /** 图2 正文区字号(Servings ~ 底部维生素行) */ export const NUTRITION_BODY_FONT_SIZE = 12; export const DEFAULT_NUTRITION_FOOTER_NOTE = '* Percent Daily Values are based on a 2000 calorie diet'; export type NutritionFactsRowView = { key: string; label: string; amountText: string; dailyValueText: string; labelBold: boolean; indent: boolean; dividerAfter: NutritionDivider; }; export type NutritionFactsViewModel = { titleFontSize: number; servingsLabel: string; servingsValue: string; servingSizeLabel: string; servingSizeValue: string; caloriesLabel: string; caloriesValue: string; caloriesAmountText: string; rows: NutritionFactsRowView[]; footerNote: string; ingredientsText: string; }; function cfgStr(cfg: Record, keys: string[], fallback = ''): string { for (const k of keys) { const v = cfg[k]; if (v != null && String(v).trim() !== '') return String(v).trim(); } return fallback; } function cfgBool(cfg: Record, keys: string[]): boolean { for (const k of keys) { const v = cfg[k]; if (v === true || v === 'true' || v === 1 || v === '1') return true; if (v === false || v === 'false' || v === 0 || v === '0') return false; } return false; } function fixedRows(cfg: Record): Record[] { return Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record[]) : []; } function rowFromFixed(cfg: Record, key: string): Record | undefined { return fixedRows(cfg).find((r) => String(r.key ?? '').trim() === key); } export function readNutritionLessThan(cfg: Record, key: string): boolean { const row = rowFromFixed(cfg, key); if (row && row.lessThan != null) return cfgBool({ lessThan: row.lessThan }, ['lessThan']); return cfgBool(cfg, [`${key}LessThan`, `${key}UseLessThan`]); } export function nutritionFixedField( cfg: Record, key: string, field: 'value' | 'unit' | 'dailyValuePercent', ): string { const row = rowFromFixed(cfg, key); if (field === 'dailyValuePercent') { const fromRow = row?.dailyValuePercent ?? row?.percent ?? row?.Percent; if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim(); return cfgStr(cfg, [`${key}Percent`, `${key}DailyValue`, `${key}DailyValuePercent`], ''); } if (field === 'unit') { const fromRow = row?.unit; if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim(); const def = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key); return cfgStr(cfg, [`${key}Unit`], def?.defaultUnit ?? ''); } const fromRow = row?.value; if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim(); return cfgStr(cfg, [key, key.charAt(0).toUpperCase() + key.slice(1)], ''); } /** 格式化含量:仅 optional "<" 前缀;单位由录入时在 amount 中自行填写,不自动拼接 */ export function formatNutritionAmount( value: string, _unit?: string, lessThan?: boolean, ): string { const v = String(value ?? '').trim(); if (!v) return ''; const prefix = lessThan ? '<' : ''; return `${prefix}${v}`; } export function formatNutritionDailyValue(raw: string): string { const v = String(raw ?? '').trim(); if (!v) return ''; return v.endsWith('%') ? v : `${v}%`; } function nutritionExtraRows(cfg: Record): Array<{ id: string; name: string; value: string; unit: string; }> { const raw = cfg.extraNutrients; if (!Array.isArray(raw)) return []; return raw.map((item, idx) => { const row = item as Record; return { id: String(row.id ?? `extra-${idx}`), name: String(row.name ?? ''), value: String(row.value ?? ''), unit: String(row.unit ?? ''), }; }); } /** 从 config 构建渲染模型(Web 画布 / App canvas 共用逻辑) */ export function buildNutritionFactsViewModel(cfg: Record): NutritionFactsViewModel { const titleFontSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16; const servingsValue = cfgStr(cfg, ['servings', 'servingsPerContainer', 'ServingsPerContainer']); const servingSizeValue = cfgStr(cfg, ['servingSize', 'ServingSize']); const caloriesRaw = nutritionFixedField(cfg, 'calories', 'value') || cfgStr(cfg, ['calories', 'Calories']); const caloriesLessThan = readNutritionLessThan(cfg, 'calories'); const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r])); const rows: NutritionFactsRowView[] = []; const seen = new Set(); for (const def of NUTRITION_FACTS_LAYOUT_ROWS) { seen.add(def.key); const value = nutritionFixedField(cfg, def.key, 'value'); const lessThan = readNutritionLessThan(cfg, def.key); const pct = nutritionFixedField(cfg, def.key, 'dailyValuePercent'); rows.push({ key: def.key, label: String(rowFromFixed(cfg, def.key)?.label ?? def.label), amountText: formatNutritionAmount(value, '', lessThan), dailyValueText: formatNutritionDailyValue(pct), labelBold: def.labelBold ?? false, indent: def.indent ?? false, dividerAfter: def.dividerAfter ?? 'none', }); } for (const ex of nutritionExtraRows(cfg)) { const key = `extra:${ex.id}`; if (seen.has(key)) continue; const lessThan = cfgBool(cfg, [`extra:${ex.id}:lessThan`]); rows.push({ key, label: ex.name.trim() || 'Other', amountText: formatNutritionAmount(ex.value, '', lessThan), dailyValueText: formatNutritionDailyValue( cfgStr(cfg, [`extra:${ex.id}:percent`, `extra:${ex.id}:dailyValuePercent`]), ), labelBold: false, indent: false, dividerAfter: 'thin', }); } for (const fr of fixedRows(cfg)) { const key = String(fr.key ?? '').trim(); if (!key || seen.has(key)) continue; const def = layoutByKey.get(key); const value = String(fr.value ?? '').trim(); const lessThan = cfgBool({ lessThan: fr.lessThan }, ['lessThan']); rows.push({ key, label: String(fr.label ?? def?.label ?? key), amountText: formatNutritionAmount(value, '', lessThan), dailyValueText: formatNutritionDailyValue(String(fr.dailyValuePercent ?? fr.percent ?? '')), labelBold: def?.labelBold ?? false, indent: def?.indent ?? false, dividerAfter: def?.dividerAfter ?? 'thin', }); } return { titleFontSize, servingsLabel: cfgStr(cfg, ['servingsLabel'], 'Servings'), servingsValue, servingSizeLabel: cfgStr(cfg, ['servingSizeLabel'], 'Serve size'), servingSizeValue, caloriesLabel: cfgStr(cfg, ['caloriesLabel'], 'Calories'), caloriesValue: caloriesRaw, caloriesAmountText: formatNutritionAmount(caloriesRaw, '', caloriesLessThan), rows, footerNote: cfgStr(cfg, ['nutritionFooterNote', 'footerNote'], DEFAULT_NUTRITION_FOOTER_NOTE), ingredientsText: cfgStr(cfg, ['ingredientsText', 'ingredients', 'IngredientsText']), }; }