diff --git a/resources/js/lib/project-dossier-document.ts b/resources/js/lib/project-dossier-document.ts new file mode 100644 index 00000000..0b4f2175 --- /dev/null +++ b/resources/js/lib/project-dossier-document.ts @@ -0,0 +1,680 @@ +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: 'InvestMoz', 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') { + 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: 'InvestMoz · relatório privado · página ', + }), + new SimpleField('PAGE'), + ], + }), + ], + }); + const doc = new Document({ + creator: 'InvestMoz', + title: `Relatório do projecto — ${model.title}`, + description: + 'Dossier consolidado exportado localmente a partir do formulário InvestMoz.', + styles: { + default: { + document: { + run: { font: 'Aptos', size: 20, color: '17211F' }, + paragraph: { spacing: { after: 120, line: 276 } }, + }, + }, + paragraphStyles: [ + { + id: 'Title', + name: 'Title', + basedOn: 'Normal', + run: { + font: 'Aptos Display', + size: 34, + bold: true, + color: '123B35', + }, + paragraph: { spacing: { before: 200, after: 220 } }, + }, + { + id: 'Heading1', + name: 'Heading 1', + basedOn: 'Normal', + next: 'Normal', + run: { + font: 'Aptos Display', + 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: 'Aptos', + 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: 'Aptos', + size: 22, + bold: true, + color: '27675C', + }, + paragraph: { + spacing: { before: 200, after: 100 }, + keepNext: true, + }, + }, + ], + }, + sections: [ + { + properties: { + page: { + size: { width: 12240, height: 15840 }, + 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) { + 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('
'); + } + return `Relatório — ${escapeHtml(model.title)}

InvestMoz
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('')}
InvestMoz · 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 }, + }); +} diff --git a/resources/js/lib/project-dossier-report.ts b/resources/js/lib/project-dossier-report.ts index 0bce18bb..c3afc3a7 100644 --- a/resources/js/lib/project-dossier-report.ts +++ b/resources/js/lib/project-dossier-report.ts @@ -1,23 +1,4 @@ import { displayValue } from './display-value.ts'; -import { - AlignmentType, - BorderStyle, - Document, - Footer, - HeadingLevel, - ImageRun, - Packer, - Paragraph, - SimpleField, - ShadingType, - Table, - TableCell, - TableLayoutType, - TableRow, - TextRun, - WidthType, -} from 'docx'; -import JSZip from 'jszip'; import { buildProjectDossierRows, type ProjectDossierRow, @@ -94,9 +75,7 @@ export type DossierReportModel = { export const MAX_DOSSIER_ATTACHMENT_COUNT = 10; export const MAX_DOSSIER_ATTACHMENT_BYTES = 15 * 1024 * 1024; export const MAX_DOSSIER_ATTACHMENT_TOTAL_BYTES = 50 * 1024 * 1024; -const MAX_EMBEDDED_TEXT_BYTES = 2 * 1024 * 1024; -const MAX_EMBEDDED_CSV_ROWS = 250; -const MAX_EMBEDDED_CSV_COLUMNS = 16; +export const MAX_EMBEDDED_TEXT_BYTES = 2 * 1024 * 1024; function text(draft: Record, key: string): string { const value = draft[key]; @@ -527,653 +506,35 @@ export function validateDossierAttachments( return null; } -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: 'InvestMoz', 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') { - 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: 'InvestMoz · relatório privado · página ', - }), - new SimpleField('PAGE'), - ], - }), - ], - }); - const doc = new Document({ - creator: 'InvestMoz', - title: `Relatório do projecto — ${model.title}`, - description: - 'Dossier consolidado exportado localmente a partir do formulário InvestMoz.', - styles: { - default: { - document: { - run: { font: 'Aptos', size: 20, color: '17211F' }, - paragraph: { spacing: { after: 120, line: 276 } }, - }, - }, - paragraphStyles: [ - { - id: 'Title', - name: 'Title', - basedOn: 'Normal', - run: { - font: 'Aptos Display', - size: 34, - bold: true, - color: '123B35', - }, - paragraph: { spacing: { before: 200, after: 220 } }, - }, - { - id: 'Heading1', - name: 'Heading 1', - basedOn: 'Normal', - next: 'Normal', - run: { - font: 'Aptos Display', - 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: 'Aptos', - 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: 'Aptos', - size: 22, - bold: true, - color: '27675C', - }, - paragraph: { - spacing: { before: 200, after: 100 }, - keepNext: true, - }, - }, - ], - }, - sections: [ - { - properties: { - page: { - size: { width: 12240, height: 15840 }, - 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] ?? '')}
`; + ...args: Parameters< + typeof import('./project-dossier-document.ts').generateProjectDossierDocx + > +): ReturnType< + typeof import('./project-dossier-document.ts').generateProjectDossierDocx +> { + const document = await import('./project-dossier-document.ts'); + return document.generateProjectDossierDocx(...args); } export async function createProjectDossierPrintHtml( - model: DossierReportModel, - attachments: DossierAttachment[], - annexesIncluded: boolean, -): Promise { - const content: string[] = []; - for (const section of model.sections) { - 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('
'); - } - return `Relatório — ${escapeHtml(model.title)}

InvestMoz
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('')}
InvestMoz · Relatório privado · ${escapeHtml(model.version)}
`; + ...args: Parameters< + typeof import('./project-dossier-document.ts').createProjectDossierPrintHtml + > +): ReturnType< + typeof import('./project-dossier-document.ts').createProjectDossierPrintHtml +> { + const document = await import('./project-dossier-document.ts'); + return document.createProjectDossierPrintHtml(...args); } 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 }, - }); + ...args: Parameters< + typeof import('./project-dossier-document.ts').generateProjectDossierPackage + > +): ReturnType< + typeof import('./project-dossier-document.ts').generateProjectDossierPackage +> { + const document = await import('./project-dossier-document.ts'); + return document.generateProjectDossierPackage(...args); } diff --git a/vite.config.ts b/vite.config.ts index 9db86d0b..8fd78b0e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,7 +9,7 @@ import { defineConfig, lazyPlugins } from 'vite-plus'; const qaBuild = process.env.INVESTMOZ_QA_BUILD === '1'; -export default defineConfig({ +export default defineConfig(({ command }) => ({ // Label the isolated QA browser without changing the active environment/build. define: qaBuild ? { 'import.meta.env.VITE_APP_NAME': JSON.stringify('InvesteMoz QA') } @@ -34,12 +34,34 @@ export default defineConfig({ inertia(), react(), babel({ + // Compile React components and hooks, not generated URLs or data utilities. + include: [/\.[jt]sx(?:$|\?)/, /[/\\]hooks[/\\].*\.[jt]s(?:$|\?)/], presets: [reactCompilerPreset()], + // Keep development debugging; production does not emit source maps. + sourceMap: command !== 'build', }), tailwindcss(), // Reuse generated URLs in QA without booting the active Laravel environment. ...(!qaBuild ? [wayfinder({ formVariants: true })] : []), ]), + build: { + rolldownOptions: { + output: { + codeSplitting: { + groups: [ + { + name: 'document-word', + test: /[/\\]node_modules[/\\]docx[/\\]/, + }, + { + name: 'document-zip', + test: /[/\\]node_modules[/\\]jszip[/\\]/, + }, + ], + }, + }, + }, + }, server: { // Laravel's public/hot points to IPv4 loopback; bind Vite there explicitly. host: '127.0.0.1', @@ -98,4 +120,4 @@ export default defineConfig({ entryPoint: 'resources/css/app.css', }, }, -}); +}));