/** * US Nutrition Facts 面板布局(与 Web src/lib/nutritionFactsLayout.ts 保持一致) */ export type NutritionDivider = 'none' | 'thin' | 'double' export type NutritionLayoutRowDef = { key: string label: string defaultUnit: string labelBold?: boolean indent?: boolean dividerAfter?: NutritionDivider } 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 export const DEFAULT_NUTRITION_FOOTER_NOTE = '* Percent Daily Values are based on a 2000 calorie diet' /** 与 Web src/lib/nutritionFactsLayout.ts 一致 */ export const NUTRITION_AMOUNT_COL_WIDTH = 64 export const NUTRITION_PCT_COL_WIDTH = 44 export const NUTRITION_BODY_FONT_SIZE = 12 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)], '') } 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 ?? ''), } }) } 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']), } }