feat: integrate Typst for PDF generation and add new fonts

- Added Typst templates for receipts and announcements.
- Implemented TypstRunner and TypstRunner2 services for PDF generation.
- Included necessary fonts for Typst rendering.
- Updated Dockerfile to install Typst and Pandoc.
- Enhanced API with new endpoints for Typst functionality.
- Added temporary directory handling for generated files.
- Updated .gitignore and .dockerignore to exclude temporary files.
This commit is contained in:
Marco Pedone 2026-04-01 10:44:46 +02:00
parent aec6860cbd
commit 8c48dbb4b6
64 changed files with 524 additions and 1 deletions

2
.gitignore vendored
View file

@ -29,3 +29,5 @@ apps/infoalloggi/fic_cities.json
apps/infoalloggi/fic_nations.json
apps/infoalloggi/gi_comuni_cap.json
apps/infoalloggi/gi_nazioni.json
apps/infoalloggi/tmp/

View file

@ -27,3 +27,4 @@ docs
biome_plugins
vitest.config.mts
tsconfig.tsbuildinfo
tmp

View file

@ -21,4 +21,5 @@
],
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
"biome.lsp.trace.server": "verbose",
"tinymist.fontPaths": ["${workspaceFolder}/typst/fonts"]
}

View file

@ -88,7 +88,7 @@ ENV STORAGE_TOKEN=$STORAGE_TOKEN
ARG BROWSER_WS_URL
ENV BROWSER_WS_URL=$BROWSER_WS_URL
RUN apk add --no-cache curl
RUN apk add --no-cache curl pandoc typst
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
@ -97,6 +97,9 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/typst ./typst
RUN mkdir -p /app/tmp && chown nextjs:nodejs /app/tmp
USER nextjs

View file

@ -3,6 +3,7 @@ import {
protectedApiProcedure,
publicProcedure,
} from "~/server/api/trpc";
import { TypstRunner, TypstRunner2 } from "~/server/services/typst.service";
export const testRouter = createTRPCRouter({
testApi: protectedApiProcedure.query(async () => {
@ -14,6 +15,9 @@ export const testRouter = createTRPCRouter({
console.error("test console.error");
throw new Error("This is a test error");
}),
testTypst: publicProcedure.query(async () => {
return await TypstRunner2();
}),
});
/*
PROTECTED API CALL EXAMPLE

View file

@ -0,0 +1,167 @@
import { exec, execSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { TRPCError } from "@trpc/server";
import type { TestiEStringheStingaId } from "~/schemas/public/TestiEStringhe";
import { db } from "~/server/db";
import { getStringa } from "~/server/services/testi_stringhe.service";
const execPromise = promisify(exec);
const projectRoot = path.resolve(".");
const fontsPath = path.join(projectRoot, "typst", "fonts");
const tmpDir = path.join(projectRoot, "tmp");
type RicevutaProps = {
ordineId: string;
date: string;
datetime: string;
nominativo: string;
codicefiscale: string;
indirizzo: string;
nazione: string;
items: {
title: string;
desc: string | null;
discount: number | null;
amount: number;
strike: boolean;
}[];
total: number;
vat: number;
method: string;
};
export const TypstRunner = async () => {
const timestamp = Date.now();
await fs.mkdir(tmpDir, { recursive: true });
const jsonFileName = `data-${timestamp}.json`;
const jsonPath = path.join(tmpDir, jsonFileName);
const templatePath = path.join(projectRoot, "typst", "receipt.typ");
const pdfPath = path.join(tmpDir, `receipt-${timestamp}.pdf`);
const props: RicevutaProps = {
ordineId: "dniuawndiandioawmodmawodmawdaw",
date: "10 gennaio 2026",
datetime: "14:55",
nominativo: "Di Grigoli Angelo Damiano",
codicefiscale: "DGRNLD97C13B602Z",
indirizzo: "Via Aldo Fantina 15, PIEVE DEL GRAPPA (TV) 31017,",
nazione: "Italia",
items: [
{
title: "Acconto Cerco Transitorio",
desc: null,
discount: null,
amount: 3500,
strike: false,
},
{
title: "Saldo Cerco Affitto Transitorio (6 mesi)",
desc: "Pagamento differito, non è dovuto in questo momento",
discount: null,
amount: 37500,
strike: true,
},
],
total: 3500,
vat: 3500 * 0.22,
method: "Carta di credito",
};
await fs.writeFile(jsonPath, JSON.stringify(props));
try {
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfPath}"`;
await execPromise(command);
const pdfBuffer = await fs.readFile(pdfPath);
return new Response(pdfBuffer, {
headers: { "Content-Type": "application/pdf" },
});
} catch (err) {
console.error("typst compile failed:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF with Typst",
cause: err instanceof Error ? err : undefined,
});
} finally {
// Cleanup — runs even on failure
await Promise.all([
fs.unlink(jsonPath).catch(() => {}),
//fs.unlink(pdfFile).catch(() => {}),
]);
}
};
export const TypstRunner2 = async () => {
const s = await getStringa({
db: db,
stringaId: "CONDIZIONI_CERCHI" as TestiEStringheStingaId,
});
if (!s || !s.stringa_value) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Stringa non trovata",
});
}
const timestamp = Date.now();
await fs.mkdir(tmpDir, { recursive: true });
const jsonFileName = `tmp-${timestamp}.json`;
const jsonPath = path.join(tmpDir, jsonFileName);
const templatePath = path.join(projectRoot, "typst", "testi.typ");
const pdfFile = path.join(tmpDir, `doc-${timestamp}.pdf`);
const p = convertHtmlToTypst(s.stringa_value);
await fs.writeFile(jsonPath, JSON.stringify({ stringa: p }));
try {
const command = `typst compile --input data_path=/tmp/${jsonFileName} --root "${projectRoot}" --font-path "${fontsPath}" "${templatePath}" "${pdfFile}"`;
await execPromise(command);
const pdfBuffer = await fs.readFile(pdfFile);
return new Response(pdfBuffer, {
headers: { "Content-Type": "application/pdf" },
});
} catch (err) {
console.error("typst compile failed:", err);
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to generate PDF with Typst",
cause: err instanceof Error ? err : undefined,
});
} finally {
// Cleanup — runs even on failure
await Promise.all([
fs.unlink(jsonPath).catch(() => {}),
//fs.unlink(pdfFile).catch(() => {}),
]);
}
};
/**
* Converts HTML to Typst markup using Pandoc.
* works on Windows & Linux.
*/
function convertHtmlToTypst(html: string): string {
try {
// We pass the HTML via 'input' which writes to the process stdin
const output = execSync(
"pandoc -f html -t typst --syntax-highlighting=none",
{
input: html,
encoding: "utf-8",
},
);
return output;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to convert HTML to Typst",
cause: error instanceof Error ? error : undefined,
});
}
}

View file

@ -0,0 +1,139 @@
#import "@preview/iconic-salmon-fa:1.1.0": fa-location-pin, fa-whatsapp
#set page(
paper: "a4",
margin: (bottom: 1.5cm, rest: 1cm),
footer: [
#line(length: 100%, stroke: 0.5pt + luma(200))
#set text(size: 8pt, fill: luma(100))
#grid(
columns: (1fr, auto),
[Arcenia Srl. Tel. +39 0424529869 | Email: arca\@infoalloggi.it], [Documento generato in data: 31/3/2026],
)
],
)
#set text(font: "Inter 18pt", size: 10pt, lang: "it", fill: rgb("#1f1f1f"))
#set par(justify: true, leading: 0.65em)
// --- HEADER ---
#grid(
columns: (1fr, 1fr),
align: (left, right),
image("../public/Infoalloggi.png", width: 150pt, height: auto),
[
*Arcenia Srl* \
Via Beata Giovanna, 1 a Bassano del Grappa (VI) \
Tel. +39 0424529869 \
Email: arca\@infoalloggi.it
],
)
#v(1em)
#line(length: 100%, stroke: 0.5pt + luma(200))
#v(1em)
// --- TITLE & REFERENCE ---
#align(center)[#text(
size: 16pt,
weight: "bold",
fill: rgb("#63493f"),
)[BASSANO S. VITO MONOLOCALE A USO TRANSITORIO - 01/06/26]]
#v(0.5em)
#align(center)[#text(
size: 14pt,
)[Riferimento: *3786B*]]
#v(1.5em)
#box(stroke: (paint: rgb("#25d366"), thickness: 1.5pt), width: 100%, inset: 16pt, radius: 10pt)[
#grid(
columns: 1fr,
gutter: 14pt,
[#fa-whatsapp(size: 20pt, fill: rgb("#25d366"))#h(1em)#text(
size: 16pt,
weight: "bold",
fill: rgb("#63493f"),
)[Contatti]#h(.5em)#text(size: 12pt, fill: rgb("#63493f"))[(mandare un messaggio WhatsApp prima di chiamare)]],
[INFOALLOGGI (delegato)],
[*Tel. +393453944827*]
)
]
#v(1.5em)
#grid(
columns: 1fr,
gutter: 10pt, [
#text(size: 10pt, weight: "bold")[Indirizzo]], [
#line(length: 100%, stroke: 0.5pt + luma(200))], [
#text(weight: "bold", size: 12pt)[VIA MONTE ASOLONE, 21 int 1 Bassano del Grappa 36061 (VI)]#h(3em)#link(
"https://maps.google.com",
)[#fa-location-pin(size: 20pt, fill: red)#text(fill: blue)[Apri in Google Maps]]
]
)
#v(0.5em)
#v(0.5em)
#v(1.5em)
// --- KEY DETAILS TABLE ---
#table(
columns: (1fr, 1fr, 1fr),
stroke: 0.5pt + luma(200),
inset: 10pt,
[*Tipologia* \ Affitto Transitorio], [*Canone Mensile* \ 540,00 ], [*Disponibile da* \ 31/05/2026],
)
#v(1.5em)
// --- FEATURES ---
#text(size: 12pt, weight: "bold")[Dotazioni e Servizi]
#v(0.5em)
#grid(
columns: (1fr, 1fr, 1fr),
gutter: 10pt,
[- *Tipo Impianto:* Radiatori], [- *Riscaldamento:* Autonomo], [- *Arredi:* Arredato],
[- *Piani Edificio:* Uno], [- *Internet:* No],
)
#v(1.5em)
// --- DESCRIPTION ---
#text(size: 12pt, weight: "bold")[Descrizione]
#v(0.5em)
CODICE: 3786B LIBERO DA GIUGNO AFFITTO TRANSITORIO - Confortevole monolocale situato nella tranquilla zona di San Vito, a Bassano del Grappa. Libero da giugno, questo appartamento è la soluzione ideale per chi cerca un affitto transitorio.
Completamente ammobiliato e arredato con gusto, il monolocale si trova al piano terra e offre un soggiorno luminoso con un angolo cottura recente. La camera, dotata di un comodo letto alla francese, è separata da una parete per garantire privacy. Classe energetica F.
Il bagno è completo di doccia e lavatrice nuova, perfetto per le esigenze quotidiane. Inoltre, è disponibile un posto auto scoperto riservato, rendendo la vita ancora più comoda. Le utenze sono attive, e il contratto di affitto prevede una permanenza da 6 a 12 mesi, con un canone mensile di 540,00 e spese condominiali di 80,00 €/mese.
Questo monolocale è ideale per single e si trova in un contesto tranquillo, lontano dal caos cittadino. Non è consentito fumare all'interno dei locali e si preferiscono inquilini senza animali. Non perdere l'occasione di vivere in un ambiente accogliente e funzionale! Contattaci per maggiori informazioni e per organizzare una visita.
#v(1.5em)
// --- OWNER REQUESTS ---
#rect(fill: rgb("#f5f5f5"), width: 100%, inset: 15pt, stroke: none)[
#text(size: 12pt, weight: "bold")[RICHIESTE DEL PROPRIETARIO:]
#v(0.5em)
#list(
[*Tipologia contratto di locazione:* TRANSITORIO (No stabile)],
[*Regime fiscale:* CEDOLARE SECCA (Senza imposte di registro e bolli)],
[*Data disponibilità:* 1 GIUGNO],
[*Permanenza:* DA 6 a 12 MESI],
[*Preferenza:* SINGLE],
[*Contratto di lavoro:* A TEMPO DETERMINATO (o altra motivazione prevista per legge)],
[*Canone mensile:* 540,00 ],
[*Spese condominiali e accessorie:* 80,00 €/MESE],
[*Deposito cauzionale:* IN BASE ALLA PERMANENZA],
[*Consumi utenze:* Gas/Riscaldamento - Luce Acqua (Rimborso a CONSUMO)],
[*Fumatori:* NON E' CONSENTITO FUMARE ALL'INTERNO DEI LOCALI],
[*Animali:* NON E' CONSENTITO],
)
]
#v(1.5em)
// --- HOW TO VISIT ---
#text(size: 12pt, weight: "bold")[COSA FARE PER VISITARE L'IMMOBILE]
#v(0.5em)
Infoalloggi.it è una piattaforma immobiliare che ti consente di avere un contatto diretto con i proprietari previa attivazione del servizio. La piattaforma e i nostri referenti sono a tua disposizione per assisterti nella tua ricerca.
La geo-localizzazione indica la zona dell'immobile, l'indirizzo esatto lo ricevi con l'attivazione del servizio. Informazione strettamente riservata ed indirizzata all'Utente Arca-Infoalloggi nel rispetto dell' Art. F. Precisazione dei Tuoi Obblighi e responsabilità delle Condizioni Generali di Contratto (CGC) accettate.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,168 @@
#import "utils.typ": format-price
#let data_file = sys.inputs.at("data_path", default: "none")
#let data = if data_file != "none" { json(data_file) } else {
(
"ordineId": "3df6bd0d-2d2d-4687-b4b9-87f2aecc297c",
"date": "1 gennaio 2026",
"datetime": "14:55",
"nominativo": "Di Grigoli Angelo Damiano",
"codicefiscale": "DGRNLD97C13B602Z",
"indirizzo": "Via Aldo Fantina 15, PIEVE DEL GRAPPA (TV) 31017",
"nazione": "Italia",
"items": (
(
"title": "Acconto Cerco Transitorio",
"desc": none,
"discount": none,
"amount": 3500,
"strike": false,
),
(
"title": "Saldo Cerco Affitto Transitorio (6 mesi)",
"desc": "Pagamento differito, non è dovuto in questo momento",
"discount": none,
"amount": 37500,
"strike": true,
),
),
"total": 3500,
"vat": 770,
"method": "Carta di credito",
)
}
#set page(
paper: "a4",
margin: (x: 1cm, y: 1cm),
footer: [
#set align(center)
#set text(size: 9pt, fill: luma(100))
Arcenia Srl.| P.IVA 00924740244 | Tel. +39 0424529869 | Email: arca\@infoalloggi.it
],
)
#set text(font: "Inter 18pt", size: 10pt)
// --- Header ---
#grid(
columns: (1fr, auto),
[
#text(size: 14pt, weight: "bold", fill: rgb("1a202c"))[Arcenia S.r.l.] \
Via Beata Giovanna 1 \
Bassano del Grappa (VI) 36061 \
Italia
],
[
#image("/public/favicon/favicon.png", width: 80pt)
],
)
#v(2.5em)
// --- Title ---
#align(center)[
#text(size: 16pt, weight: "bold", fill: rgb("1a202c"))[Ricevuta di cortesia]
]
#v(2em)
#grid(
columns: (1fr, auto),
[
#text(fill: luma(120), size: 9pt)[ID Ordine] \
#data.ordineId
],
align(right)[
#text(fill: luma(120), size: 9pt)[Data] \
#data.date \
#data.datetime
],
)
#v(0.5em)
#line(length: 100%, stroke: 0.5pt + luma(200))
#v(1em)
#text(weight: "bold")[Intestatario:] \
#v(0.2em)
#data.nominativo \
#data.codicefiscale \
#data.indirizzo \
#data.nazione
#v(2.5em)
#table(
columns: (1fr, auto, auto),
stroke: none,
align: (left, right, right),
inset: (x: 5pt, y: 10pt),
// Header row
[*Oggetto*], [*Sconto applicato*], [*Importo*],
table.hline(stroke: 0.5pt + luma(200)),
..data
.items
.map(item => {
(
item.title + if item.desc != none { "\n" + text(size: 9pt, fill: luma(100))[#item.desc] } else { "" },
if item.at("discount", default: none) != none { [#item.discount %] } else [],
if item.at("strike", default: false) {
strike(stroke: 1pt + luma(100))[#text(fill: luma(100))[#format-price(item.amount)]]
} else {
[#format-price(item.amount)]
},
table.hline(stroke: 0.5pt + luma(200)),
)
})
.flatten(),
)
// --- Totals ---
#v(0.5em)
#align(right)[
#grid(
columns: (1fr, auto),
align: (right, right),
gutter: 1.5em,
[*Totale:*], [*#format-price(data.total)*],
[#text(fill: luma(100))[di cui IVA (22%):]], [#text(fill: luma(100))[#format-price(data.vat)]],
)
]
#v(2em)
#line(length: 100%, stroke: 0.5pt + luma(200))
#v(0.5em)
#grid(
columns: (1fr, auto),
[
#text(weight: "bold")[Metodo di pagamento] \
#v(0.2em)
#data.method
],
align(right)[
#text(weight: "bold")[Status pagamento] \
#v(0.2em)
// Custom hex color for the success text
#text(fill: rgb("00A650"), weight: "bold")[Pagato]
],
)
#v(0.5em)
#line(length: 100%, stroke: 0.5pt + luma(200))
#v(1em)
#text(weight: "bold")[Note] \
#v(0.2em)
#text(size: 9pt, fill: rgb("4a5568"))[
La presente non costituisce ricevuta o fattura fiscale ai sensi dell'Art.21, DPR 633/72.\
Seguirà fattura elettronica direttamente al tuo indirizzo di posta.
]

View file

@ -0,0 +1,23 @@
#let data = sys.inputs.at("data", default: none)
#set page(paper: "a4", margin: 1cm)
#set text(font: "Inter 18pt", size: 10pt, lang: "it")
#show heading: set text(fill: rgb("#1a202c"))
#show heading.where(level: 2): set text(size: 14pt)
#show heading.where(level: 3): set text(size: 12pt)
#set par(justify: true, leading: 0.65em)
#let data_file = sys.inputs.at("data_path", default: "none")
#let data = if data_file != "none" { json(data_file) } else {
(
"stringa": "none",
)
}
#eval(data.stringa, mode: "markup")

View file

@ -0,0 +1,15 @@
#import "@preview/oxifmt:1.0.0": strfmt
#let format-price(cents) = {
// Handle case where cents might be null or not a number
if cents == none { return "0,00 €" }
let euros = int(cents / 100)
let decimal = calc.rem(cents, 100)
// Create the decimal string and pad with a leading zero if needed (e.g., 5 cents -> "05")
let decimal-str = if decimal < 10 { "0" + str(decimal) } else { str(decimal) }
return str(euros) + "," + decimal-str + " €"
}