Download generated DOCX from UI

This commit is contained in:
Fernando 2023-05-12 16:01:00 -05:00
parent 2938dd4dce
commit 4448539307
21 changed files with 711 additions and 4 deletions

View File

@ -317,6 +317,7 @@ const certificatePersonName = new Paragraph({
font: "Arial", font: "Arial",
allCaps: true, allCaps: true,
size: 48, size: 48,
underline: {},
}), }),
], ],
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,

441
src/certs/MATPEL.ts Normal file
View File

@ -0,0 +1,441 @@
import {
Document, Packer, Paragraph, PageOrientation, ImageRun,
HorizontalPositionRelativeFrom, VerticalPositionRelativeFrom,
FrameAnchorType,
HorizontalPositionAlign,
VerticalPositionAlign,
TextRun,
AlignmentType,
} from "docx";
import * as QR from "qrcode";
import { Matpel, cm, cmText, cmToEmu, getImage, getMatpelHours, getMatpelLabel } from "./utils";
const imgFondoDoc = getImage({
name: "fondo_certificado.png",
height: 20.97,
width: 29.8,
horizontalOffset: 0,
verticalOffset: 0,
behindDocument: true,
});
const imgMatpel = getImage({
name: "matpel-logo.png",
height: 2.81,
width: 2.81,
horizontalOffset: 0.7,
verticalOffset: 0.7,
});
const imgCIP = getImage({
name: "colegio_ingenieros_logo.png",
height: 2.15,
width: 2.15,
horizontalOffset: 0.91,
verticalOffset: 3.64,
});
const imgCEE = getImage({
name: "cee_logo.png",
height: 2.22,
width: 3.11,
horizontalOffset: 0.29,
verticalOffset: 18,
});
const imgMTC = getImage({
name: "mtc_logo.png",
height: 1.3,
width: 4.08,
horizontalOffset: 5.13,
verticalOffset: 19.33,
});
const imgEEG = getImage({
name: "eeg_f_logo.png",
height: 4.39,
width: 6.4,
horizontalOffset: 23.35,
verticalOffset: 0,
});
const imgOSHA = getImage({
name: "osha_logo.png",
height: 1.55,
width: 4.46,
horizontalOffset: 24.34,
verticalOffset: 4.95,
});
const imgAguila = getImage({
name: "aguila_logo.png",
height: 2.62,
width: 2.68,
horizontalOffset: 25.45,
verticalOffset: 6.57,
});
const imgMichigan = getImage({
name: "michigan_logo.png",
height: 2.47,
width: 2.6,
horizontalOffset: 25.59,
verticalOffset: 9.54,
});
const imgCEEM = getImage({
name: "ceem_logo.jpg",
height: 1,
width: 3.99,
horizontalOffset: 24.97,
verticalOffset: 12.5,
});
const imgEATE = getImage({
name: "eate_logo.jpg",
height: 2.21,
width: 3.28,
horizontalOffset: 25.54,
verticalOffset: 14.09,
});
export type CertData = {
matpel: Matpel,
/**
* Full name in format: "FIRST_NAMES LAST_NAME_1 LAST_NAME_2"
*/
personFullName: string,
/**
* DNI as a string.
*
* E.g.: "04123523"
*/
personDni: string,
/**
* Cert code as a string. Must have length = 4.
*
* E.g.: "0322"
*/
certCode: string,
/**
* Year of emission as a string.
*
* E.g.: "2023"
*/
certYear: string,
/**
* Month of emission.
*
* E.g.: "05"
*/
certMonth: string,
/**
* Day of emission.
*
* E.g.: "23"
*/
certDay: string,
}
/**
* Generates a certificate in format DOCX and in-place.
*
* @param props Data to use in the certificate
* @returns A buffer of the DOCX document
*/
export async function matpelCert(props: CertData): Promise<Buffer> {
const certificateName = new Paragraph({
frame: {
position: {
x: cmText(3.13),
y: cmText(1.9),
},
width: cmText(14.28),
height: cmText(3.19),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: `CAPACITACIÓN EN MANEJO; MANIPULACIÓN Y TRANSPORTE DE MATERIALES Y RESIDUOS PELIGROSOS - ${getMatpelLabel(props.matpel)}`,
bold: true,
font: "Arial",
size: 32,
}),
],
alignment: AlignmentType.CENTER,
});
const certificatePersonName = new Paragraph({
frame: {
position: {
x: cmText(0),
y: cmText(6.5),
},
width: cmText(23),
height: cmText(1.5),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: props.personFullName.toUpperCase(),
bold: true,
font: "Arial",
allCaps: true,
size: 48,
underline: {},
}),
],
alignment: AlignmentType.CENTER,
});
// "Se expide el presente certificado a:"
const certificateExpedite = new Paragraph({
frame: {
position: {
x: cmText(2.85),
y: cmText(5.15),
},
width: cmText(14.28),
height: cmText(1),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: "Se expide el presente certificado a:",
font: "Arial",
size: 22,
}),
],
alignment: AlignmentType.CENTER,
});
// N° XXXX-202X-EEG
const certificateCode = new Paragraph({
frame: {
position: {
x: cmText(17.1),
y: cmText(5.35),
},
width: cmText(3.68),
height: cmText(0.7),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: `${props.certCode}-${props.certYear}-EEG`,
font: "Arial",
size: 20,
}),
],
alignment: AlignmentType.CENTER,
});
// Identificado con dni ...
const certificateBody = new Paragraph({
frame: {
position: {
x: cmText(1.51),
y: cmText(8.35),
},
width: cmText(20.08),
height: cmText(3.7),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: "Identificado con DNI N° ",
font: "Arial",
size: 24,
}),
new TextRun({
text: props.personDni,
font: "Arial",
size: 24,
bold: true,
}),
new TextRun({
text: ", al haber aprobado el curso de capacitación sobre ",
font: "Arial",
size: 24,
}),
new TextRun({
text: `MANEJO DE MATERIALES Y RESIDUOS PELIGROSOS - ${getMatpelLabel(props.matpel)}`,
font: "Arial",
size: 24,
bold: true,
}),
new TextRun({
text: `, según lo estipulado en la Ley N°28256 (ley que regula el Transporte de Materiales y Residuos Peligrosos) y Decreto Supremo N° 021-2008-MTC (Reglamento Nacional de Transporte Terrestre de Materiales y Residuos Peligrosos) con una duración de ${getMatpelHours(props.matpel)} horas lectivas.`,
font: "Arial",
size: 24,
}),
],
alignment: AlignmentType.JUSTIFIED,
});
// Se expide certificado...
const certificateFinishLabel = new Paragraph({
frame: {
position: {
x: cmText(1.52),
y: cmText(11.25),
},
width: cmText(20.1),
height: cmText(0.8),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: "Se expide certificado para los fines que se estime conveniente.",
font: "Arial",
size: 24,
}),
],
alignment: AlignmentType.CENTER,
});
// Fecha de Emision: ...
const certificateDate = new Paragraph({
frame: {
position: {
x: cmText(16),
y: cmText(16.5),
},
width: cmText(9),
height: cmText(1.4),
anchor: {
horizontal: FrameAnchorType.MARGIN,
vertical: FrameAnchorType.MARGIN,
},
alignment: {
x: HorizontalPositionAlign.CENTER,
y: VerticalPositionAlign.TOP,
},
},
children: [
new TextRun({
text: `Fecha de Emisión:\t\t${props.certDay} / ${props.certMonth} / ${props.certYear}`,
font: "Arial",
size: 20,
break: 1,
}),
new TextRun({
text: `Fecha de Expiración:\t\t${props.certDay} / ${props.certMonth} / ${parseInt(props.certYear, 10) + 1}`,
font: "Arial",
size: 20,
break: 1,
}),
],
alignment: AlignmentType.LEFT,
});
const qr = await QR.toDataURL(`https://www.eegsac.com/alumnoscertificados.php?DNI=${props.personDni}`, {margin: 1});
const imgQR = new ImageRun({
data: qr,
transformation: {
height: cm(2.47),
width: cm(2.47),
},
floating: {
zIndex: 1,
horizontalPosition: {
relative: HorizontalPositionRelativeFrom.LEFT_MARGIN,
offset: cmToEmu(26.3),
},
verticalPosition: {
relative: VerticalPositionRelativeFrom.TOP_MARGIN,
offset: cmToEmu(16.48),
},
},
});
const doc = new Document({
sections: [
{
properties: {
page: {
size: {
orientation: PageOrientation.LANDSCAPE,
},
},
},
children: [
certificateName,
certificatePersonName,
certificateExpedite,
certificateCode,
certificateBody,
certificateFinishLabel,
certificateDate,
new Paragraph({
children: [
imgFondoDoc,
imgMatpel,
imgCIP,
imgCEE,
imgMTC,
imgEEG,
imgOSHA,
imgAguila,
imgMichigan,
imgCEEM,
imgEATE,
imgQR,
],
}),
],
},
],
});
// Return document as a buffer
return await Packer.toBuffer(doc);
}

5
src/certs/README.md Normal file
View File

@ -0,0 +1,5 @@
# Certs
This folder contains files that generate certificates in DOCX format.

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View File

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

View File

Before

Width:  |  Height:  |  Size: 153 KiB

After

Width:  |  Height:  |  Size: 153 KiB

View File

Before

Width:  |  Height:  |  Size: 245 KiB

After

Width:  |  Height:  |  Size: 245 KiB

View File

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

103
src/certs/utils.ts Normal file
View File

@ -0,0 +1,103 @@
import { HorizontalPositionRelativeFrom, ImageRun, VerticalPositionRelativeFrom, convertMillimetersToTwip } from "docx";
import * as fs from "fs";
import { join } from "path";
// To define centimeters in a image's size
export function cm(centimeters: number) {
return convertMillimetersToTwip((100 * centimeters) / 150);
}
// To define centimeters in a floating image's position
export function cmToEmu(cm: number) {
return Math.round(cm * 360000);
}
// To define centimeters in a text
export function cmText(cm: number) {
return Math.round(cm * 567);
}
type ImgConfig = {
name: string,
height: number,
width: number,
horizontalOffset: number,
verticalOffset: number,
behindDocument?: boolean,
}
export function getImage(data: ImgConfig): ImageRun {
return new ImageRun({
data: fs.readFileSync(join(__dirname, "img", data.name)),
transformation: {
height: cm(data.height),
width: cm(data.width),
},
floating: {
zIndex: 0,
horizontalPosition: {
relative: HorizontalPositionRelativeFrom.LEFT_MARGIN,
offset: cmToEmu(data.horizontalOffset),
},
verticalPosition: {
relative: VerticalPositionRelativeFrom.TOP_MARGIN,
offset: cmToEmu(data.verticalOffset),
},
behindDocument: data.behindDocument ?? false,
},
});
}
export enum Matpel {
_1,
_2,
_3,
}
const matpel1Label = "MATPEL I - Advertencia";
const matpel2Label = "MATPEL II - Operaciones";
const matpel3Label = "MATPEL III - Técnico";
export function getMatpelLabel(matpel: Matpel) {
switch (matpel) {
case Matpel._1: {
return matpel1Label;
}
case Matpel._2: {
return matpel2Label;
}
case Matpel._3: {
return matpel3Label;
}
}
}
export function getMatpelHours(matpel: Matpel): number {
switch (matpel) {
case Matpel._1: {
return 12;
}
case Matpel._2: {
return 12;
}
case Matpel._3: {
return 40;
}
}
}
export function getMatpelFileName(matpel: Matpel) {
switch (matpel) {
case Matpel._1: {
return "MATPEL 1";
}
case Matpel._2: {
return "MATPEL 2";
}
case Matpel._3: {
return "MATPEL 3";
}
}
}

View File

@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, InternalServerErrorException, Param, Post } from "@nestjs/common"; import { Body, Controller, Delete, Get, InternalServerErrorException, Param, Post, Res, StreamableFile } from "@nestjs/common";
import { renderToString } from "solid-js/web"; import { renderToString } from "solid-js/web";
import { Certs } from "../../views/Certs"; import { Certs } from "../../views/Certs";
import { template } from "./certificate.template"; import { template } from "./certificate.template";
import { CertificateService } from "./certificate.service"; import { CertificateService } from "./certificate.service";
import { CertificateDto } from "./certificate.dto"; import { CertificateDto } from "./certificate.dto";
import type { Response } from "express";
@Controller("certificate") @Controller("certificate")
export class CertificateController { export class CertificateController {
@ -27,6 +29,17 @@ export class CertificateController {
await this.certificateService.create(subjectDto); await this.certificateService.create(subjectDto);
} }
@Post("generate/:id")
async generate(@Param() param: {id: string}, @Res({ passthrough: true }) res: Response) {
const [stream, filename] = await this.certificateService.generate(parseInt(param.id, 10));
const str = res.writeHead(201, {
"Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"Content-Disposition": `attachment; filename="${filename}"}`,
});
str.write(stream);
}
@Delete(":id") @Delete(":id")
async delete(@Param() param: {id: number}) { async delete(@Param() param: {id: number}) {

View File

@ -5,7 +5,9 @@ import { RegisterReturn } from "../../types/RegisterReturn";
import { CertificateDto } from "./certificate.dto"; import { CertificateDto } from "./certificate.dto";
import { Persona } from "../../model/Persona/persona.entity"; import { Persona } from "../../model/Persona/persona.entity";
import { CursoGIE } from "../../model/CursoGIE/cursoGIE.entity"; import { CursoGIE } from "../../model/CursoGIE/cursoGIE.entity";
import { data } from "autoprefixer"; import { GenerateCertificateDto } from "./dto/GenerateCertificate.dto";
import { CertData, matpelCert } from "src/certs/MATPEL";
import { Matpel, getMatpelFileName } from "src/certs/utils";
@Injectable() @Injectable()
@ -14,7 +16,7 @@ export class CertificateService {
personGIERepository: Repository<Persona>; personGIERepository: Repository<Persona>;
subjectRepo: Repository<CursoGIE>; subjectRepo: Repository<CursoGIE>;
constructor(private dataSource: DataSource) { constructor(dataSource: DataSource) {
this.registroGIERepository = dataSource.getRepository(RegistroGIE); this.registroGIERepository = dataSource.getRepository(RegistroGIE);
this.personGIERepository = dataSource.getRepository(Persona); this.personGIERepository = dataSource.getRepository(Persona);
this.subjectRepo = dataSource.getRepository(CursoGIE); this.subjectRepo = dataSource.getRepository(CursoGIE);
@ -39,6 +41,63 @@ export class CertificateService {
})); }));
} }
/**
* Generates a certificate.
* @param id Id of the register
* @returns The certificate and its filename
*/
async generate(id: number): Promise<[Buffer, string]> {
const registerR = await this.registroGIERepository.find({
relations: {
persona: true,
},
where: {
id,
},
});
if (registerR.length === 0) {
throw new BadRequestException("ID invalido");
}
const register = registerR[0];
const person = register.persona;
// YYYY-MM-DD
const date = register.fecha_inscripcion.toString();
const [, certYear, certMonth, certDay] = /(\d\d\d\d)-(\d\d)-(\d\d)/.exec(date)!;
// Matpel 1, 2 & 3
// TODO: Get actual ids from db instead of hard coding them?
if (register.curso !== 10 && register.curso !== 11 && register.curso !== 12) {
throw new BadRequestException("Curso no soportado");
}
let matpel = Matpel._1;
if (register.curso === 10) {
matpel = Matpel._1;
} else if (register.curso === 11) {
matpel = Matpel._2;
} else if (register.curso === 12) {
matpel = Matpel._3;
}
const personFullName = `${person.nombres} ${person.apellidoPaterno} ${person.apellidoMaterno}`;
const data: CertData = {
matpel,
personFullName: personFullName,
personDni: register.dni,
certCode: register.codigo.toString().padStart(4, "0"),
certYear,
certMonth,
certDay,
};
return [await matpelCert(data), `${getMatpelFileName(matpel)} - ${personFullName.toUpperCase()}.docx`];
}
async create(data: CertificateDto) { async create(data: CertificateDto) {
const certificate = new RegistroGIE(); const certificate = new RegistroGIE();

View File

@ -0,0 +1,39 @@
import { Matpel } from "src/certs/utils";
export class GenerateCertificateDto {
matpel: Matpel;
/**
* Full name in format: "FIRST_NAMES LAST_NAME_1 LAST_NAME_2"
*/
personFullName: string;
/**
* DNI as a string.
*
* E.g.: "04123523"
*/
personDni: string;
/**
* Cert code as a string. Must have length = 4.
*
* E.g.: "0322"
*/
certCode: string;
/**
* Year of emission as a string.
*
* E.g.: "2023"
*/
certYear: string;
/**
* Month of emission.
*
* E.g.: "05"
*/
certMonth: string;
/**
* Day of emission.
*
* E.g.: "23"
*/
certDay: string;
}

View File

@ -66,6 +66,7 @@ export function Registers(props: { person: Person | null, lastUpdate: number })
<th class="p-2">CÓDIGO</th> <th class="p-2">CÓDIGO</th>
<th class="p-2"></th> <th class="p-2"></th>
<th class="p-2"></th> <th class="p-2"></th>
<th class="p-2"></th>
</tr> </tr>
</thead> </thead>
@ -89,7 +90,6 @@ function Register(props: {cert: RegisterReturn, onUpdate: () => void}) {
const [deleteText, setDeleteText] = createSignal("Eliminar"); const [deleteText, setDeleteText] = createSignal("Eliminar");
const deleteRegister = async() => { const deleteRegister = async() => {
console.log(":D");
if (deleteConfirmation()) { if (deleteConfirmation()) {
// Actually delete from DB // Actually delete from DB
const response = await fetch(`/certificate/${props.cert.id}`,{ const response = await fetch(`/certificate/${props.cert.id}`,{
@ -113,12 +113,58 @@ function Register(props: {cert: RegisterReturn, onUpdate: () => void}) {
} }
}; };
const getCertificate = async() => {
const response = await fetch(`/certificate/generate/${props.cert.id}`,{
method: "POST",
});
if (response.ok) {
const h = response.headers;
let filename = "certificate.docx";
const disposition = h.get("Content-Disposition");
if (disposition && disposition.indexOf("attachment") !== -1) {
const filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/;
const matches = filenameRegex.exec(disposition);
if (matches !== null && matches[1]) {
filename = matches[1].replace(/['"]/g, "");
}
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.style.display = "none";
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
return;
} else {
alert("Error downloading...");
}
};
return ( return (
<tr class="odd:bg-c-surface-variant"> <tr class="odd:bg-c-surface-variant">
<td class="py-2 px-4">{props.cert.nombre}</td> <td class="py-2 px-4">{props.cert.nombre}</td>
<td class="py-2 px-4">{props.cert.curso_nombre}</td> <td class="py-2 px-4">{props.cert.curso_nombre}</td>
<td class="py-2 px-4 font-mono">{props.cert.fecha_inscripcion.toString()}</td> <td class="py-2 px-4 font-mono">{props.cert.fecha_inscripcion.toString()}</td>
<td class="py-2 px-4">{props.cert.codigo}</td> <td class="py-2 px-4">{props.cert.codigo}</td>
<td class="py-2 pl-8 pr-4">
<button
class="rounded-full py-1 px-2 shadow"
style={{"background-color": "#0055d5", "color": "#ffffff"}}
onclick={getCertificate}
>
DOCX
</button>
</td>
<td class="py-2 pl-8 pr-4"> <td class="py-2 pl-8 pr-4">
<button <button
class="rounded-full py-1 px-2 shadow class="rounded-full py-1 px-2 shadow