import { execFileSync } from 'node:child_process'; import pg from 'pg'; import { storedGroup, tacoGroup } from '../groups.ts'; import { importDrafts, toNumber } from './national.ts'; import type { Draft, NutrientKey, Parsed, ValueType } from './national.ts'; // TACO, Tabela Brasileira de Composição de Alimentos, 4th extended and revised edition, 2011 (NEPA-UNICAMP), SRC-23. // The book (PDF, https://www.cfn.org.br/wp-content/uploads/2017/03/taco_4_edicao_ampliada_e_revisada.pdf) states on its // imprint: "É permitida a reprodução parcial ou total desta obra, desde que citada a fonte." There is no spreadsheet // from the publisher, so the tables are read from the PDF text laid out as on the page (Ghostscript `txtwrite`, // already on the machine; no npm package). Table 1 (composition, two page-wide blocks per food) and Table 2 (fatty acids, // saturated fat only) are read; names are Portuguese. export const tacoSourceName = 'Tabela Brasileira de Composição de Alimentos (TACO), 4th edition, NEPA-UNICAMP'; export const tacoAttribution = 'NEPA-UNICAMP. Tabela Brasileira de Composição de Alimentos - TACO. 4. ed. rev. e ampl. Campinas: NEPA-UNICAMP, 2011.'; export const tacoLanguage = 'pt'; // The book's own methods: moisture, protein (nitrogen), lipids, cholesterol, fibre, ash, minerals and fatty acids are // determined by analysis; energy "was calculated ... following the Atwater system" and carbohydrate "was calculated by // difference" (it includes dietary fibre, FD-11 `total`). const columnsA: [string, NutrientKey | null, ValueType][] = [ ['Umidade', 'water_g', 'analytic'], ['Energia kcal', 'enerc_kcal', 'calculated'], ['Energia kJ', 'enerc_kj', 'calculated'], ['Proteína', 'procnt_g', 'analytic'], ['Lipídeos', 'fat_g', 'analytic'], ['Colesterol', 'chole_mg', 'analytic'], ['Carboidrato', 'chocdf_g', 'calculated'], ['Fibra Alimentar', 'fibtg_g', 'analytic'], ['Cinzas', null, 'analytic'], ['Cálcio', 'ca_mg', 'analytic'], ['Magnésio', null, 'analytic'], ]; const columnsB: [string, NutrientKey | null][] = [['Manganês', null], ['Fósforo', null], ['Ferro', 'fe_mg'], ['Sódio', 'na_mg'], ['Potássio', 'k_mg']]; const groups = ['Cereais e derivados', 'Verduras, hortaliças e derivados', 'Frutas e derivados', 'Frutos e derivados', 'Gorduras e óleos', 'Pescados e frutos do mar', 'Carnes e derivados', 'Leite e derivados', 'Bebidas (alcoólicas e não alcoólicas)', 'Ovos e derivados', 'Produtos açucarados', 'Miscelâneas', 'Outros alimentos industrializados', 'Alimentos preparados', 'Leguminosas e derivados', 'Nozes e sementes']; const infantName = /\binfantil\b/i; // Page text of the PDF with columns kept in place. export function extractTacoText(pdfPath: string): Buffer { return execFileSync('gs', ['-q', '-dNOPAUSE', '-dBATCH', '-dSAFER', '-sDEVICE=txtwrite', '-sOutputFile=-', pdfPath], { maxBuffer: 64 * 1024 * 1024, timeout: 120_000 }); } type Token = { text: string; center: number; start: number }; const tokens = (line: string, from = 0): Token[] => [...line.slice(from).matchAll(/\S+/g)].map(match => ({ text: match[0], start: from + match.index, center: from + match.index + match[0].length / 2 })); const isValue = (text: string) => /^(\d+(,\d+)?|Tr|NA|\*)$/.test(text); // Value tokens of a row assigned to the nearest column centre; null when two tokens fall into one column. function place(row: Token[], centres: number[]): (string | null)[] | null { const out: (string | null)[] = centres.map(() => null); for (const token of row) { let best = 0; centres.forEach((centre, index) => { if (Math.abs(centre - token.center) < Math.abs(centres[best] - token.center)) best = index; }); if (out[best] !== null) return null; out[best] = token.text; } return out; } function cell(text: string | null | undefined): { value: number; zero: boolean } | null { if (text === null || text === undefined || text === 'NA' || text === '*') return null; // not applicable, not analysed if (text === 'Tr') return { value: 0, zero: true }; const value = toNumber(text.replace(',', '.')); return value === null ? null : { value, zero: false }; } type Row = { name: string; group: string | null; a: (string | null)[] | null; b: (string | null)[] | null; saturated: string | null; bad: boolean; lines: string[] }; export function parseTaco(bytes: Buffer): Parsed { const rows = new Map(); const order: string[] = []; let describes = false, table = 0, block: 'A' | 'B' | null = null, centres: number[] = [], descEnd = 0, group: string | null = null; for (const line of bytes.toString('utf8').split('\n')) { const title = /Tabela ([123])\. Composição de alimentos/.exec(line); if (title) { table = Number(title[1]); block = null; continue; } if (table !== 1 && table !== 2) continue; const trimmed = line.trim(); if (groups.includes(trimmed)) { group = trimmed; continue; } if (table === 1 && /Descrição dos alimentos/.test(line) && /\(/.test(line)) { const units = [...line.matchAll(/\([^)]*\)/g)]; centres = units.map(unit => unit.index + unit[0].length / 2); descEnd = units[0].index - 6; if (units.length !== 11) throw new Error('TACO table 1 header changed'); block = 'A'; continue; } if (table === 2 && /Descrição dos alimentos/.test(line)) { describes = true; continue; } if (table === 2 && /^\s*Alimento\s+\(g\)/.test(line)) { // The fatty-acid block with names has "Descrição dos alimentos" above its unit line; the second block has none. if (!describes) { block = null; continue; } describes = false; const units = [...line.matchAll(/\([^)]*\)/g)]; centres = units.map(unit => unit.index + unit[0].length / 2); descEnd = units[0].index - 6; block = 'A'; continue; } if (table === 1 && /^\s*Alimento\s+\(/.test(line)) { const units = [...line.matchAll(/\([^)]*\)/g)]; if (units.length < 5) throw new Error('TACO table 1 minerals header changed'); centres = units.map(unit => unit.index + unit[0].length / 2); block = 'B'; continue; } const head = /^(\s*)(\d{1,3})(\s+)/.exec(line); if (!head || !block) continue; const id = head[2]; const after = head[1].length + head[2].length + head[3].length; let name = '', values: Token[]; if (block === 'A') { const all = tokens(line, after); const split = all.findIndex(token => token.start >= descEnd && isValue(token.text)); if (split < 0) continue; name = line.slice(after, all[split].start).replace(/\s+/g, ' ').trim(); values = all.slice(split); } else values = tokens(line, after); if (!values.length || !values.every(token => isValue(token.text))) continue; const placed = place(values, centres); const key = `${table}`; let row = rows.get(id); if (!row) { row = { name: '', group, a: null, b: null, saturated: null, bad: false, lines: [] }; rows.set(id, row); order.push(id); } row.lines.push(`${key}${block}: ${trimmed}`); if (!placed) { row.bad = true; continue; } if (table === 2) { row.saturated = placed[0]; if (name && !row.name) row.name = name; continue; } if (block === 'A') { if (row.a) row.bad = true; row.a = placed; row.name = name; row.group = group; } else { if (row.b) row.bad = true; row.b = placed; } } const out: Parsed = { drafts: [], skipped: [] }; for (const id of order) { const row = rows.get(id)!; if (row.bad || !row.a || !row.b || !row.name) { out.skipped.push({ recordId: id, reason: row.bad ? 'ambiguous page columns' : 'incomplete table rows' }); continue; } if (infantName.test(row.name)) { out.skipped.push({ recordId: id, reason: 'baby food (C-23)' }); continue; } const values: Draft['values'] = {}; const kinds: Draft['kinds'] = {}; const put = (key: NutrientKey, text: string | null, type: ValueType) => { const parsed = cell(text); if (!parsed) return; values[key] = parsed.value; kinds[key] = parsed.zero ? 'assumed_zero' : type; }; columnsA.forEach(([, key, type], index) => { if (key) put(key, row.a![index], type); }); columnsB.forEach(([, key], index) => { if (key) put(key, row.b![index], 'analytic'); }); put('fasat_g', row.saturated, 'analytic'); out.drafts.push({ recordId: id, name: row.name, basis: '100g', carbohydrateDefinition: 'total', values, kinds, measures: [], group: storedGroup(tacoGroup(row.group)), added: { name_language: tacoLanguage, taco_group: row.group }, raw: Buffer.from(JSON.stringify({ lines: row.lines })), }); } return out; } export async function importTacoFile(client: pg.Client, path: string, releasedAt: string) { return importDrafts(client, { src: 'SRC-23', market: 'BR', family: 'open' }, parseTaco(extractTacoText(path)), releasedAt, tacoSourceName); }