From ca2861e66cd017c7997fd285bbbe162a95adaf1a Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 12:17:21 +0100 Subject: [PATCH 01/17] add CLAUDE.md with project context and engineering conventions Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 92 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..94ac87b0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# CLAUDE.md — ksef-pdf-generator + +## Why This Project Exists + +The goal is to generate invoice PDFs without building and maintaining a custom template system that breaks every time the layout needs to change. Instead, this project leverages the Polish government's KSeF (Krajowy System e-Faktur) open-source PDF generators — they handle all the rendering logic from structured XML. The XML schema is the template. + +The missing piece is a thin HTTP service that wraps this library so other systems can send invoice XML and get back a PDF. That's the end-game: **XML in, PDF out, via a simple API**. + +## Project Overview + +TypeScript library for generating PDF visualizations of KSeF invoices and UPO confirmations from XML files. Built with Vite, pdfmake, and xml-js. + +**Architecture:** Layered — Public API (`src/index.ts`) -> business logic (`src/lib-public/`) -> shared utilities (`src/shared/`) -> external libs (pdfmake, xml-js). + +**Domain:** Polish e-invoicing schemas FA(1), FA(2), FA(3), UPO v4.2/v4.3. Generator directories: `FA1/`, `FA2/`, `FA3/`, `UPO4_2/`, `UPO4_3/`. + +## Commands + +```bash +npm run dev # Dev server (port 5173) +npm run build # Production build (ES + UMD) +npm run type # TypeScript type checking +npm run test # Watch mode tests (vitest) +npm run test:ui # Interactive test UI +npm run test:ci # CI tests with coverage +``` + +## Code Conventions + +**TypeScript:** +- Strict mode enabled +- Explicit return types on all functions (`@typescript-eslint/explicit-function-return-type: error`) +- Explicit member accessibility on classes (`@typescript-eslint/explicit-member-accessibility: warn`) +- `@typescript-eslint/no-explicit-any` is off — use sparingly and only when schema types are genuinely dynamic + +**Prettier** (`.prettierrc.json`): +- 110 char print width, single quotes, trailing commas (`es5`), 2-space indent, semicolons + +**ESLint** (`eslint.config.mts`): +- Curly braces always required (`curly: error`) +- Blank line after variable declarations (`@stylistic/padding-line-between-statements`) +- Member ordering enforced (`@typescript-eslint/member-ordering`) + +**Naming:** +- Polish-English naming matching XML schema structure (e.g., `Naglowek`, `Wiersze`, `Stopka`, `Rozliczenie`, `Platnosc`, `Adres`) +- Types/interfaces in `types/` folders and `*.types.ts` files +- Co-located test files: `Component.ts` -> `Component.spec.ts` + +## Architecture & SOLID Principles + +- **Single Responsibility:** Each generator file handles one PDF section (e.g., `Adres.ts` = address, `Platnosc.ts` = payment, `Wiersze.ts` = line items) +- **Open/Closed:** New schema versions (FA4, UPO v5) should add new generator directories under `src/lib-public/generators/`, not modify existing ones +- **Liskov Substitution:** Generator functions share consistent signatures (`(invoice, additionalData?) => Content[]`) +- **Interface Segregation:** Type definitions are schema-specific (`fa1.types.ts`, `fa2.types.ts`, `fa3.types.ts`) +- **Dependency Inversion:** Generators depend on abstractions (`Content[]`, `FormatTyp`) not pdfmake internals + +## DRY Practices + +- Reuse shared utilities from `src/shared/PDF-functions.ts` (`formatText`, `createLabelText`, `createSection`, `createHeader`, `generateTable`, `getContentTable`) +- Reuse common generators from `src/lib-public/generators/common/` (Naglowek, Wiersze, Stopka, Rozliczenie, DaneFaKorygowanej, Zalaczniki) +- Reuse lookup functions from `src/shared/generators/common/functions.ts` +- Constants centralized in `src/shared/consts/const.ts` +- Use `FormatTyp` enum from `src/shared/enums/common.enum.ts` for all text formatting — do not inline formatting logic + +## TDD Approach + +- Write tests BEFORE implementation (Red-Green-Refactor) +- Co-locate test files: `Component.ts` -> `Component.spec.ts` +- Test structure: `describe('functionName')` with `it('expected behavior')` +- Test edge cases: undefined inputs, empty `_text`, invalid XML values +- Use Vitest globals (`describe`, `it`, `expect`) — no imports needed +- Mock external dependencies via `vi.spyOn` / `vi.fn` +- Run `npm run test:ci` to verify coverage before committing + +## Technical Documentation + +- All new public functions must have explicit TypeScript return types (ESLint enforced) +- Type definitions serve as documentation — keep them accurate and up-to-date +- Generator function names must match the XML schema section they handle +- Document non-obvious business logic with inline comments (tax rules, schema-specific behavior) + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `src/index.ts` | Library entry point | +| `src/lib-public/generate-invoice.ts` | Invoice routing logic | +| `src/shared/PDF-functions.ts` | Shared PDF utility functions | +| `src/shared/generators/common/functions.ts` | Common lookup/helper functions | +| `src/shared/consts/const.ts` | Centralized constants | +| `src/shared/enums/common.enum.ts` | Shared enums (FormatTyp, etc.) | +| `src/lib-public/generators/common/` | Common generators reused across schema versions | From 28c2b0150f15a6ccc5fc01a2c92f14df367eae44 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 13:14:27 +0100 Subject: [PATCH 02/17] add .tool-versions to pin Node 24.3.0 for asdf Co-Authored-By: Claude Opus 4.6 --- .tool-versions | 1 + 1 file changed, 1 insertion(+) create mode 100644 .tool-versions diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 00000000..eebb231c --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +nodejs 24.3.0 From 453e4d7545e662011e543749a97f3a50195bfe05 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 13:50:56 +0100 Subject: [PATCH 03/17] add HTTP microservice for PDF generation from XML Wrap the existing library in a minimal node:http server (server/index.js) that accepts KSeF XML via POST /generate and returns a PDF. Includes Dockerfile, integration tests, and README documentation. Co-Authored-By: Claude Opus 4.6 --- .dockerignore | 12 ++++ Dockerfile | 20 ++++++ package.json | 5 +- readme.md | 44 ++++++++++++- server/index.js | 76 ++++++++++++++++++++++ server/server.spec.ts | 140 ++++++++++++++++++++++++++++++++++++++++ vite.config.ts | 2 +- vitest.server.config.ts | 9 +++ 8 files changed, 304 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 server/index.js create mode 100644 server/server.spec.ts create mode 100644 vitest.server.config.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..977de9bd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +node_modules +.git +*.md +.idea +.vscode +coverage +.vitest +.cache +temp +out +*.tsbuildinfo +.claude diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6283a4df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:22-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +# Runtime stage — only jsdom is needed (pdfmake + xml-js are bundled in dist) +FROM node:22-alpine +WORKDIR /app +RUN addgroup -g 1001 -S app && adduser -S app -u 1001 +COPY --from=build /app/dist ./dist +COPY --from=build /app/server ./server +COPY --from=build /app/package*.json ./ +RUN npm ci --omit=dev && npm cache clean --force +USER app +EXPOSE 3001 +HEALTHCHECK CMD wget -qO- http://localhost:3001/health || exit 1 +CMD ["node", "server/index.js"] diff --git a/package.json b/package.json index feb772c0..4e9b9eab 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "type": "tsc -p src/app-public/tsconfig.json --noEmit", "test": "vitest", "test:ui": "vitest --ui", - "test:ci": "vitest run --coverage" + "test:ci": "vitest run --coverage", + "test:server": "npm run build && vitest run --config vitest.server.config.ts" }, "main": "./ksef-fe-invoice-converter.umd.cjs", "module": "./ksef-fe-invoice-converter.js", @@ -41,7 +42,6 @@ "eslint-plugin-prettier": "^5.5.4", "globals": "^16.3.0", "jiti": "^2.5.1", - "jsdom": "^26.1.0", "prettier": "^3.6.2", "typescript": "^5.8.3", "typescript-eslint": "^8.40.0", @@ -50,6 +50,7 @@ "vitest": "^3.2.4" }, "dependencies": { + "jsdom": "^26.1.0", "pdfmake": "^0.2.20", "xml-js": "^1.6.11" } diff --git a/readme.md b/readme.md index 0e869aee..224cd7d6 100644 --- a/readme.md +++ b/readme.md @@ -66,7 +66,44 @@ Aplikacja uruchomi się domyślnie pod adresem: [http://localhost:5173/](http:// --- -## 5. Testy jednostkowe +## 5. Serwer HTTP (mikroserwis) + +Biblioteka może działać jako samodzielny serwer HTTP — XML na wejściu, PDF na wyjściu. + +### Uruchomienie lokalne + +```bash +npm run build +node server/index.js +``` + +Serwer nasłuchuje na porcie `3001` (zmiana przez zmienną środowiskową `PORT`). + +### Endpointy + +| Metoda | Ścieżka | Opis | +|--------|---------|------| +| `POST` | `/generate` | Wyślij XML, otrzymaj PDF | +| `GET` | `/health` | Health check (`{"status":"ok"}`) | + +### Przykład użycia + +```bash +curl -X POST -H 'Content-Type: application/xml' \ + --data-binary @assets/invoice.xml \ + http://localhost:3001/generate -o faktura.pdf +``` + +### Docker + +```bash +docker build -t ksef-pdf . +docker run -p 3001:3001 ksef-pdf +``` + +--- + +## 6. Testy jednostkowe Aplikacja zawiera zestaw testów napisanych w **TypeScript**, które weryfikują poprawność działania aplikacji. Projekt wykorzystuje **Vite** do bundlowania i **Vitest** jako framework testowy. @@ -88,6 +125,11 @@ Projekt wykorzystuje **Vite** do bundlowania i **Vitest** jako framework testowy npm run test:ci ``` +4. Uruchom testy integracyjne serwera HTTP: + ```bash + npm run test:server + ``` + --- Raport: /coverage/index.html diff --git a/server/index.js b/server/index.js new file mode 100644 index 00000000..0f336be7 --- /dev/null +++ b/server/index.js @@ -0,0 +1,76 @@ +import { JSDOM } from 'jsdom'; + +// pdfmake expects browser globals (window, document, navigator) at module load time. +// Provide them via jsdom before importing the library. +const dom = new JSDOM('', { url: 'http://localhost' }); +globalThis.window = dom.window; +globalThis.document = dom.window.document; +// navigator is read-only on globalThis in Node 20+; override via property descriptor +Object.defineProperty(globalThis, 'navigator', { + value: dom.window.navigator, + writable: true, + configurable: true, +}); +globalThis.FileReader = dom.window.FileReader; +// Use jsdom's File/Blob so FileReader.readAsText can consume them +globalThis.File = dom.window.File; +globalThis.Blob = dom.window.Blob; + +import { createServer } from 'node:http'; +import { generateInvoice } from '../dist/ksef-fe-invoice-converter.js'; + +const PORT = Number(process.env.PORT) || 3001; + +function collectBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', (chunk) => chunks.push(chunk)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +const server = createServer(async (req, res) => { + const start = Date.now(); + let status = 200; + + try { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + } else if (req.method === 'POST' && req.url === '/generate') { + const body = await collectBody(req); + + if (!body.length) { + status = 400; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Empty request body' })); + } else { + const file = new File([body], 'invoice.xml', { type: 'text/xml' }); + const base64 = await generateInvoice(file, { nrKSeF: '' }, 'base64'); + const buffer = Buffer.from(base64, 'base64'); + + res.writeHead(200, { + 'Content-Type': 'application/pdf', + 'Content-Length': buffer.length, + }); + res.end(buffer); + } + } else { + status = 404; + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + } + } catch (err) { + status = 500; + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message || 'Internal server error' })); + } + + const duration = Date.now() - start; + console.log(`${req.method} ${req.url} ${status} ${duration}ms`); +}); + +server.listen(PORT, () => { + console.log(`ksef-pdf-generator listening on port ${PORT}`); +}); diff --git a/server/server.spec.ts b/server/server.spec.ts new file mode 100644 index 00000000..f15a72b6 --- /dev/null +++ b/server/server.spec.ts @@ -0,0 +1,140 @@ +// @vitest-environment node +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ChildProcess, spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { request as httpRequest, IncomingMessage } from 'node:http'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = 3456 + Math.floor(Math.random() * 1000); +let serverProcess: ChildProcess; + +function request( + path: string, + options: { method?: string; body?: Buffer; headers?: Record } = {} +): Promise<{ status: number; headers: Record; body: Buffer }> { + return new Promise((resolve, reject) => { + const url = new URL(path, `http://localhost:${PORT}`); + const req = httpRequest(url, { + method: options.method || 'GET', + headers: options.headers || {}, + }); + + req.on('response', (res: IncomingMessage) => { + const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); + res.on('end', () => { + resolve({ + status: res.statusCode!, + headers: res.headers as Record, + body: Buffer.concat(chunks), + }); + }); + }); + + req.on('error', reject); + + if (options.body && options.body.length > 0) { + req.write(options.body); + } + req.end(); + }); +} + +describe('ksef-pdf HTTP server', () => { + beforeAll(async () => { + serverProcess = spawn('node', [join(__dirname, 'index.js')], { + env: { ...process.env, PORT: String(PORT) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + // Wait for server to be ready + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 15000); + + serverProcess.stdout?.on('data', (data: Buffer) => { + if (data.toString().includes('listening')) { + clearTimeout(timeout); + resolve(); + } + }); + + serverProcess.stderr?.on('data', (data: Buffer) => { + console.error('Server stderr:', data.toString()); + }); + + serverProcess.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + serverProcess.on('exit', (code) => { + if (code !== null && code !== 0) { + clearTimeout(timeout); + reject(new Error(`Server exited with code ${code}`)); + } + }); + }); + }, 30000); + + afterAll(() => { + serverProcess?.kill('SIGTERM'); + }); + + it('GET /health returns 200 with status ok', async () => { + const res = await request('/health'); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/json'); + expect(JSON.parse(res.body.toString())).toEqual({ status: 'ok' }); + }); + + it('POST /generate with valid invoice XML returns a PDF', async () => { + const xml = readFileSync(join(__dirname, '..', 'assets', 'invoice.xml')); + + const res = await request('/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: xml, + }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('application/pdf'); + expect(res.body.subarray(0, 5).toString()).toBe('%PDF-'); + expect(res.body.length).toBeGreaterThan(1000); + }, 15000); + + it('POST /generate with invalid XML returns 500 with JSON error', async () => { + const res = await request('/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: Buffer.from('not an invoice'), + }); + + expect(res.status).toBe(500); + expect(res.headers['content-type']).toBe('application/json'); + const body = JSON.parse(res.body.toString()); + expect(body).toHaveProperty('error'); + expect(typeof body.error).toBe('string'); + }); + + it('GET /nonexistent returns 404', async () => { + const res = await request('/nonexistent'); + + expect(res.status).toBe(404); + expect(res.headers['content-type']).toBe('application/json'); + expect(JSON.parse(res.body.toString())).toEqual({ error: 'Not found' }); + }); + + it('POST /generate with empty body returns 400', async () => { + const res = await request('/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: Buffer.alloc(0), + }); + + expect(res.status).toBe(400); + expect(JSON.parse(res.body.toString())).toEqual({ error: 'Empty request body' }); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 292176f8..a5f8cf97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -43,7 +43,7 @@ export default defineConfig(({ mode }) => { entryRoot: libRoot, insertTypesEntry: true, outDir: path.resolve(__dirname, 'dist'), - exclude: ['src/app-public'], + exclude: ['src/app-public', 'server'], }), ], diff --git a/vitest.server.config.ts b/vitest.server.config.ts new file mode 100644 index 00000000..6c60397d --- /dev/null +++ b/vitest.server.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['server/**/*.spec.ts'], + }, +}); From da149a214491db7a837c6a7579f51e115be9e712 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 14:21:35 +0100 Subject: [PATCH 04/17] support KSeF number and QR code via request headers Read X-KSeF-Number and X-KSeF-QRCode headers in POST /generate and pass them as additionalData to the PDF generator. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/server/index.js b/server/index.js index 0f336be7..908072b6 100644 --- a/server/index.js +++ b/server/index.js @@ -46,8 +46,10 @@ const server = createServer(async (req, res) => { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Empty request body' })); } else { + const nrKSeF = req.headers['x-ksef-number'] || ''; + const qrCode = req.headers['x-ksef-qrcode'] || undefined; const file = new File([body], 'invoice.xml', { type: 'text/xml' }); - const base64 = await generateInvoice(file, { nrKSeF: '' }, 'base64'); + const base64 = await generateInvoice(file, { nrKSeF, qrCode }, 'base64'); const buffer = Buffer.from(base64, 'base64'); res.writeHead(200, { From f0e638de291b1f5d9e4453b9b525cf2365019281 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 14:22:24 +0100 Subject: [PATCH 05/17] update README with API headers and curl examples Document X-KSeF-Number and X-KSeF-QRCode optional headers with usage examples for POST /generate endpoint. Co-Authored-By: Claude Opus 4.6 --- readme.md | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 224cd7d6..f9b9b2d7 100644 --- a/readme.md +++ b/readme.md @@ -86,7 +86,16 @@ Serwer nasłuchuje na porcie `3001` (zmiana przez zmienną środowiskową `PORT` | `POST` | `/generate` | Wyślij XML, otrzymaj PDF | | `GET` | `/health` | Health check (`{"status":"ok"}`) | -### Przykład użycia +### Opcjonalne nagłówki + +| Nagłówek | Opis | +|----------|------| +| `X-KSeF-Number` | Numer KSeF faktury (wyświetlany w prawym górnym rogu) | +| `X-KSeF-QRCode` | URL kodu QR (renderowany na dole PDF) | + +### Przykłady użycia + +Podstawowe generowanie PDF: ```bash curl -X POST -H 'Content-Type: application/xml' \ @@ -94,6 +103,17 @@ curl -X POST -H 'Content-Type: application/xml' \ http://localhost:3001/generate -o faktura.pdf ``` +Z numerem KSeF i kodem QR: + +```bash +curl -X POST \ + -H 'Content-Type: application/xml' \ + -H 'X-KSeF-Number: 5555555555-20250808-9231003CA67B-BE' \ + -H 'X-KSeF-QRCode: https://ksef-test.mf.gov.pl/invoice/...' \ + --data-binary @assets/invoice.xml \ + http://localhost:3001/generate -o faktura.pdf +``` + ### Docker ```bash From dd2084c0325eb048ec30c053df79735a4f1e1654 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 15:46:09 +0100 Subject: [PATCH 06/17] add GitHub Actions workflow to publish Docker image to GHCR Builds and pushes to ghcr.io on every push to main. Tags with :latest and :sha. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/docker-publish.yml | 43 ++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/docker-publish.yml diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..d273c079 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,43 @@ +name: Build and publish Docker image + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/ksef-pdf + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=sha,prefix= + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} From e8c148087a2529a028c3ffe7c3c4c7fdfb2137a4 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 17:04:55 +0100 Subject: [PATCH 07/17] add pdfmake-html-renderer dependency Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 514 +++++++++++++++++++++++++++++++++++++++------- package.json | 1 + 2 files changed, 443 insertions(+), 72 deletions(-) diff --git a/package-lock.json b/package-lock.json index 69eb4389..f10eb543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,9 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "jsdom": "^26.1.0", "pdfmake": "^0.2.20", + "pdfmake-html-renderer": "^0.3.2", "xml-js": "^1.6.11" }, "devDependencies": { @@ -26,7 +28,6 @@ "eslint-plugin-prettier": "^5.5.4", "globals": "^16.3.0", "jiti": "^2.5.1", - "jsdom": "^26.1.0", "prettier": "^3.6.2", "typescript": "^5.8.3", "typescript-eslint": "^8.40.0", @@ -38,6 +39,19 @@ "@rollup/rollup-linux-x64-gnu": "^4.50.1" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "dev": true, @@ -82,7 +96,6 @@ }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", - "dev": true, "funding": [ { "type": "github", @@ -100,7 +113,6 @@ }, "node_modules/@csstools/css-calc": { "version": "2.1.4", - "dev": true, "funding": [ { "type": "github", @@ -122,7 +134,6 @@ }, "node_modules/@csstools/css-color-parser": { "version": "3.1.0", - "dev": true, "funding": [ { "type": "github", @@ -148,7 +159,6 @@ }, "node_modules/@csstools/css-parser-algorithms": { "version": "3.0.5", - "dev": true, "funding": [ { "type": "github", @@ -169,7 +179,6 @@ }, "node_modules/@csstools/css-tokenizer": { "version": "3.0.4", - "dev": true, "funding": [ { "type": "github", @@ -309,14 +318,22 @@ "node": ">=8" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -325,7 +342,6 @@ }, "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -699,7 +715,6 @@ }, "node_modules/@types/estree": { "version": "1.0.8", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1329,18 +1344,6 @@ } } }, - "node_modules/@vitest/coverage-v8/node_modules/@ampproject/remapping": { - "version": "2.3.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { "version": "1.0.2", "dev": true, @@ -1349,15 +1352,6 @@ "node": ">=18" } }, - "node_modules/@vitest/coverage-v8/node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@vitest/coverage-v8/node_modules/ast-v8-to-istanbul": { "version": "0.3.5", "dev": true, @@ -1557,7 +1551,6 @@ }, "node_modules/acorn": { "version": "8.15.0", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1576,7 +1569,6 @@ }, "node_modules/agent-base": { "version": "7.1.4", - "dev": true, "license": "MIT", "engines": { "node": ">= 14" @@ -1623,7 +1615,6 @@ }, "node_modules/ansi-styles": { "version": "4.3.0", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -1640,6 +1631,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "dev": true, @@ -1648,6 +1649,16 @@ "node": ">=12" } }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -1741,6 +1752,15 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -1764,6 +1784,72 @@ "node": ">= 16" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/clone": { "version": "1.0.4", "license": "MIT", @@ -1771,9 +1857,22 @@ "node": ">=0.8" } }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -1784,7 +1883,6 @@ }, "node_modules/color-name": { "version": "1.1.4", - "dev": true, "license": "MIT" }, "node_modules/compare-versions": { @@ -1819,9 +1917,22 @@ "version": "4.2.0", "license": "MIT" }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssstyle": { "version": "4.6.0", - "dev": true, "license": "MIT", "dependencies": { "@asamuzakjp/css-color": "^3.2.0", @@ -1833,7 +1944,6 @@ }, "node_modules/cssstyle/node_modules/@asamuzakjp/css-color": { "version": "3.2.0", - "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^2.1.3", @@ -1845,7 +1955,6 @@ }, "node_modules/data-urls": { "version": "5.0.0", - "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^4.0.0", @@ -1862,7 +1971,6 @@ }, "node_modules/debug": { "version": "4.4.3", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1876,9 +1984,17 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", - "dev": true, "license": "MIT" }, "node_modules/deep-eql": { @@ -1946,6 +2062,12 @@ "version": "1.2.0", "license": "MIT" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "license": "MIT", @@ -2352,7 +2474,6 @@ }, "node_modules/estree-walker": { "version": "3.0.3", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.0" @@ -2539,6 +2660,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "license": "MIT", @@ -2731,7 +2861,6 @@ }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", - "dev": true, "license": "MIT", "dependencies": { "whatwg-encoding": "^3.1.1" @@ -2747,7 +2876,6 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -2759,7 +2887,6 @@ }, "node_modules/https-proxy-agent": { "version": "7.0.6", - "dev": true, "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -2870,7 +2997,6 @@ }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2897,9 +3023,18 @@ }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", - "dev": true, "license": "MIT" }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/is-regex": { "version": "1.2.1", "license": "MIT", @@ -3099,7 +3234,6 @@ }, "node_modules/jsdom": { "version": "26.1.0", - "dev": true, "license": "MIT", "dependencies": { "cssstyle": "^4.2.1", @@ -3228,6 +3362,13 @@ "pathe": "^2.0.1" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT", + "peer": true + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -3259,12 +3400,10 @@ }, "node_modules/lru-cache": { "version": "10.4.3", - "dev": true, "license": "ISC" }, "node_modules/magic-string": { "version": "0.30.19", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -3313,6 +3452,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0", + "peer": true + }, "node_modules/merge2": { "version": "1.4.1", "dev": true, @@ -3350,7 +3496,6 @@ }, "node_modules/ms": { "version": "2.1.3", - "dev": true, "license": "MIT" }, "node_modules/muggle-string": { @@ -3382,7 +3527,6 @@ }, "node_modules/nwsapi": { "version": "2.2.22", - "dev": true, "license": "MIT" }, "node_modules/object-is": { @@ -3450,6 +3594,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "dev": true, @@ -3472,7 +3625,6 @@ }, "node_modules/parse5": { "version": "7.3.0", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -3483,7 +3635,6 @@ }, "node_modules/parse5/node_modules/entities": { "version": "6.0.1", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -3499,7 +3650,6 @@ }, "node_modules/path-exists": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3544,6 +3694,30 @@ "node": ">=18" } }, + "node_modules/pdfmake-html-renderer": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/pdfmake-html-renderer/-/pdfmake-html-renderer-0.3.2.tgz", + "integrity": "sha512-9rEUJyBQE1Bn1lzU18O4MhtNPTvT3DehKl6hOAQaoTxJKoE2CmM/jMqxjwrdS3NG8kS4442OL8OMdV5AI355jg==", + "license": "MIT", + "dependencies": { + "qrcode": "^1.5.4" + }, + "peerDependencies": { + "svelte": "^4.0.0" + } + }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -3573,6 +3747,15 @@ "node_modules/png-js": { "version": "1.0.0" }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "dev": true, @@ -3635,12 +3818,28 @@ }, "node_modules/punycode": { "version": "2.3.1", - "dev": true, "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/quansync": { "version": "0.2.11", "dev": true, @@ -3693,6 +3892,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "dev": true, @@ -3701,6 +3909,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "dev": true, @@ -3786,7 +4000,6 @@ }, "node_modules/rrweb-cssom": { "version": "0.8.0", - "dev": true, "license": "MIT" }, "node_modules/run-parallel": { @@ -3821,7 +4034,6 @@ }, "node_modules/saxes": { "version": "6.0.0", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -3841,6 +4053,12 @@ "node": ">=10" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-function-length": { "version": "1.2.2", "license": "MIT", @@ -3927,7 +4145,6 @@ }, "node_modules/source-map-js": { "version": "1.2.1", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -4050,9 +4267,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", - "dev": true, "license": "MIT" }, "node_modules/synckit": { @@ -4178,7 +4420,6 @@ }, "node_modules/tough-cookie": { "version": "5.1.2", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^6.1.32" @@ -4189,7 +4430,6 @@ }, "node_modules/tough-cookie/node_modules/tldts": { "version": "6.1.86", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^6.1.86" @@ -4200,12 +4440,10 @@ }, "node_modules/tough-cookie/node_modules/tldts-core": { "version": "6.1.86", - "dev": true, "license": "MIT" }, "node_modules/tr46": { "version": "5.1.1", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -5045,7 +5283,6 @@ }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -5056,7 +5293,6 @@ }, "node_modules/webidl-conversions": { "version": "7.0.0", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -5064,7 +5300,6 @@ }, "node_modules/whatwg-encoding": { "version": "3.1.1", - "dev": true, "license": "MIT", "dependencies": { "iconv-lite": "0.6.3" @@ -5075,7 +5310,6 @@ }, "node_modules/whatwg-mimetype": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5083,7 +5317,6 @@ }, "node_modules/whatwg-url": { "version": "14.2.0", - "dev": true, "license": "MIT", "dependencies": { "tr46": "^5.1.0", @@ -5107,6 +5340,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "dev": true, @@ -5159,7 +5398,6 @@ }, "node_modules/ws": { "version": "8.18.3", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -5189,7 +5427,6 @@ }, "node_modules/xml-name-validator": { "version": "5.0.0", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -5197,7 +5434,6 @@ }, "node_modules/xmlchars": { "version": "2.2.0", - "dev": true, "license": "MIT" }, "node_modules/xmldoc": { @@ -5210,11 +5446,145 @@ "node": ">=12.0.0" } }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "4.0.0", "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "dev": true, diff --git a/package.json b/package.json index 4e9b9eab..bcd4d9f6 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "dependencies": { "jsdom": "^26.1.0", "pdfmake": "^0.2.20", + "pdfmake-html-renderer": "^0.3.2", "xml-js": "^1.6.11" } } From b3413beffc3e354c8ae0e2ef98aada8e46251b28 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 17:06:06 +0100 Subject: [PATCH 08/17] extract buildDocDefinition functions from FA and UPO generators Split each generator into two steps: building the pdfmake TDocumentDefinitions object and rendering to PDF. This enables reuse of the doc definition for alternative output formats. Co-Authored-By: Claude Opus 4.6 --- src/lib-public/FA1-generator.ts | 9 ++++++--- src/lib-public/FA2-generator.ts | 9 ++++++--- src/lib-public/FA3-generator.ts | 9 ++++++--- src/lib-public/UPO-generator.ts | 10 +++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/src/lib-public/FA1-generator.ts b/src/lib-public/FA1-generator.ts index 6adf12c8..290aaa2c 100644 --- a/src/lib-public/FA1-generator.ts +++ b/src/lib-public/FA1-generator.ts @@ -23,11 +23,12 @@ import { AdditionalDataTypes } from './types/common.types'; pdfMake.vfs = pdfFonts.vfs; -export function generateFA1(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { +export function buildFA1DocDefinition(invoice: Faktura, additionalData: AdditionalDataTypes): TDocumentDefinitions { const isKOR_RABAT: boolean = invoice.Fa?.RodzajFaktury?._text == TRodzajFaktury.KOR && hasValue(invoice.Fa?.OkresFaKorygowanej); const rabatOrRowsInvoice: Content = isKOR_RABAT ? generateRabat(invoice.Fa!) : generateWiersze(invoice.Fa!); - const docDefinition: TDocumentDefinitions = { + + return { content: [ ...generateNaglowek(invoice.Fa, additionalData), generateDaneFaKorygowanej(invoice.Fa), @@ -52,6 +53,8 @@ export function generateFA1(invoice: Faktura, additionalData: AdditionalDataType ], ...generateStyle(), }; +} - return pdfMake.createPdf(docDefinition); +export function generateFA1(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { + return pdfMake.createPdf(buildFA1DocDefinition(invoice, additionalData)); } diff --git a/src/lib-public/FA2-generator.ts b/src/lib-public/FA2-generator.ts index 02a04fd2..98b60be2 100644 --- a/src/lib-public/FA2-generator.ts +++ b/src/lib-public/FA2-generator.ts @@ -23,11 +23,12 @@ import { AdditionalDataTypes } from './types/common.types'; pdfMake.vfs = pdfFonts.vfs; -export function generateFA2(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { +export function buildFA2DocDefinition(invoice: Faktura, additionalData: AdditionalDataTypes): TDocumentDefinitions { const isKOR_RABAT: boolean = invoice.Fa?.RodzajFaktury?._text == TRodzajFaktury.KOR && hasValue(invoice.Fa?.OkresFaKorygowanej); const rabatOrRowsInvoice: Content = isKOR_RABAT ? generateRabat(invoice.Fa!) : generateWiersze(invoice.Fa!); - const docDefinition: TDocumentDefinitions = { + + return { content: [ ...generateNaglowek(invoice.Fa, additionalData), generateDaneFaKorygowanej(invoice.Fa), @@ -52,6 +53,8 @@ export function generateFA2(invoice: Faktura, additionalData: AdditionalDataType ], ...generateStyle(), }; +} - return pdfMake.createPdf(docDefinition); +export function generateFA2(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { + return pdfMake.createPdf(buildFA2DocDefinition(invoice, additionalData)); } diff --git a/src/lib-public/FA3-generator.ts b/src/lib-public/FA3-generator.ts index b826965b..215f7941 100644 --- a/src/lib-public/FA3-generator.ts +++ b/src/lib-public/FA3-generator.ts @@ -23,11 +23,12 @@ import { AdditionalDataTypes } from './types/common.types'; pdfMake.vfs = pdfFonts.vfs; -export function generateFA3(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { +export function buildFA3DocDefinition(invoice: Faktura, additionalData: AdditionalDataTypes): TDocumentDefinitions { const isKOR_RABAT: boolean = invoice.Fa?.RodzajFaktury?._text == TRodzajFaktury.KOR && hasValue(invoice.Fa?.OkresFaKorygowanej); const rabatOrRowsInvoice: Content = isKOR_RABAT ? generateRabat(invoice.Fa!) : generateWiersze(invoice.Fa!); - const docDefinition: TDocumentDefinitions = { + + return { content: [ ...generateNaglowek(invoice.Fa, additionalData, invoice.Zalacznik), generateDaneFaKorygowanej(invoice.Fa), @@ -52,6 +53,8 @@ export function generateFA3(invoice: Faktura, additionalData: AdditionalDataType ], ...generateStyle(), }; +} - return pdfMake.createPdf(docDefinition); +export function generateFA3(invoice: Faktura, additionalData: AdditionalDataTypes): TCreatedPdf { + return pdfMake.createPdf(buildFA3DocDefinition(invoice, additionalData)); } diff --git a/src/lib-public/UPO-generator.ts b/src/lib-public/UPO-generator.ts index 8c4fe520..b8595642 100644 --- a/src/lib-public/UPO-generator.ts +++ b/src/lib-public/UPO-generator.ts @@ -7,9 +7,8 @@ import { Position } from '../shared/enums/common.enum'; import { generateDokumentUPO } from './generators/UPO4_3/Dokumenty'; import { generateNaglowekUPO } from './generators/UPO4_3/Naglowek'; -export async function generatePDFUPO(file: File): Promise { - const upo = (await parseXML(file)) as Upo; - const docDefinition: TDocumentDefinitions = { +export function buildUPODocDefinition(upo: Upo): TDocumentDefinitions { + return { content: [generateNaglowekUPO(upo.Potwierdzenie!), generateDokumentUPO(upo.Potwierdzenie!)], ...generateStyle(), pageSize: 'A4', @@ -22,6 +21,11 @@ export async function generatePDFUPO(file: File): Promise { }; }, }; +} + +export async function generatePDFUPO(file: File): Promise { + const upo = (await parseXML(file)) as Upo; + const docDefinition: TDocumentDefinitions = buildUPODocDefinition(upo); return new Promise((resolve, reject): void => { pdfMake.createPdf(docDefinition).getBlob((blob: Blob): void => { From 681252707001e36cd520a5203a2349fc90ab10cf Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 17:16:01 +0100 Subject: [PATCH 09/17] add HTML output format via pdfmake-html-renderer - Add renderDocDefinitionToHtml() for server-side HTML rendering - Add 'html' format option to generateInvoice() - Export buildFA1/FA2/FA3DocDefinition and buildUPODocDefinition - Externalize server-only deps in Vite config Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 8 ++++--- src/lib-public/generate-invoice.ts | 35 ++++++++++++++++++++++++++---- src/lib-public/index.ts | 5 +++++ src/lib-public/render-html.ts | 29 +++++++++++++++++++++++++ vite.config.ts | 10 ++++++++- 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 src/lib-public/render-html.ts diff --git a/src/index.ts b/src/index.ts index c79b5728..eb4a5220 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ export { generateInvoice, generatePDFUPO } from './lib-public'; -export { generateFA1 } from './lib-public/FA1-generator'; -export { generateFA2 } from './lib-public/FA2-generator'; -export { generateFA3 } from './lib-public/FA3-generator'; +export { generateFA1, buildFA1DocDefinition } from './lib-public/FA1-generator'; +export { generateFA2, buildFA2DocDefinition } from './lib-public/FA2-generator'; +export { generateFA3, buildFA3DocDefinition } from './lib-public/FA3-generator'; +export { buildUPODocDefinition } from './lib-public/UPO-generator'; +export { renderDocDefinitionToHtml } from './lib-public/render-html'; export { generateNaglowekUPO } from './lib-public/generators/UPO4_3/Naglowek'; export { generateDokumentUPO } from './lib-public/generators/UPO4_3/Dokumenty'; export { generateStyle } from './shared/PDF-functions'; diff --git a/src/lib-public/generate-invoice.ts b/src/lib-public/generate-invoice.ts index 290ef403..9a74de2f 100644 --- a/src/lib-public/generate-invoice.ts +++ b/src/lib-public/generate-invoice.ts @@ -1,12 +1,14 @@ -import { generateFA1 } from './FA1-generator'; +import { buildFA1DocDefinition, generateFA1 } from './FA1-generator'; import { Faktura as Faktura1 } from './types/fa1.types'; -import { generateFA2 } from './FA2-generator'; +import { buildFA2DocDefinition, generateFA2 } from './FA2-generator'; import { Faktura as Faktura2 } from './types/fa2.types'; -import { generateFA3 } from './FA3-generator'; +import { buildFA3DocDefinition, generateFA3 } from './FA3-generator'; import { Faktura as Faktura3 } from './types/fa3.types'; import { parseXML } from '../shared/XML-parser'; import { TCreatedPdf } from 'pdfmake/build/pdfmake'; +import { TDocumentDefinitions } from 'pdfmake/interfaces'; import { AdditionalDataTypes } from './types/common.types'; +import { renderDocDefinitionToHtml } from './render-html'; export async function generateInvoice( file: File, @@ -18,6 +20,11 @@ export async function generateInvoice( additionalData: AdditionalDataTypes, formatType: 'base64' ): Promise; +export async function generateInvoice( + file: File, + additionalData: AdditionalDataTypes, + formatType: 'html' +): Promise; export async function generateInvoice( file: File, additionalData: AdditionalDataTypes, @@ -26,6 +33,26 @@ export async function generateInvoice( const xml: unknown = await parseXML(file); const wersja: any = (xml as any)?.Faktura?.Naglowek?.KodFormularza?._attributes?.kodSystemowy; + if (formatType === 'html') { + let docDefinition: TDocumentDefinitions; + + switch (wersja) { + case 'FA (1)': + docDefinition = buildFA1DocDefinition((xml as any).Faktura as Faktura1, additionalData); + break; + case 'FA (2)': + docDefinition = buildFA2DocDefinition((xml as any).Faktura as Faktura2, additionalData); + break; + case 'FA (3)': + docDefinition = buildFA3DocDefinition((xml as any).Faktura as Faktura3, additionalData); + break; + default: + throw new Error(`Unsupported invoice version: ${wersja}`); + } + + return renderDocDefinitionToHtml(docDefinition); + } + let pdf: TCreatedPdf; return new Promise((resolve): void => { @@ -55,5 +82,5 @@ export async function generateInvoice( }); } -type FormatType = 'blob' | 'base64'; +type FormatType = 'blob' | 'base64' | 'html'; type FormatTypeResult = Blob | string; diff --git a/src/lib-public/index.ts b/src/lib-public/index.ts index f8a22646..749ea8c5 100644 --- a/src/lib-public/index.ts +++ b/src/lib-public/index.ts @@ -2,3 +2,8 @@ import { generateInvoice } from './generate-invoice'; import { generatePDFUPO } from './UPO-generator'; export { generateInvoice, generatePDFUPO }; +export { buildFA1DocDefinition } from './FA1-generator'; +export { buildFA2DocDefinition } from './FA2-generator'; +export { buildFA3DocDefinition } from './FA3-generator'; +export { buildUPODocDefinition } from './UPO-generator'; +export { renderDocDefinitionToHtml } from './render-html'; diff --git a/src/lib-public/render-html.ts b/src/lib-public/render-html.ts new file mode 100644 index 00000000..563a73dd --- /dev/null +++ b/src/lib-public/render-html.ts @@ -0,0 +1,29 @@ +import { TDocumentDefinitions } from 'pdfmake/interfaces'; + +export async function renderDocDefinitionToHtml(docDefinition: TDocumentDefinitions): Promise { + const { PdfmakeHtmlRenderer } = await import('pdfmake-html-renderer/server'); + const fs = await import('node:fs'); + const nodeModule = await import('node:module'); + + // @ts-expect-error -- import.meta.url is available at runtime in Node ESM; TS lib config does not cover it + const require = nodeModule.createRequire(import.meta.url); + const cssPath = require.resolve('pdfmake-html-renderer/dist/index.css'); + const css = fs.readFileSync(cssPath, 'utf-8'); + + const { html: body, css: componentCss } = PdfmakeHtmlRenderer.render({ + document: docDefinition, + pageShadow: false, + mode: 'natural', + }); + + return ` + + + + +Faktura + + +${body} +`; +} diff --git a/vite.config.ts b/vite.config.ts index a5f8cf97..ae6b1093 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,7 +16,15 @@ export default defineConfig(({ mode }) => { emptyOutDir: true, formats: ['es', 'umd'], rollupOptions: { - external: [/\.spec\.ts$/, /\.test\.ts$/, 'src/app-private', 'src/app-public'], + external: [ + /\.spec\.ts$/, + /\.test\.ts$/, + 'src/app-private', + 'src/app-public', + 'pdfmake-html-renderer/server', + 'node:fs', + 'node:module', + ], }, }, }, From 52d1e0901ad0996ca8fc7b05122fd2588298c8d8 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 17:16:26 +0100 Subject: [PATCH 10/17] add POST /generate/html endpoint to server Same interface as /generate (XML body, X-KSeF-Number/X-KSeF-QRCode headers) but returns text/html instead of application/pdf. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/server/index.js b/server/index.js index 908072b6..da4c9fda 100644 --- a/server/index.js +++ b/server/index.js @@ -58,6 +58,25 @@ const server = createServer(async (req, res) => { }); res.end(buffer); } + } else if (req.method === 'POST' && req.url === '/generate/html') { + const body = await collectBody(req); + + if (!body.length) { + status = 400; + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Empty request body' })); + } else { + const nrKSeF = req.headers['x-ksef-number'] || ''; + const qrCode = req.headers['x-ksef-qrcode'] || undefined; + const file = new File([body], 'invoice.xml', { type: 'text/xml' }); + const html = await generateInvoice(file, { nrKSeF, qrCode }, 'html'); + + res.writeHead(200, { + 'Content-Type': 'text/html; charset=utf-8', + 'Content-Length': Buffer.byteLength(html), + }); + res.end(html); + } } else { status = 404; res.writeHead(404, { 'Content-Type': 'application/json' }); From b1382394bfee773c8f7940cec7147d95b4140533 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 18:05:05 +0100 Subject: [PATCH 11/17] move HTML rendering to server, add buildInvoiceDocDefinition The pdfmake-html-renderer SSR module requires Node.js APIs (fs, module) that Vite browser-externalizes in the library bundle. Instead, keep the HTML rendering logic in server/render-html.js (not bundled) and export buildInvoiceDocDefinition from the library for server-side consumers. Co-Authored-By: Claude Opus 4.6 --- server/index.js | 6 ++-- server/render-html.js | 26 +++++++++++++++++ src/index.ts | 3 +- src/lib-public/generate-invoice.ts | 47 +++++++++++++----------------- src/lib-public/index.ts | 5 ++-- src/lib-public/render-html.ts | 29 ------------------ vite.config.ts | 10 +------ 7 files changed, 54 insertions(+), 72 deletions(-) create mode 100644 server/render-html.js delete mode 100644 src/lib-public/render-html.ts diff --git a/server/index.js b/server/index.js index da4c9fda..272341f8 100644 --- a/server/index.js +++ b/server/index.js @@ -17,7 +17,8 @@ globalThis.File = dom.window.File; globalThis.Blob = dom.window.Blob; import { createServer } from 'node:http'; -import { generateInvoice } from '../dist/ksef-fe-invoice-converter.js'; +import { generateInvoice, buildInvoiceDocDefinition } from '../dist/ksef-fe-invoice-converter.js'; +import { renderDocDefinitionToHtml } from './render-html.js'; const PORT = Number(process.env.PORT) || 3001; @@ -69,7 +70,8 @@ const server = createServer(async (req, res) => { const nrKSeF = req.headers['x-ksef-number'] || ''; const qrCode = req.headers['x-ksef-qrcode'] || undefined; const file = new File([body], 'invoice.xml', { type: 'text/xml' }); - const html = await generateInvoice(file, { nrKSeF, qrCode }, 'html'); + const docDefinition = await buildInvoiceDocDefinition(file, { nrKSeF, qrCode }); + const html = renderDocDefinitionToHtml(docDefinition); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', diff --git a/server/render-html.js b/server/render-html.js new file mode 100644 index 00000000..ab500e9b --- /dev/null +++ b/server/render-html.js @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { PdfmakeHtmlRenderer } from 'pdfmake-html-renderer/server'; + +const require = createRequire(import.meta.url); +const cssPath = require.resolve('pdfmake-html-renderer/dist/index.css'); +const baseCss = readFileSync(cssPath, 'utf-8'); + +export function renderDocDefinitionToHtml(docDefinition) { + const { html: body, css: componentCss } = PdfmakeHtmlRenderer.render({ + document: docDefinition, + pageShadow: false, + mode: 'natural', + }); + + return ` + + + + +Faktura + + +${body} +`; +} diff --git a/src/index.ts b/src/index.ts index eb4a5220..59244c8a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,8 @@ -export { generateInvoice, generatePDFUPO } from './lib-public'; +export { generateInvoice, generatePDFUPO, buildInvoiceDocDefinition } from './lib-public'; export { generateFA1, buildFA1DocDefinition } from './lib-public/FA1-generator'; export { generateFA2, buildFA2DocDefinition } from './lib-public/FA2-generator'; export { generateFA3, buildFA3DocDefinition } from './lib-public/FA3-generator'; export { buildUPODocDefinition } from './lib-public/UPO-generator'; -export { renderDocDefinitionToHtml } from './lib-public/render-html'; export { generateNaglowekUPO } from './lib-public/generators/UPO4_3/Naglowek'; export { generateDokumentUPO } from './lib-public/generators/UPO4_3/Dokumenty'; export { generateStyle } from './shared/PDF-functions'; diff --git a/src/lib-public/generate-invoice.ts b/src/lib-public/generate-invoice.ts index 9a74de2f..0f9927a8 100644 --- a/src/lib-public/generate-invoice.ts +++ b/src/lib-public/generate-invoice.ts @@ -8,7 +8,6 @@ import { parseXML } from '../shared/XML-parser'; import { TCreatedPdf } from 'pdfmake/build/pdfmake'; import { TDocumentDefinitions } from 'pdfmake/interfaces'; import { AdditionalDataTypes } from './types/common.types'; -import { renderDocDefinitionToHtml } from './render-html'; export async function generateInvoice( file: File, @@ -20,11 +19,6 @@ export async function generateInvoice( additionalData: AdditionalDataTypes, formatType: 'base64' ): Promise; -export async function generateInvoice( - file: File, - additionalData: AdditionalDataTypes, - formatType: 'html' -): Promise; export async function generateInvoice( file: File, additionalData: AdditionalDataTypes, @@ -33,26 +27,6 @@ export async function generateInvoice( const xml: unknown = await parseXML(file); const wersja: any = (xml as any)?.Faktura?.Naglowek?.KodFormularza?._attributes?.kodSystemowy; - if (formatType === 'html') { - let docDefinition: TDocumentDefinitions; - - switch (wersja) { - case 'FA (1)': - docDefinition = buildFA1DocDefinition((xml as any).Faktura as Faktura1, additionalData); - break; - case 'FA (2)': - docDefinition = buildFA2DocDefinition((xml as any).Faktura as Faktura2, additionalData); - break; - case 'FA (3)': - docDefinition = buildFA3DocDefinition((xml as any).Faktura as Faktura3, additionalData); - break; - default: - throw new Error(`Unsupported invoice version: ${wersja}`); - } - - return renderDocDefinitionToHtml(docDefinition); - } - let pdf: TCreatedPdf; return new Promise((resolve): void => { @@ -82,5 +56,24 @@ export async function generateInvoice( }); } -type FormatType = 'blob' | 'base64' | 'html'; +export async function buildInvoiceDocDefinition( + file: File, + additionalData: AdditionalDataTypes +): Promise { + const xml: unknown = await parseXML(file); + const wersja: any = (xml as any)?.Faktura?.Naglowek?.KodFormularza?._attributes?.kodSystemowy; + + switch (wersja) { + case 'FA (1)': + return buildFA1DocDefinition((xml as any).Faktura as Faktura1, additionalData); + case 'FA (2)': + return buildFA2DocDefinition((xml as any).Faktura as Faktura2, additionalData); + case 'FA (3)': + return buildFA3DocDefinition((xml as any).Faktura as Faktura3, additionalData); + default: + throw new Error(`Unsupported invoice version: ${wersja}`); + } +} + +type FormatType = 'blob' | 'base64'; type FormatTypeResult = Blob | string; diff --git a/src/lib-public/index.ts b/src/lib-public/index.ts index 749ea8c5..2a9c5a34 100644 --- a/src/lib-public/index.ts +++ b/src/lib-public/index.ts @@ -1,9 +1,8 @@ -import { generateInvoice } from './generate-invoice'; +import { generateInvoice, buildInvoiceDocDefinition } from './generate-invoice'; import { generatePDFUPO } from './UPO-generator'; -export { generateInvoice, generatePDFUPO }; +export { generateInvoice, generatePDFUPO, buildInvoiceDocDefinition }; export { buildFA1DocDefinition } from './FA1-generator'; export { buildFA2DocDefinition } from './FA2-generator'; export { buildFA3DocDefinition } from './FA3-generator'; export { buildUPODocDefinition } from './UPO-generator'; -export { renderDocDefinitionToHtml } from './render-html'; diff --git a/src/lib-public/render-html.ts b/src/lib-public/render-html.ts deleted file mode 100644 index 563a73dd..00000000 --- a/src/lib-public/render-html.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { TDocumentDefinitions } from 'pdfmake/interfaces'; - -export async function renderDocDefinitionToHtml(docDefinition: TDocumentDefinitions): Promise { - const { PdfmakeHtmlRenderer } = await import('pdfmake-html-renderer/server'); - const fs = await import('node:fs'); - const nodeModule = await import('node:module'); - - // @ts-expect-error -- import.meta.url is available at runtime in Node ESM; TS lib config does not cover it - const require = nodeModule.createRequire(import.meta.url); - const cssPath = require.resolve('pdfmake-html-renderer/dist/index.css'); - const css = fs.readFileSync(cssPath, 'utf-8'); - - const { html: body, css: componentCss } = PdfmakeHtmlRenderer.render({ - document: docDefinition, - pageShadow: false, - mode: 'natural', - }); - - return ` - - - - -Faktura - - -${body} -`; -} diff --git a/vite.config.ts b/vite.config.ts index ae6b1093..a5f8cf97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -16,15 +16,7 @@ export default defineConfig(({ mode }) => { emptyOutDir: true, formats: ['es', 'umd'], rollupOptions: { - external: [ - /\.spec\.ts$/, - /\.test\.ts$/, - 'src/app-private', - 'src/app-public', - 'pdfmake-html-renderer/server', - 'node:fs', - 'node:module', - ], + external: [/\.spec\.ts$/, /\.test\.ts$/, 'src/app-private', 'src/app-public'], }, }, }, From 885dad48ce2b149c5b4177f9adba1e83323fe23d Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 18:05:50 +0100 Subject: [PATCH 12/17] add integration tests for POST /generate/html endpoint Tests cover: valid XML returning HTML, empty body returning 400, and invalid XML returning 500. Co-Authored-By: Claude Opus 4.6 --- server/server.spec.ts | 45 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/server/server.spec.ts b/server/server.spec.ts index f15a72b6..57bc8f38 100644 --- a/server/server.spec.ts +++ b/server/server.spec.ts @@ -137,4 +137,49 @@ describe('ksef-pdf HTTP server', () => { expect(res.status).toBe(400); expect(JSON.parse(res.body.toString())).toEqual({ error: 'Empty request body' }); }); + + it('POST /generate/html with valid invoice XML returns HTML', async () => { + const xml = readFileSync(join(__dirname, '..', 'assets', 'invoice.xml')); + + const res = await request('/generate/html', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: xml, + }); + + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('text/html; charset=utf-8'); + + const html = res.body.toString(); + + expect(html).toContain(''); + expect(html.length).toBeGreaterThan(1000); + }, 15000); + + it('POST /generate/html with empty body returns 400', async () => { + const res = await request('/generate/html', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: Buffer.alloc(0), + }); + + expect(res.status).toBe(400); + expect(JSON.parse(res.body.toString())).toEqual({ error: 'Empty request body' }); + }); + + it('POST /generate/html with invalid XML returns 500 with JSON error', async () => { + const res = await request('/generate/html', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: Buffer.from('not an invoice'), + }); + + expect(res.status).toBe(500); + expect(res.headers['content-type']).toBe('application/json'); + const body = JSON.parse(res.body.toString()); + expect(body).toHaveProperty('error'); + expect(typeof body.error).toBe('string'); + }); }); From 128c5cdffe9a9ac832759f68dce77da2f3caff3f Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 18:15:35 +0100 Subject: [PATCH 13/17] update README with HTML generation endpoint and examples Co-Authored-By: Claude Opus 4.6 --- readme.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index f9b9b2d7..56f07114 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # Biblioteka do generowania wizualizacji PDF faktur i UPO -Biblioteka do generowania wizualizacji PDF faktur oraz UPO na podstawie plików XML po stronie klienta. +Biblioteka do generowania wizualizacji PDF oraz HTML faktur oraz UPO na podstawie plików XML. --- @@ -8,6 +8,7 @@ Biblioteka do generowania wizualizacji PDF faktur oraz UPO na podstawie plików Biblioteka zawiera następujące funkcjonalności: - Generowanie wizualizacji PDF faktur + - Generowanie wizualizacji HTML faktur - Generowanie wizualizacji PDF UPO --- @@ -68,7 +69,7 @@ Aplikacja uruchomi się domyślnie pod adresem: [http://localhost:5173/](http:// ## 5. Serwer HTTP (mikroserwis) -Biblioteka może działać jako samodzielny serwer HTTP — XML na wejściu, PDF na wyjściu. +Biblioteka może działać jako samodzielny serwer HTTP — XML na wejściu, PDF lub HTML na wyjściu. ### Uruchomienie lokalne @@ -84,6 +85,7 @@ Serwer nasłuchuje na porcie `3001` (zmiana przez zmienną środowiskową `PORT` | Metoda | Ścieżka | Opis | |--------|---------|------| | `POST` | `/generate` | Wyślij XML, otrzymaj PDF | +| `POST` | `/generate/html` | Wyślij XML, otrzymaj HTML | | `GET` | `/health` | Health check (`{"status":"ok"}`) | ### Opcjonalne nagłówki @@ -114,6 +116,14 @@ curl -X POST \ http://localhost:3001/generate -o faktura.pdf ``` +Generowanie HTML (te same nagłówki co dla PDF): + +```bash +curl -X POST -H 'Content-Type: application/xml' \ + --data-binary @assets/invoice.xml \ + http://localhost:3001/generate/html -o faktura.html +``` + ### Docker ```bash From dd1737409409df77774859a2fd218666e0b87add Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 21:39:08 +0100 Subject: [PATCH 14/17] rename /generate to /generate/pdf for consistent API paths Co-Authored-By: Claude Opus 4.6 --- readme.md | 6 +++--- server/index.js | 2 +- server/server.spec.ts | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/readme.md b/readme.md index 56f07114..de257c82 100644 --- a/readme.md +++ b/readme.md @@ -84,7 +84,7 @@ Serwer nasłuchuje na porcie `3001` (zmiana przez zmienną środowiskową `PORT` | Metoda | Ścieżka | Opis | |--------|---------|------| -| `POST` | `/generate` | Wyślij XML, otrzymaj PDF | +| `POST` | `/generate/pdf` | Wyślij XML, otrzymaj PDF | | `POST` | `/generate/html` | Wyślij XML, otrzymaj HTML | | `GET` | `/health` | Health check (`{"status":"ok"}`) | @@ -102,7 +102,7 @@ Podstawowe generowanie PDF: ```bash curl -X POST -H 'Content-Type: application/xml' \ --data-binary @assets/invoice.xml \ - http://localhost:3001/generate -o faktura.pdf + http://localhost:3001/generate/pdf -o faktura.pdf ``` Z numerem KSeF i kodem QR: @@ -113,7 +113,7 @@ curl -X POST \ -H 'X-KSeF-Number: 5555555555-20250808-9231003CA67B-BE' \ -H 'X-KSeF-QRCode: https://ksef-test.mf.gov.pl/invoice/...' \ --data-binary @assets/invoice.xml \ - http://localhost:3001/generate -o faktura.pdf + http://localhost:3001/generate/pdf -o faktura.pdf ``` Generowanie HTML (te same nagłówki co dla PDF): diff --git a/server/index.js b/server/index.js index 272341f8..662ff21f 100644 --- a/server/index.js +++ b/server/index.js @@ -39,7 +39,7 @@ const server = createServer(async (req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok' })); - } else if (req.method === 'POST' && req.url === '/generate') { + } else if (req.method === 'POST' && req.url === '/generate/pdf') { const body = await collectBody(req); if (!body.length) { diff --git a/server/server.spec.ts b/server/server.spec.ts index 57bc8f38..4063a037 100644 --- a/server/server.spec.ts +++ b/server/server.spec.ts @@ -90,10 +90,10 @@ describe('ksef-pdf HTTP server', () => { expect(JSON.parse(res.body.toString())).toEqual({ status: 'ok' }); }); - it('POST /generate with valid invoice XML returns a PDF', async () => { + it('POST /generate/pdf with valid invoice XML returns a PDF', async () => { const xml = readFileSync(join(__dirname, '..', 'assets', 'invoice.xml')); - const res = await request('/generate', { + const res = await request('/generate/pdf', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: xml, @@ -105,8 +105,8 @@ describe('ksef-pdf HTTP server', () => { expect(res.body.length).toBeGreaterThan(1000); }, 15000); - it('POST /generate with invalid XML returns 500 with JSON error', async () => { - const res = await request('/generate', { + it('POST /generate/pdf with invalid XML returns 500 with JSON error', async () => { + const res = await request('/generate/pdf', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: Buffer.from('not an invoice'), @@ -127,8 +127,8 @@ describe('ksef-pdf HTTP server', () => { expect(JSON.parse(res.body.toString())).toEqual({ error: 'Not found' }); }); - it('POST /generate with empty body returns 400', async () => { - const res = await request('/generate', { + it('POST /generate/pdf with empty body returns 400', async () => { + const res = await request('/generate/pdf', { method: 'POST', headers: { 'Content-Type': 'application/xml' }, body: Buffer.alloc(0), From 1c73f34e38eb7bbd2c0f689a081c9c836ac0ceaf Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 21:44:01 +0100 Subject: [PATCH 15/17] harden server: payload size limit, sanitized errors, graceful teardown - collectBody enforces configurable max body size (10 MB default, configurable via MAX_BODY_BYTES env var), returns 413 on overflow - Catch block logs full error server-side, returns generic message to clients (no internal details leaked) - Test afterAll awaits process exit with SIGKILL fallback Co-Authored-By: Claude Opus 4.6 --- server/index.js | 41 +++++++++++++++++++++++++++++++++++------ server/server.spec.ts | 32 ++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/server/index.js b/server/index.js index 662ff21f..8d46a90b 100644 --- a/server/index.js +++ b/server/index.js @@ -21,12 +21,34 @@ import { generateInvoice, buildInvoiceDocDefinition } from '../dist/ksef-fe-invo import { renderDocDefinitionToHtml } from './render-html.js'; const PORT = Number(process.env.PORT) || 3001; +const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES) || 10 * 1024 * 1024; // 10 MB -function collectBody(req) { +function collectBody(req, maxBytes = MAX_BODY_BYTES) { return new Promise((resolve, reject) => { const chunks = []; - req.on('data', (chunk) => chunks.push(chunk)); - req.on('end', () => resolve(Buffer.concat(chunks))); + let totalBytes = 0; + let rejected = false; + + req.on('data', (chunk) => { + if (rejected) { + return; + } + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + rejected = true; + req.resume(); // drain remaining data so the socket stays usable + const err = new Error('Payload too large'); + err.status = 413; + reject(err); + return; + } + chunks.push(chunk); + }); + req.on('end', () => { + if (!rejected) { + resolve(Buffer.concat(chunks)); + } + }); req.on('error', reject); }); } @@ -85,9 +107,16 @@ const server = createServer(async (req, res) => { res.end(JSON.stringify({ error: 'Not found' })); } } catch (err) { - status = 500; - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: err.message || 'Internal server error' })); + console.error('Request error:', err); + if (err.status === 413) { + status = 413; + res.writeHead(413, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Payload too large' })); + } else { + status = 500; + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Internal server error' })); + } } const duration = Date.now() - start; diff --git a/server/server.spec.ts b/server/server.spec.ts index 4063a037..1b931929 100644 --- a/server/server.spec.ts +++ b/server/server.spec.ts @@ -78,8 +78,23 @@ describe('ksef-pdf HTTP server', () => { }); }, 30000); - afterAll(() => { - serverProcess?.kill('SIGTERM'); + afterAll(async () => { + if (!serverProcess) { + return; + } + + serverProcess.kill('SIGTERM'); + await new Promise((resolve) => { + const timeout = setTimeout(() => { + serverProcess.kill('SIGKILL'); + resolve(); + }, 5000); + + serverProcess.on('exit', () => { + clearTimeout(timeout); + resolve(); + }); + }); }); it('GET /health returns 200 with status ok', async () => { @@ -169,6 +184,19 @@ describe('ksef-pdf HTTP server', () => { expect(JSON.parse(res.body.toString())).toEqual({ error: 'Empty request body' }); }); + it('POST /generate/pdf with oversized body returns 413', async () => { + const oversized = Buffer.alloc(11 * 1024 * 1024, 'x'); + + const res = await request('/generate/pdf', { + method: 'POST', + headers: { 'Content-Type': 'application/xml' }, + body: oversized, + }); + + expect(res.status).toBe(413); + expect(JSON.parse(res.body.toString())).toEqual({ error: 'Payload too large' }); + }, 15000); + it('POST /generate/html with invalid XML returns 500 with JSON error', async () => { const res = await request('/generate/html', { method: 'POST', From 1a86e98c11c0b203ff53b54c72b966c87c290f43 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 21:49:02 +0100 Subject: [PATCH 16/17] address review feedback: request IDs, port 0, error assertions, docs - Add request ID (UUID) to all log lines and error responses for client-server correlation - Document intentional jsdom global pollution with process isolation note - Fix PORT=0 support so OS can assign a free port (tests + server) - Tests parse actual port from server stdout instead of random range - Strengthen error response assertions: verify exact message and non-empty requestId - Add ESLint padding-line blank lines in test helper - Fix QR code header description to cover both PDF and HTML output Co-Authored-By: Claude Opus 4.6 --- readme.md | 2 +- server/index.js | 21 +++++++++++++-------- server/server.spec.ts | 31 ++++++++++++++++++++++++------- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/readme.md b/readme.md index de257c82..24809a48 100644 --- a/readme.md +++ b/readme.md @@ -93,7 +93,7 @@ Serwer nasłuchuje na porcie `3001` (zmiana przez zmienną środowiskową `PORT` | Nagłówek | Opis | |----------|------| | `X-KSeF-Number` | Numer KSeF faktury (wyświetlany w prawym górnym rogu) | -| `X-KSeF-QRCode` | URL kodu QR (renderowany na dole PDF) | +| `X-KSeF-QRCode` | URL kodu QR (renderowany na dole dokumentu) | ### Przykłady użycia diff --git a/server/index.js b/server/index.js index 8d46a90b..1af5ee17 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,8 @@ import { JSDOM } from 'jsdom'; +import { randomUUID } from 'node:crypto'; -// pdfmake expects browser globals (window, document, navigator) at module load time. -// Provide them via jsdom before importing the library. +// Intentional global pollution: pdfmake expects browser globals (window, document, +// navigator) at module load time. This server should run as a dedicated process. const dom = new JSDOM('', { url: 'http://localhost' }); globalThis.window = dom.window; globalThis.document = dom.window.document; @@ -20,7 +21,7 @@ import { createServer } from 'node:http'; import { generateInvoice, buildInvoiceDocDefinition } from '../dist/ksef-fe-invoice-converter.js'; import { renderDocDefinitionToHtml } from './render-html.js'; -const PORT = Number(process.env.PORT) || 3001; +const PORT = process.env.PORT !== undefined ? Number(process.env.PORT) : 3001; const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES) || 10 * 1024 * 1024; // 10 MB function collectBody(req, maxBytes = MAX_BODY_BYTES) { @@ -54,6 +55,7 @@ function collectBody(req, maxBytes = MAX_BODY_BYTES) { } const server = createServer(async (req, res) => { + const requestId = randomUUID(); const start = Date.now(); let status = 200; @@ -107,22 +109,25 @@ const server = createServer(async (req, res) => { res.end(JSON.stringify({ error: 'Not found' })); } } catch (err) { - console.error('Request error:', err); + console.error(`[${requestId}] Request error:`, err); if (err.status === 413) { status = 413; res.writeHead(413, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Payload too large' })); + res.end(JSON.stringify({ error: 'Payload too large', requestId })); } else { status = 500; res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal server error' })); + res.end(JSON.stringify({ error: 'Internal server error', requestId })); } } const duration = Date.now() - start; - console.log(`${req.method} ${req.url} ${status} ${duration}ms`); + console.log(`[${requestId}] ${req.method} ${req.url} ${status} ${duration}ms`); }); server.listen(PORT, () => { - console.log(`ksef-pdf-generator listening on port ${PORT}`); + const addr = server.address(); + const actualPort = typeof addr === 'object' ? addr.port : PORT; + + console.log(`ksef-pdf-generator listening on port ${actualPort}`); }); diff --git a/server/server.spec.ts b/server/server.spec.ts index 1b931929..60d53ef6 100644 --- a/server/server.spec.ts +++ b/server/server.spec.ts @@ -7,7 +7,7 @@ import { fileURLToPath } from 'node:url'; import { request as httpRequest, IncomingMessage } from 'node:http'; const __dirname = dirname(fileURLToPath(import.meta.url)); -const PORT = 3456 + Math.floor(Math.random() * 1000); +let PORT: number; let serverProcess: ChildProcess; function request( @@ -23,6 +23,7 @@ function request( req.on('response', (res: IncomingMessage) => { const chunks: Buffer[] = []; + res.on('data', (chunk: Buffer) => chunks.push(chunk)); res.on('end', () => { resolve({ @@ -45,16 +46,19 @@ function request( describe('ksef-pdf HTTP server', () => { beforeAll(async () => { serverProcess = spawn('node', [join(__dirname, 'index.js')], { - env: { ...process.env, PORT: String(PORT) }, + env: { ...process.env, PORT: '0' }, stdio: ['ignore', 'pipe', 'pipe'], }); - // Wait for server to be ready + // Wait for server to be ready and parse the OS-assigned port await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 15000); serverProcess.stdout?.on('data', (data: Buffer) => { - if (data.toString().includes('listening')) { + const match = data.toString().match(/listening on port (\d+)/); + + if (match) { + PORT = Number(match[1]); clearTimeout(timeout); resolve(); } @@ -129,9 +133,13 @@ describe('ksef-pdf HTTP server', () => { expect(res.status).toBe(500); expect(res.headers['content-type']).toBe('application/json'); + const body = JSON.parse(res.body.toString()); + expect(body).toHaveProperty('error'); - expect(typeof body.error).toBe('string'); + expect(body.error).toBe('Internal server error'); + expect(body).toHaveProperty('requestId'); + expect(body.requestId.length).toBeGreaterThan(0); }); it('GET /nonexistent returns 404', async () => { @@ -194,7 +202,12 @@ describe('ksef-pdf HTTP server', () => { }); expect(res.status).toBe(413); - expect(JSON.parse(res.body.toString())).toEqual({ error: 'Payload too large' }); + + const body = JSON.parse(res.body.toString()); + + expect(body.error).toBe('Payload too large'); + expect(body).toHaveProperty('requestId'); + expect(body.requestId.length).toBeGreaterThan(0); }, 15000); it('POST /generate/html with invalid XML returns 500 with JSON error', async () => { @@ -206,8 +219,12 @@ describe('ksef-pdf HTTP server', () => { expect(res.status).toBe(500); expect(res.headers['content-type']).toBe('application/json'); + const body = JSON.parse(res.body.toString()); + expect(body).toHaveProperty('error'); - expect(typeof body.error).toBe('string'); + expect(body.error).toBe('Internal server error'); + expect(body).toHaveProperty('requestId'); + expect(body.requestId.length).toBeGreaterThan(0); }); }); From 4cce8eed22bfa1aaac28f5520b7886c9a460aff3 Mon Sep 17 00:00:00 2001 From: emil Date: Thu, 12 Feb 2026 21:55:43 +0100 Subject: [PATCH 17/17] fix clone URL in README, add KSeF header propagation test - Fix broken git clone URL (trailing #, wrong org) - Add test verifying X-KSeF-Number header appears in HTML output Co-Authored-By: Claude Opus 4.6 --- readme.md | 2 +- server/server.spec.ts | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 24809a48..e5a2c947 100644 --- a/readme.md +++ b/readme.md @@ -20,7 +20,7 @@ Biblioteka do generowania wizualizacji PDF oraz HTML faktur oraz UPO na podstawi 2. Sklonuj repozytorium i przejdź do folderu projektu: ```bash - git clone https://github.com/CIRFMF/ksef-pdf-generator# + git clone https://github.com/appunite/ksef-pdf-generator.git cd ksef-pdf-generator ``` diff --git a/server/server.spec.ts b/server/server.spec.ts index 60d53ef6..b66612a4 100644 --- a/server/server.spec.ts +++ b/server/server.spec.ts @@ -210,6 +210,23 @@ describe('ksef-pdf HTTP server', () => { expect(body.requestId.length).toBeGreaterThan(0); }, 15000); + it('POST /generate/html with X-KSeF-Number header includes the number in output', async () => { + const xml = readFileSync(join(__dirname, '..', 'assets', 'invoice.xml')); + const ksefNumber = '5555555555-20250808-9231003CA67B-BE'; + + const res = await request('/generate/html', { + method: 'POST', + headers: { + 'Content-Type': 'application/xml', + 'X-KSeF-Number': ksefNumber, + }, + body: xml, + }); + + expect(res.status).toBe(200); + expect(res.body.toString()).toContain(ksefNumber); + }, 15000); + it('POST /generate/html with invalid XML returns 500 with JSON error', async () => { const res = await request('/generate/html', { method: 'POST',