import { AlignmentType, BorderStyle, Document, Footer, HeadingLevel, ImageRun, Packer, Paragraph, SimpleField, ShadingType, Table, TableCell, TableLayoutType, TableRow, TextRun, WidthType, } from 'docx'; import JSZip from 'jszip'; import { classifyDossierAttachment, MAX_EMBEDDED_TEXT_BYTES, type DossierAttachment, type DossierReportModel, type DossierReportSection, } from './project-dossier-report.ts'; const MAX_EMBEDDED_CSV_ROWS = 250; const MAX_EMBEDDED_CSV_COLUMNS = 16; function parseDelimited(textValue: string, delimiter: string): string[][] { const rows: string[][] = []; let row: string[] = []; let cell = ''; let quoted = false; for (let index = 0; index < textValue.length; index++) { const char = textValue[index]; if (char === '"') { if (quoted && textValue[index + 1] === '"') { cell += '"'; index++; } else quoted = !quoted; } else if (!quoted && char === delimiter) { row.push(cell); cell = ''; } else if (!quoted && (char === '\n' || char === '\r')) { if (char === '\r' && textValue[index + 1] === '\n') index++; row.push(cell); rows.push(row); row = []; cell = ''; } else cell += char; } if (cell || row.length) { row.push(cell); rows.push(row); } const cleaned = rows.map((values) => values.map((value) => value.replace(/^\uFEFF/, '')), ); return cleaned .slice(0, MAX_EMBEDDED_CSV_ROWS) .map((values) => values.slice(0, MAX_EMBEDDED_CSV_COLUMNS)); } function csvDelimiter(value: string): string { const first = value.split(/\r?\n/, 1)[0] ?? ''; const semicolons = (first.match(/;/g) ?? []).length; const commas = (first.match(/,/g) ?? []).length; const tabs = (first.match(/\t/g) ?? []).length; return tabs > commas && tabs > semicolons ? '\t' : semicolons > commas ? ';' : ','; } function safePackageName(name: string, used: Set): string { const basename = name.split(/[\\/]/).pop() ?? 'anexo'; const cleaned = basename .normalize('NFKC') .replace(/[^\p{L}\p{N}._-]+/gu, '_') .replace(/^\.+/, '') .slice(0, 120) || 'anexo'; let candidate = cleaned; let suffix = 2; while (used.has(candidate.toLocaleLowerCase('en'))) { const dot = cleaned.lastIndexOf('.'); candidate = dot > 0 ? `${cleaned.slice(0, dot)}_${suffix}${cleaned.slice(dot)}` : `${cleaned}_${suffix}`; suffix++; } used.add(candidate.toLocaleLowerCase('en')); return candidate; } function attachmentStatus( attachment: DossierAttachment, annexesIncluded: boolean, ): string { if (!attachment.include) return 'Não seleccionado para exportação'; if (!annexesIncluded) return 'Original no pacote ZIP; secção de anexos não foi incluída no relatório'; const kind = classifyDossierAttachment(attachment.file); if (kind === 'image') return `Fotografia incorporada${attachment.caption ? `; legenda: ${attachment.caption}` : ''}; original também no pacote ZIP`; if (kind === 'csv') return 'Tabela incorporada; original também no pacote ZIP'; if (kind === 'text') return 'Texto incorporado; original também no pacote ZIP'; return 'Conteúdo não incorporado; original preservado no pacote ZIP'; } function tableForDocx(headers: string[], data: string[][]): Table { const widths = headers.length === 4 ? [1850, 1850, 3450, 2210] : headers.length === 3 ? [1900, 4400, 3060] : headers.map(() => Math.floor(9360 / headers.length)); const tableRows = [headers, ...data].map( (values, rowIndex) => new TableRow({ tableHeader: rowIndex === 0, cantSplit: true, children: headers.map( (_, cellIndex) => new TableCell({ width: { size: widths[cellIndex] ?? 1500, type: WidthType.DXA, }, shading: rowIndex === 0 ? { type: ShadingType.CLEAR, fill: '123B35', } : undefined, margins: { top: 80, bottom: 80, left: 100, right: 100, }, children: [ new Paragraph({ children: [ new TextRun({ text: values[cellIndex] ?? '', bold: rowIndex === 0, color: rowIndex === 0 ? 'FFFFFF' : '17211F', size: 18, }), ], }), ], }), ), }), ); return new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE }, columnWidths: widths, layout: TableLayoutType.FIXED, borders: Object.fromEntries( [ 'top', 'bottom', 'left', 'right', 'insideHorizontal', 'insideVertical', ].map((side) => [ side, { style: BorderStyle.SINGLE, size: 4, color: 'CBD5D1' }, ]), ) as never, }); } function sectionTable(section: DossierReportSection): Table | null { if (!section.rows.length) return null; return tableForDocx( ['Campo', 'Conteúdo', 'Classificação / origem'], section.rows.map((row) => [ row.field, row.value, `${row.classification}. ${row.origin}`, ]), ); } async function imageRuns( attachment: DossierAttachment, ): Promise<(Paragraph | Table)[]> { const file = attachment.file; const type = file.type === 'image/jpeg' ? 'jpg' : 'png'; const data = new Uint8Array(await file.arrayBuffer()); let sourceWidth = 0; let sourceHeight = 0; if (type === 'png') { if ( data.length < 24 || data[0] !== 0x89 || data[1] !== 0x50 || data[2] !== 0x4e || data[3] !== 0x47 || data[12] !== 0x49 || data[13] !== 0x48 || data[14] !== 0x44 || data[15] !== 0x52 ) { throw new Error( `A fotografia ${file.name} não tem um cabeçalho PNG válido.`, ); } sourceWidth = new DataView( data.buffer, data.byteOffset, data.byteLength, ).getUint32(16); sourceHeight = new DataView( data.buffer, data.byteOffset, data.byteLength, ).getUint32(20); } else { if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8) throw new Error( `A fotografia ${file.name} não tem um cabeçalho JPEG válido.`, ); const frameMarkers = new Set([ 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, ]); for (let offset = 2; offset + 8 < data.length;) { if (data[offset] !== 0xff) { offset++; continue; } while (data[offset] === 0xff) offset++; const marker = data[offset++]; if (marker === 0xd9 || marker === 0xda) break; if ( [0x01, 0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7].includes( marker, ) ) continue; const segmentLength = (data[offset] << 8) | data[offset + 1]; if (segmentLength < 2 || offset + segmentLength > data.length) break; if (frameMarkers.has(marker)) { sourceHeight = (data[offset + 3] << 8) | data[offset + 4]; sourceWidth = (data[offset + 5] << 8) | data[offset + 6]; break; } offset += segmentLength; } } if ( !sourceWidth || !sourceHeight || sourceWidth * sourceHeight > 40_000_000 || sourceWidth > 12_000 || sourceHeight > 12_000 ) { throw new Error( `As dimensões da fotografia ${file.name} são inválidas ou excedem o limite de segurança.`, ); } const scale = Math.min(1, 520 / sourceWidth, 620 / sourceHeight); const width = Math.max(1, Math.round(sourceWidth * scale)); const height = Math.max(1, Math.round(sourceHeight * scale)); return [ new Paragraph({ alignment: AlignmentType.CENTER, children: [ new ImageRun({ data, type, transformation: { width, height }, altText: { name: file.name, description: attachment.caption || file.name, title: file.name, }, }), ], }), new Paragraph({ alignment: AlignmentType.CENTER, children: [ new TextRun({ text: attachment.caption || file.name, italics: true, size: 18, }), ], }), ]; } async function annexDocxBlocks( attachments: DossierAttachment[], ): Promise<(Paragraph | Table)[]> { const blocks: (Paragraph | Table)[] = []; for (const attachment of attachments.filter((item) => item.include)) { const kind = classifyDossierAttachment(attachment.file); blocks.push( new Paragraph({ text: attachment.file.name, heading: HeadingLevel.HEADING_3, keepNext: true, }), ); if (kind === 'image') { blocks.push(...(await imageRuns(attachment))); } else if (kind === 'text') { const contents = (await attachment.file.text()).replace( /^\uFEFF/, '', ); for (const line of contents.split(/\r?\n/).slice(0, 1000)) blocks.push( new Paragraph({ children: [new TextRun({ text: line || ' ' })], }), ); } else if (kind === 'csv') { const contents = (await attachment.file.text()).replace( /^\uFEFF/, '', ); const rows = parseDelimited(contents, csvDelimiter(contents)); const headers = rows[0] ?? []; const data = rows.slice(1); if (headers.length) blocks.push(tableForDocx(headers, data)); if (contents.split(/\r?\n/).length > MAX_EMBEDDED_CSV_ROWS) blocks.push( new Paragraph( 'A tabela foi truncada para 250 linhas no relatório; o original completo acompanha o pacote ZIP.', ), ); } else { blocks.push( new Paragraph( 'Este formato não foi incorporado ao DOCX. Para preservar o original, exporte também o pacote ZIP de anexos com o respectivo índice.', ), ); } } return blocks; } export async function generateProjectDossierDocx( model: DossierReportModel, attachments: DossierAttachment[], annexesIncluded: boolean, ): Promise { const children: (Paragraph | Table)[] = [ new Paragraph({ text: 'InvesteMoz', heading: HeadingLevel.HEADING_2 }), new Paragraph({ text: 'Relatório consolidado do projecto', heading: HeadingLevel.TITLE, }), new Paragraph({ children: [ new TextRun({ text: model.title, bold: true, size: 30 }), ], }), new Paragraph({ children: [ new TextRun({ text: `Gerado em ${new Date(model.generatedAt).toLocaleString('pt-MZ')} · versão ${model.version}`, color: '52615D', size: 20, }), ], }), new Paragraph({ children: [ new TextRun({ text: 'Documento privado gerado neste dispositivo. A exportação não publica nem partilha o conteúdo.', bold: true, color: '8A4B08', }), ], }), ]; for (const section of model.sections) { if (section.id === 'annexes') { if (!attachments.some((item) => item.include)) { children.push( new Paragraph({ text: 'Anexos: não foram seleccionados ficheiros.', spacing: { before: 80, after: 80 }, }), ); continue; } children.push( new Paragraph({ text: section.title, heading: HeadingLevel.HEADING_1, }), ); if (annexesIncluded) children.push(...(await annexDocxBlocks(attachments))); if (!attachments.some((item) => item.include)) children.push(new Paragraph('Nenhum anexo foi seleccionado.')); if ( attachments.some( (item) => item.include && classifyDossierAttachment(item.file) === 'original-only', ) ) children.push( new Paragraph( 'Os ficheiros que não podem ser incorporados de forma legível devem ser exportados também no pacote ZIP, com índice.', ), ); continue; } children.push( new Paragraph({ text: section.title, heading: HeadingLevel.HEADING_1, keepNext: true, }), ); for (const paragraph of section.paragraphs) children.push( new Paragraph({ children: [new TextRun({ text: paragraph })] }), ); const table = sectionTable(section); if (table) children.push(table); if (!section.rows.length && !section.paragraphs.length) children.push( new Paragraph('Não há informação fornecida nesta secção.'), ); } const footer = new Footer({ children: [ new Paragraph({ alignment: AlignmentType.RIGHT, children: [ new TextRun({ text: 'InvesteMoz · ' + model.title.slice(0, 60) + ' · privado · página ', size: 18, }), new SimpleField('PAGE'), ], }), ], }); const doc = new Document({ creator: 'InvesteMoz', title: `Relatório do projecto — ${model.title}`, description: 'Dossier consolidado exportado localmente a partir do formulário InvesteMoz.', styles: { default: { document: { run: { font: 'Arial', size: 20, color: '17211F' }, paragraph: { spacing: { after: 120, line: 276 } }, }, }, paragraphStyles: [ { id: 'Title', name: 'Title', basedOn: 'Normal', run: { font: 'Arial', size: 34, bold: true, color: '123B35', }, paragraph: { spacing: { before: 200, after: 220 } }, }, { id: 'Heading1', name: 'Heading 1', basedOn: 'Normal', next: 'Normal', run: { font: 'Arial', size: 26, bold: true, color: '123B35', }, paragraph: { spacing: { before: 320, after: 150 }, keepNext: true, }, }, { id: 'Heading2', name: 'Heading 2', basedOn: 'Normal', next: 'Normal', run: { font: 'Arial', size: 24, bold: true, color: '27675C', }, paragraph: { spacing: { before: 260, after: 120 }, keepNext: true, }, }, { id: 'Heading3', name: 'Heading 3', basedOn: 'Normal', next: 'Normal', run: { font: 'Arial', size: 22, bold: true, color: '27675C', }, paragraph: { spacing: { before: 200, after: 100 }, keepNext: true, }, }, ], }, sections: [ { properties: { page: { size: { width: 11906, height: 16838 }, margin: { top: 900, right: 960, bottom: 900, left: 960, footer: 450, header: 450, }, }, }, footers: { default: footer }, children, }, ], }); return Packer.toBlob(doc); } function escapeHtml(value: string): string { return value .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } async function fileDataUrl(file: File): Promise { const bytes = new Uint8Array(await file.arrayBuffer()); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); return `data:${file.type};base64,${btoa(binary)}`; } function htmlTable(headers: string[], rows: string[][]): string { return `${headers.map((value) => ``).join('')}${rows.map((row) => `${headers.map((_, index) => ``).join('')}`).join('')}
${escapeHtml(value)}
${escapeHtml(row[index] ?? '')}
`; } export async function createProjectDossierPrintHtml( model: DossierReportModel, attachments: DossierAttachment[], annexesIncluded: boolean, ): Promise { const content: string[] = []; for (const section of model.sections) { if ( section.id === 'annexes' && !attachments.some((item) => item.include) ) { content.push('

Anexos: não foram seleccionados ficheiros.

'); continue; } content.push(`

${escapeHtml(section.title)}

`); for (const paragraph of section.paragraphs) content.push(`

${escapeHtml(paragraph)}

`); if (section.rows.length) content.push( htmlTable( ['Campo', 'Conteúdo', 'Classificação / origem'], section.rows.map((row) => [ row.field, row.value, `${row.classification}. ${row.origin}`, ]), ), ); if (section.id === 'annexes') { const selected = attachments.filter((item) => item.include); if (!selected.length) content.push('

Nenhum anexo foi seleccionado.

'); if (!annexesIncluded) content.push( '

A secção de anexos foi omitida do relatório. Os originais seleccionados continuam no pacote ZIP.

', ); else for (const attachment of selected) { const kind = classifyDossierAttachment(attachment.file); content.push( `

${escapeHtml(attachment.file.name)}

`, ); if (kind === 'image') content.push( `
${escapeHtml(attachment.caption || attachment.file.name)}
${escapeHtml(attachment.caption || attachment.file.name)}
`, ); else if (kind === 'text') content.push( `
${escapeHtml((await attachment.file.text()).slice(0, MAX_EMBEDDED_TEXT_BYTES))}
`, ); else if (kind === 'csv') { const source = (await attachment.file.text()).slice( 0, MAX_EMBEDDED_TEXT_BYTES, ); const rows = parseDelimited( source, csvDelimiter(source), ); content.push(htmlTable(rows[0] ?? [], rows.slice(1))); } else content.push( '

Conteúdo original não incorporado nesta versão PDF. Para obter o original, exporte também o pacote ZIP com índice.

', ); } } if ( !section.rows.length && !section.paragraphs.length && section.id !== 'annexes' ) content.push('

Não há informação fornecida nesta secção.

'); content.push('
'); } // Page margin boxes reserve footer space on every printed page. A fixed // body element can overlap fragmented tables in Chromium's print layout. const printFooter = JSON.stringify( `InvesteMoz · Relatório privado · ${model.version}`, ).replaceAll('<', '\\3c '); return `Relatório — ${escapeHtml(model.title)}

InvesteMoz
Relatório consolidado do projecto

${escapeHtml(model.title)}
Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}
Versão/revisão: ${escapeHtml(model.version)}

Documento privado. O portal não publica nem partilha este conteúdo.

${content.join('')}
InvesteMoz · Relatório privado · ${escapeHtml(model.version)}
`; } export async function generateProjectDossierPackage( docxBlob: Blob, attachments: DossierAttachment[], annexesIncluded: boolean, ): Promise { const zip = new JSZip(); zip.file('Relatorio/relatorio-projecto-investmoz.docx', docxBlob); const usedNames = new Set(); const selected = attachments.filter((item) => item.include); const index = [ 'ÍNDICE DO PACOTE DE RELATÓRIO INVESTMOZ', '', 'O relatório e os anexos são privados e foram seleccionados localmente pelo utilizador.', '', ].join('\n'); const records: string[] = []; for (const attachment of selected) { const filename = safePackageName(attachment.file.name, usedNames); zip.file(`Anexos/${filename}`, attachment.file, { binary: true }); records.push( `Ficheiro: ${filename}\nTamanho: ${attachment.file.size} bytes\nTipo: ${attachment.file.type || 'desconhecido'}\nSituação: ${attachmentStatus(attachment, annexesIncluded)}\nLegenda: ${attachment.caption || 'Não indicada'}\n`, ); } zip.file( 'indice-anexos.txt', `${index}${records.length ? records.join('\n') : 'Não foram seleccionados anexos.\n'}`, ); return zip.generateAsync({ type: 'blob', mimeType: 'application/zip', compression: 'DEFLATE', compressionOptions: { level: 6 }, }); }