@@ -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>): 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<Blob> {
|
||||
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<string> {
|
||||
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 `<table><thead><tr>${headers.map((value) => `<th>${escapeHtml(value)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, index) => `<td>${escapeHtml(row[index] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
export async function createProjectDossierPrintHtml(
|
||||
model: DossierReportModel,
|
||||
attachments: DossierAttachment[],
|
||||
annexesIncluded: boolean,
|
||||
): Promise<string> {
|
||||
const content: string[] = [];
|
||||
for (const section of model.sections) {
|
||||
content.push(`<section><h2>${escapeHtml(section.title)}</h2>`);
|
||||
for (const paragraph of section.paragraphs)
|
||||
content.push(`<p>${escapeHtml(paragraph)}</p>`);
|
||||
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('<p>Nenhum anexo foi seleccionado.</p>');
|
||||
if (!annexesIncluded)
|
||||
content.push(
|
||||
'<p>A secção de anexos foi omitida do relatório. Os originais seleccionados continuam no pacote ZIP.</p>',
|
||||
);
|
||||
else
|
||||
for (const attachment of selected) {
|
||||
const kind = classifyDossierAttachment(attachment.file);
|
||||
content.push(
|
||||
`<h3>${escapeHtml(attachment.file.name)}</h3>`,
|
||||
);
|
||||
if (kind === 'image')
|
||||
content.push(
|
||||
`<figure><img alt="${escapeHtml(attachment.caption || attachment.file.name)}" src="${await fileDataUrl(attachment.file)}"><figcaption>${escapeHtml(attachment.caption || attachment.file.name)}</figcaption></figure>`,
|
||||
);
|
||||
else if (kind === 'text')
|
||||
content.push(
|
||||
`<pre>${escapeHtml((await attachment.file.text()).slice(0, MAX_EMBEDDED_TEXT_BYTES))}</pre>`,
|
||||
);
|
||||
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(
|
||||
'<p>Conteúdo original não incorporado nesta versão PDF. Para obter o original, exporte também o pacote ZIP com índice.</p>',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!section.rows.length &&
|
||||
!section.paragraphs.length &&
|
||||
section.id !== 'annexes'
|
||||
)
|
||||
content.push('<p>Não há informação fornecida nesta secção.</p>');
|
||||
content.push('</section>');
|
||||
}
|
||||
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
|
||||
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}th:first-child,td:first-child{width:22%}th:last-child,td:last-child{width:32%;font-size:9pt;color:#40524d}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}}
|
||||
</style></head><body><h1>InvestMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvestMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
|
||||
}
|
||||
|
||||
export async function generateProjectDossierPackage(
|
||||
docxBlob: Blob,
|
||||
attachments: DossierAttachment[],
|
||||
annexesIncluded: boolean,
|
||||
): Promise<Blob> {
|
||||
const zip = new JSZip();
|
||||
zip.file('Relatorio/relatorio-projecto-investmoz.docx', docxBlob);
|
||||
const usedNames = new Set<string>();
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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<string, string | string[]>, 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>): 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<Blob> {
|
||||
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<string> {
|
||||
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 `<table><thead><tr>${headers.map((value) => `<th>${escapeHtml(value)}</th>`).join('')}</tr></thead><tbody>${rows.map((row) => `<tr>${headers.map((_, index) => `<td>${escapeHtml(row[index] ?? '')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;
|
||||
...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<string> {
|
||||
const content: string[] = [];
|
||||
for (const section of model.sections) {
|
||||
content.push(`<section><h2>${escapeHtml(section.title)}</h2>`);
|
||||
for (const paragraph of section.paragraphs)
|
||||
content.push(`<p>${escapeHtml(paragraph)}</p>`);
|
||||
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('<p>Nenhum anexo foi seleccionado.</p>');
|
||||
if (!annexesIncluded)
|
||||
content.push(
|
||||
'<p>A secção de anexos foi omitida do relatório. Os originais seleccionados continuam no pacote ZIP.</p>',
|
||||
);
|
||||
else
|
||||
for (const attachment of selected) {
|
||||
const kind = classifyDossierAttachment(attachment.file);
|
||||
content.push(
|
||||
`<h3>${escapeHtml(attachment.file.name)}</h3>`,
|
||||
);
|
||||
if (kind === 'image')
|
||||
content.push(
|
||||
`<figure><img alt="${escapeHtml(attachment.caption || attachment.file.name)}" src="${await fileDataUrl(attachment.file)}"><figcaption>${escapeHtml(attachment.caption || attachment.file.name)}</figcaption></figure>`,
|
||||
);
|
||||
else if (kind === 'text')
|
||||
content.push(
|
||||
`<pre>${escapeHtml((await attachment.file.text()).slice(0, MAX_EMBEDDED_TEXT_BYTES))}</pre>`,
|
||||
);
|
||||
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(
|
||||
'<p>Conteúdo original não incorporado nesta versão PDF. Para obter o original, exporte também o pacote ZIP com índice.</p>',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!section.rows.length &&
|
||||
!section.paragraphs.length &&
|
||||
section.id !== 'annexes'
|
||||
)
|
||||
content.push('<p>Não há informação fornecida nesta secção.</p>');
|
||||
content.push('</section>');
|
||||
}
|
||||
return `<!doctype html><html lang="pt-MZ"><head><meta charset="utf-8"><title>Relatório — ${escapeHtml(model.title)}</title><style>
|
||||
@page{size:A4;margin:18mm 17mm 20mm}*{box-sizing:border-box}body{font:10.5pt/1.45 Arial,sans-serif;color:#17211f;margin:0}h1{font-size:24pt;color:#123b35;margin:0 0 7mm}h2{font-size:16pt;color:#123b35;border-bottom:1px solid #b8c9c3;padding-bottom:2mm;margin:8mm 0 3mm;break-after:avoid}h3{font-size:12pt;color:#27675c;break-after:avoid}p{white-space:pre-wrap}table{width:100%;border-collapse:collapse;table-layout:fixed;margin:3mm 0 5mm;break-inside:auto}th,td{border:1px solid #cbd5d1;padding:5px 6px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#123b35;color:white}tr{break-inside:avoid}th:first-child,td:first-child{width:22%}th:last-child,td:last-child{width:32%;font-size:9pt;color:#40524d}.private{padding:3mm;background:#fff4df;border-left:3px solid #b66a0c;font-weight:bold}section{break-inside:auto}figure{margin:5mm auto;text-align:center;break-inside:avoid}img{max-width:100%;max-height:210mm;object-fit:contain}figcaption{font-style:italic;color:#52615d}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:9.5pt/1.4 Arial,sans-serif}@media print{a{color:inherit;text-decoration:none}.no-print{display:none}}
|
||||
</style></head><body><h1>InvestMoz<br>Relatório consolidado do projecto</h1><p><strong>${escapeHtml(model.title)}</strong><br>Gerado em ${escapeHtml(new Date(model.generatedAt).toLocaleString('pt-MZ'))}<br>Versão/revisão: ${escapeHtml(model.version)}</p><p class="private">Documento privado. O portal não publica nem partilha este conteúdo.</p>${content.join('')}<footer><small>InvestMoz · Relatório privado · ${escapeHtml(model.version)}</small></footer><script>window.addEventListener('load',()=>setTimeout(()=>window.print(),350));</script></body></html>`;
|
||||
...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<Blob> {
|
||||
const zip = new JSZip();
|
||||
zip.file('Relatorio/relatorio-projecto-investmoz.docx', docxBlob);
|
||||
const usedNames = new Set<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
+24
-2
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user