@cenk1cenk2/md-printer 2.20.11 → 2.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import { DEFAULT_LANGUAGE, I18N_DIRECTORY, RequiredTemplateFiles, TEMPLATE_DIRECTORY, TemplateFiles } from "../constants/template.constants.mjs";
2
2
  import { InputFileType, OutputFileType } from "../constants/file.constants.mjs";
3
3
  import { I18nService } from "../services/i18n.service.mjs";
4
+ import { ATrustAdapter } from "../services/signature/adapters/a-trust.adapter.mjs";
5
+ import { SignatureService } from "../services/signature/signature.service.mjs";
4
6
  import "../services/index.mjs";
5
7
  import tailwind from "@tailwindcss/postcss";
6
8
  import { watch } from "chokidar";
@@ -70,7 +72,10 @@ var MDPrinter = class extends Command {
70
72
  char: "d",
71
73
  description: "Run with Chrome browser instead of publishing the file."
72
74
  }),
73
- continuous: Flags.boolean({ description: "Render PDF as a single continuous page without page breaks." })
75
+ continuous: Flags.boolean({ description: "Render PDF as a single continuous page without page breaks." }),
76
+ sign: Flags.boolean({ description: "Sign the PDF with a qualified electronic signature through ID Austria (A-Trust)." }),
77
+ ["sign-provider"]: Flags.string({ description: "Signature provider, defaults to a-trust. Overwrites the one defined in front-matter and template settings." }),
78
+ ["sign-certificate"]: Flags.string({ description: "Signer certificate (DER or PEM) used for the visible signature block and to verify the returned signature." })
74
79
  };
75
80
  static args = {
76
81
  file: Args.string({ description: "File to be processed." }),
@@ -88,17 +93,24 @@ var MDPrinter = class extends Command {
88
93
  cs;
89
94
  fs;
90
95
  i18n;
96
+ signature;
91
97
  browser;
92
98
  register(cli) {
93
99
  return {
94
100
  ...cli,
95
- providers: [...cli.providers ?? [], I18nService]
101
+ providers: [
102
+ ...cli.providers ?? [],
103
+ I18nService,
104
+ ATrustAdapter,
105
+ SignatureService
106
+ ]
96
107
  };
97
108
  }
98
109
  async shouldRunBefore() {
99
110
  this.cs = this.app.get(ConfigService);
100
111
  this.fs = this.app.get(FileSystemService);
101
112
  this.i18n = this.app.get(I18nService);
113
+ this.signature = this.app.get(SignatureService);
102
114
  await this.app.get(ParserService).register(JsonParser, YamlParser);
103
115
  this.nunjucks.addFilter("markdown_to_html", (markdown) => {
104
116
  return new showdown.Converter().makeHtml(markdown);
@@ -147,6 +159,7 @@ var MDPrinter = class extends Command {
147
159
  ctx.options = await this.cs.extend([paths["settings.json"], {
148
160
  dest: this.args?.output ?? ctx.metadata?.dest ?? `${basename(this.args.file, extname(this.args.file))}.${this.flags["output-filetype"]}`,
149
161
  ...title ? { document_title: title } : {},
162
+ ...ctx.metadata?.sign ? { sign: ctx.metadata.sign } : {},
150
163
  launch_options: { executablePath: this.flags.browser }
151
164
  }]);
152
165
  ctx.renderContinuous = this.flags.continuous || ctx.metadata?.continuous === true || ctx.options.continuous === true;
@@ -176,6 +189,7 @@ var MDPrinter = class extends Command {
176
189
  ctx.options.css = await postcss([tailwind({ base: ctx.templates })]).process(await this.fs.read(paths["tailwind.css"]), { from: paths["tailwind.css"] }).then((result) => result.css);
177
190
  }
178
191
  await this.applyTranslations(ctx);
192
+ await this.applySignature(ctx);
179
193
  await this.applyDocumentTitle(ctx);
180
194
  } }]);
181
195
  }
@@ -202,7 +216,8 @@ var MDPrinter = class extends Command {
202
216
  context(ctx) {
203
217
  const context = {
204
218
  ...ctx.metadata ?? {},
205
- content: ctx.content
219
+ content: ctx.content,
220
+ signature: ctx.signature ?? null
206
221
  };
207
222
  if (ctx.t) {
208
223
  context.t = ctx.t;
@@ -216,6 +231,21 @@ var MDPrinter = class extends Command {
216
231
  ctx.options.document_title = this.nunjucks.renderString(title, this.context(ctx));
217
232
  this.logger.info("Document title will be: %s", ctx.options.document_title);
218
233
  }
234
+ async applySignature(ctx) {
235
+ ctx.options.sign = {
236
+ ...ctx.options.sign,
237
+ ...this.flags.sign ? { enabled: true } : {},
238
+ ...this.flags["sign-provider"] ? { provider: this.flags["sign-provider"] } : {},
239
+ ...this.flags["sign-certificate"] ? { certificate: this.flags["sign-certificate"] } : {}
240
+ };
241
+ if (!ctx.options.sign.enabled) return;
242
+ if (ctx.options.as_html) throw new Error("Signing is only supported for PDF output.");
243
+ this.signature.adapter(ctx.options.sign.provider);
244
+ ctx.signingTime = /* @__PURE__ */ new Date();
245
+ if (ctx.options.sign.certificate) ctx.certificate = await this.signature.certificate(ctx.options.sign.certificate, ctx.signingTime);
246
+ ctx.signature = this.signature.describe(ctx.signingTime, ctx.certificate);
247
+ this.logger.info("Document will be signed: %o", ctx.signature);
248
+ }
219
249
  async applyTranslations(ctx) {
220
250
  ctx.language = this.flags.language ?? ctx.metadata?.language ?? ctx.options.i18n?.language ?? "en";
221
251
  ctx.t = await this.i18n.load(join(ctx.templates, I18N_DIRECTORY), {
@@ -294,6 +324,17 @@ var MDPrinter = class extends Command {
294
324
  resolve(null);
295
325
  }));
296
326
  if (output) {
327
+ if (ctx.signature) {
328
+ if (this.flags.watch) this.logger.warn("Skipping the signature in watch mode, run without --watch to sign the document.");
329
+ else {
330
+ output.content = await this.signature.sign(Buffer.from(output.content), {
331
+ ...ctx.options.sign,
332
+ date: ctx.signingTime,
333
+ certificate: ctx.certificate
334
+ });
335
+ this.logger.info("Document is signed.");
336
+ }
337
+ }
297
338
  if (this.flags.stdout) {
298
339
  process.stdout.write(output.content);
299
340
  return;
@@ -0,0 +1 @@
1
+ export { };
@@ -1,3 +1,7 @@
1
1
  import { I18nService } from "./i18n.service.mjs";
2
+ import { SignatureAdapter } from "./signature/signature.adapter.mjs";
3
+ import { ATrustAdapter } from "./signature/adapters/a-trust.adapter.mjs";
4
+ import { SignatureService } from "./signature/signature.service.mjs";
5
+ import "./signature/index.mjs";
2
6
 
3
- export { I18nService };
7
+ export { ATrustAdapter, I18nService, SignatureAdapter, SignatureService };
@@ -0,0 +1,116 @@
1
+ import __decorateMetadata from "../../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs";
2
+ import __decorate from "../../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs";
3
+ import { SignatureAdapter } from "../signature.adapter.mjs";
4
+ import { LoggerService } from "@cenk1cenk2/oclif-common";
5
+ import { Injectable } from "@nestjs/common";
6
+ import { spawn } from "child_process";
7
+ import { readFile } from "fs/promises";
8
+ import { createServer } from "http";
9
+
10
+ //#region src/services/signature/adapters/a-trust.adapter.ts
11
+ var _ATrustAdapter;
12
+ let ATrustAdapter = class ATrustAdapter extends SignatureAdapter {
13
+ static {
14
+ _ATrustAdapter = this;
15
+ }
16
+ logger;
17
+ static ENDPOINT = "https://service.a-trust.at/mobile/https-security-layer-request/default.aspx";
18
+ static NAMESPACE = "http://www.buergerkarte.at/namespaces/securitylayer/1.2#";
19
+ static TIMEOUT = 6e5;
20
+ static PAGE = "./a-trust.html";
21
+ name = "a-trust";
22
+ constructor(logger) {
23
+ super();
24
+ this.logger = logger;
25
+ this.logger.setup(this.constructor.name);
26
+ }
27
+ async sign(request) {
28
+ return this.collect(await this.session(this.request(request)));
29
+ }
30
+ request({ pdf, excluded: [from, to] }) {
31
+ const data = Buffer.from(pdf).fill(0, from, to + 1);
32
+ return [
33
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
34
+ `<sl:CreateCMSSignatureRequest xmlns:sl="${_ATrustAdapter.NAMESPACE}" Structure="detached" PAdESCompatibility="true">`,
35
+ "<sl:KeyboxIdentifier>SecureSignatureKeypair</sl:KeyboxIdentifier>",
36
+ "<sl:DataObject>",
37
+ "<sl:MetaInfo><sl:MimeType>application/pdf</sl:MimeType></sl:MetaInfo>",
38
+ `<sl:Content><sl:Base64Content>${data.toString("base64")}</sl:Base64Content></sl:Content>`,
39
+ `<sl:ExcludedByteRange><sl:From>${from}</sl:From><sl:To>${to}</sl:To></sl:ExcludedByteRange>`,
40
+ "</sl:DataObject>",
41
+ "</sl:CreateCMSSignatureRequest>"
42
+ ].join("");
43
+ }
44
+ async session(request) {
45
+ let url = _ATrustAdapter.ENDPOINT;
46
+ let response = await fetch(url, {
47
+ method: "POST",
48
+ body: new URLSearchParams([["XMLRequest", request]]),
49
+ redirect: "manual"
50
+ });
51
+ while (response.status === 301 || response.status === 302) {
52
+ url = new URL(response.headers.get("location"), url).href;
53
+ response = await fetch(url, { redirect: "manual" });
54
+ }
55
+ if (!response.ok || !url.includes("/identification.aspx")) throw new Error(`A-Trust did not start a signing session, ended at ${url} with ${response.status}: ${(await response.text()).slice(0, 500)}`);
56
+ this.logger.debug("A-Trust signing session started: %s", url);
57
+ return url;
58
+ }
59
+ async collect(login) {
60
+ let resolve;
61
+ let reject;
62
+ const result = new Promise((done, fail) => {
63
+ resolve = done;
64
+ reject = fail;
65
+ });
66
+ const page = await readFile(new URL(_ATrustAdapter.PAGE, import.meta.url));
67
+ const server = createServer(async (req, res) => {
68
+ if (req.method === "GET" && req.url === "/session") {
69
+ res.setHeader("content-type", "application/json");
70
+ res.writeHead(200);
71
+ return res.end(JSON.stringify({ login }));
72
+ }
73
+ if (req.method !== "POST" || req.url !== "/signature") {
74
+ res.setHeader("content-type", "text/html; charset=utf-8");
75
+ res.writeHead(200);
76
+ return res.end(page);
77
+ }
78
+ let body = "";
79
+ for await (const chunk of req) body += chunk;
80
+ const cms = (body.match(/<(?:\w+:)?CMSSignature>([^<]+)</)?.[1] ?? body).replace(/\s+/g, "");
81
+ if (!/^MII[A-Za-z0-9+/=]+$/.test(cms)) {
82
+ res.writeHead(400);
83
+ return res.end("This does not look like a signature, it should start with MII.");
84
+ }
85
+ res.writeHead(200);
86
+ res.end("Signature received, you can close this tab.");
87
+ resolve(Buffer.from(cms, "base64"));
88
+ });
89
+ await new Promise((done) => server.listen(0, "127.0.0.1", done));
90
+ const url = `http://127.0.0.1:${server.address().port}/`;
91
+ this.logger.info("Sign the document in the browser, then paste the signature back: %s", url);
92
+ this.open(url);
93
+ const timeout = setTimeout(() => reject(/* @__PURE__ */ new Error("Timed out waiting for the signature.")), _ATrustAdapter.TIMEOUT);
94
+ return result.finally(() => {
95
+ clearTimeout(timeout);
96
+ server.close();
97
+ });
98
+ }
99
+ open(url) {
100
+ const [command, ...args] = process.platform === "darwin" ? ["open", url] : process.platform === "win32" ? [
101
+ "cmd",
102
+ "/c",
103
+ "start",
104
+ "",
105
+ url
106
+ ] : ["xdg-open", url];
107
+ spawn(command, args, {
108
+ stdio: "ignore",
109
+ detached: true
110
+ }).on("error", (err) => this.logger.warn("Can not open the browser, open the link manually: %s", err.message)).unref();
111
+ }
112
+ };
113
+ ATrustAdapter = _ATrustAdapter = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof LoggerService === "undefined" ? Object : LoggerService])], ATrustAdapter);
114
+
115
+ //#endregion
116
+ export { ATrustAdapter };
@@ -0,0 +1,77 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>md-printer signature</title>
7
+ <style>
8
+ body {
9
+ font-family: system-ui, sans-serif;
10
+ margin: 0;
11
+ padding: 16px;
12
+ background: #f4f4f5;
13
+ color: #18181b;
14
+ }
15
+ main {
16
+ max-width: 560px;
17
+ margin: 0 auto;
18
+ display: grid;
19
+ gap: 12px;
20
+ }
21
+ iframe {
22
+ width: 100%;
23
+ height: 600px;
24
+ border: 0;
25
+ border-radius: 12px;
26
+ background: #fff;
27
+ }
28
+ textarea {
29
+ width: 100%;
30
+ height: 96px;
31
+ box-sizing: border-box;
32
+ border-radius: 8px;
33
+ padding: 8px;
34
+ font: 12px ui-monospace, monospace;
35
+ }
36
+ button {
37
+ width: 100%;
38
+ padding: 10px;
39
+ border: 0;
40
+ border-radius: 8px;
41
+ background: #18181b;
42
+ color: #fff;
43
+ font-weight: 600;
44
+ }
45
+ </style>
46
+ </head>
47
+ <body>
48
+ <main id="main">
49
+ <iframe id="login"></iframe>
50
+ <p>Log in and approve the signature. When the signature starting with <code>MII</code> shows up above, paste it here.</p>
51
+ <form id="form">
52
+ <textarea name="signature" placeholder="MII..." required></textarea>
53
+ <button>Sign PDF</button>
54
+ </form>
55
+ </main>
56
+ <script>
57
+ fetch('/session')
58
+ .then((response) => response.json())
59
+ .then(({ login }) => (document.getElementById('login').src = login))
60
+
61
+ document.getElementById('form').addEventListener('submit', async (event) => {
62
+ event.preventDefault()
63
+
64
+ const response = await fetch('/signature', { method: 'POST', body: new FormData(event.target).get('signature') })
65
+ const message = document.createElement('p')
66
+
67
+ message.textContent = await response.text()
68
+
69
+ if (response.ok) {
70
+ document.getElementById('main').replaceChildren(message)
71
+ } else {
72
+ event.target.append(message)
73
+ }
74
+ })
75
+ </script>
76
+ </body>
77
+ </html>
@@ -0,0 +1,5 @@
1
+ import { SignatureAdapter } from "./signature.adapter.mjs";
2
+ import { ATrustAdapter } from "./adapters/a-trust.adapter.mjs";
3
+ import { SignatureService } from "./signature.service.mjs";
4
+
5
+ export { ATrustAdapter, SignatureAdapter, SignatureService };
@@ -0,0 +1,5 @@
1
+ //#region src/services/signature/signature.adapter.ts
2
+ var SignatureAdapter = class {};
3
+
4
+ //#endregion
5
+ export { SignatureAdapter };
@@ -0,0 +1,97 @@
1
+ import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.mjs";
2
+ import __decorate from "../../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.mjs";
3
+ import { ATrustAdapter } from "./adapters/a-trust.adapter.mjs";
4
+ import { isAbsolute, join } from "path";
5
+ import { FileSystemService } from "@cenk1cenk2/oclif-common";
6
+ import { Injectable } from "@nestjs/common";
7
+ import { plainAddPlaceholder } from "@signpdf/placeholder-plain";
8
+ import { SUBFILTER_ETSI_CADES_DETACHED, findByteRange, removeTrailingNewLine } from "@signpdf/utils";
9
+ import { X509Certificate } from "crypto";
10
+ import { homedir } from "os";
11
+
12
+ //#region src/services/signature/signature.service.ts
13
+ var _SignatureService;
14
+ let SignatureService = class SignatureService {
15
+ static {
16
+ _SignatureService = this;
17
+ }
18
+ fs;
19
+ static LENGTH = 16384;
20
+ adapters;
21
+ constructor(fs, aTrust) {
22
+ this.fs = fs;
23
+ this.adapters = [aTrust];
24
+ }
25
+ get providers() {
26
+ return this.adapters.map((adapter) => adapter.name);
27
+ }
28
+ async certificate(file, date) {
29
+ const path = file.replace(/^~(?=\/|$)/, homedir());
30
+ const certificate = new X509Certificate(await this.fs.extra.readFile(isAbsolute(path) ? path : join(process.cwd(), path)));
31
+ if (date < certificate.validFromDate || date > certificate.validToDate) throw new Error(`Signing certificate is not valid at ${date.toISOString()}, it is valid from ${certificate.validFrom} to ${certificate.validTo}.`);
32
+ return certificate;
33
+ }
34
+ describe(date, certificate) {
35
+ return {
36
+ date: date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC"),
37
+ name: certificate && this.commonName(certificate.subject),
38
+ issuer: certificate && this.commonName(certificate.issuer),
39
+ serial: certificate?.serialNumber
40
+ };
41
+ }
42
+ async sign(pdf, options) {
43
+ const request = this.prepare(pdf, options);
44
+ const cms = await this.adapter(options.provider).sign(request);
45
+ if (options.certificate && !cms.includes(options.certificate.raw)) throw new Error("The returned signature was not created with the configured certificate, the signature block would show the wrong signer.");
46
+ return this.embed(request, cms);
47
+ }
48
+ adapter(provider) {
49
+ if (!provider) return this.adapters[0];
50
+ const adapter = this.adapters.find((adapter) => adapter.name === provider);
51
+ if (!adapter) throw new Error(`Unknown signature provider "${provider}", available: ${this.providers.join(", ")}`);
52
+ return adapter;
53
+ }
54
+ prepare(pdf, options) {
55
+ const prepared = removeTrailingNewLine(plainAddPlaceholder({
56
+ pdfBuffer: pdf,
57
+ reason: options.reason ?? "",
58
+ location: options.location ?? "",
59
+ contactInfo: options.contact ?? "",
60
+ name: "",
61
+ signingTime: options.date,
62
+ signatureLength: _SignatureService.LENGTH,
63
+ subFilter: SUBFILTER_ETSI_CADES_DETACHED,
64
+ appName: "md-printer"
65
+ }));
66
+ const { byteRangePlaceholder, byteRangePlaceholderPosition } = findByteRange(prepared);
67
+ if (!byteRangePlaceholder) throw new Error("Can not find the signature byte range placeholder in the PDF.");
68
+ const byteRangeEnd = byteRangePlaceholderPosition + byteRangePlaceholder.length;
69
+ const from = prepared.indexOf("<", prepared.indexOf("/Contents ", byteRangeEnd));
70
+ const to = prepared.indexOf(">", from);
71
+ return {
72
+ pdf: Buffer.concat([
73
+ prepared.subarray(0, byteRangePlaceholderPosition),
74
+ Buffer.from(`/ByteRange [0 ${from} ${to + 1} ${prepared.length - to - 1}]`.padEnd(byteRangePlaceholder.length, " ")),
75
+ prepared.subarray(byteRangeEnd)
76
+ ]),
77
+ excluded: [from, to]
78
+ };
79
+ }
80
+ embed({ pdf, excluded: [from, to] }, cms) {
81
+ const signature = cms.toString("hex");
82
+ const length = to - from - 1;
83
+ if (signature.length > length) throw new Error(`Signature is ${signature.length} bytes but the placeholder only fits ${length}.`);
84
+ return Buffer.concat([
85
+ pdf.subarray(0, from + 1),
86
+ Buffer.from(signature.padEnd(length, "0")),
87
+ pdf.subarray(to)
88
+ ]);
89
+ }
90
+ commonName(name) {
91
+ return name.split("\n").find((part) => part.startsWith("CN="))?.slice(3);
92
+ }
93
+ };
94
+ SignatureService = _SignatureService = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [typeof FileSystemService === "undefined" ? Object : FileSystemService, typeof ATrustAdapter === "undefined" ? Object : ATrustAdapter])], SignatureService);
95
+
96
+ //#endregion
97
+ export { SignatureService };
@@ -153,6 +153,26 @@
153
153
  "name": "continuous",
154
154
  "allowNo": false,
155
155
  "type": "boolean"
156
+ },
157
+ "sign": {
158
+ "description": "Sign the PDF with a qualified electronic signature through ID Austria (A-Trust).",
159
+ "name": "sign",
160
+ "allowNo": false,
161
+ "type": "boolean"
162
+ },
163
+ "sign-provider": {
164
+ "description": "Signature provider, defaults to a-trust. Overwrites the one defined in front-matter and template settings.",
165
+ "name": "sign-provider",
166
+ "hasDynamicHelp": false,
167
+ "multiple": false,
168
+ "type": "option"
169
+ },
170
+ "sign-certificate": {
171
+ "description": "Signer certificate (DER or PEM) used for the visible signature block and to verify the returned signature.",
172
+ "name": "sign-certificate",
173
+ "hasDynamicHelp": false,
174
+ "multiple": false,
175
+ "type": "option"
156
176
  }
157
177
  },
158
178
  "hasDynamicHelp": false,
@@ -165,5 +185,5 @@
165
185
  "enableJsonFlag": false
166
186
  }
167
187
  },
168
- "version": "2.20.11"
188
+ "version": "2.21.0"
169
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cenk1cenk2/md-printer",
3
- "version": "2.20.11",
3
+ "version": "2.21.0",
4
4
  "description": "A markdown printer.",
5
5
  "module": "dist/index.mjs",
6
6
  "type": "module",
@@ -50,10 +50,12 @@
50
50
  ],
51
51
  "dependencies": {
52
52
  "@cenk1cenk2/oclif-common": "^7.1.0",
53
- "@listr2/manager": "^4.2.6",
53
+ "@listr2/manager": "^4.3.1",
54
54
  "@nestjs/common": "^12.1.2",
55
55
  "@oclif/core": "^5.1.2",
56
56
  "@oclif/plugin-help": "^7.0.2",
57
+ "@signpdf/placeholder-plain": "^3.3.0",
58
+ "@signpdf/utils": "^3.3.0",
57
59
  "@tailwindcss/forms": "^0.5.11",
58
60
  "@tailwindcss/postcss": "^4.3.3",
59
61
  "@tailwindcss/typography": "^0.5.20",
@@ -62,10 +64,10 @@
62
64
  "get-port": "^7.2.0",
63
65
  "gray-matter": "^4.0.3",
64
66
  "i18next": "^26.4.2",
65
- "listr2": "^11.1.1",
67
+ "listr2": "^11.2.0",
66
68
  "md-to-pdf": "^5.2.5",
67
69
  "nunjucks": "^3.2.4",
68
- "postcss": "^8.5.28",
70
+ "postcss": "^8.5.29",
69
71
  "puppeteer": "^25.12.0",
70
72
  "rxjs": "^7.8.2",
71
73
  "showdown": "^2.1.0",
@@ -74,15 +76,15 @@
74
76
  "yaml": "^2.9.1"
75
77
  },
76
78
  "devDependencies": {
77
- "@cenk1cenk2/cz-cc": "^3.0.2",
78
- "@cenk1cenk2/eslint-config": "^3.3.7",
79
+ "@cenk1cenk2/cz-cc": "^3.0.6",
80
+ "@cenk1cenk2/eslint-config": "^3.3.9",
79
81
  "@tsconfig/node24": "^24.0.5",
80
82
  "@types/fs-extra": "^11.0.4",
81
83
  "@types/node": "^24.19.1",
82
84
  "@types/nunjucks": "^3.2.6",
83
85
  "@types/showdown": "^2.0.6",
84
86
  "eslint": "^10.12.0",
85
- "execa": "^10.0.1",
87
+ "execa": "^10.1.0",
86
88
  "globby": "^16.2.4",
87
89
  "oclif": "^6.0.2",
88
90
  "prettier": "^3.9.9",
@@ -8,6 +8,12 @@ sections:
8
8
  notes: NOTIZEN
9
9
  invoice: RECHNUNG
10
10
  signature: UNTERSCHRIFT
11
+ signed:
12
+ title: Digital signiert
13
+ by: Unterzeichner
14
+ date: Datum
15
+ issuer: Zertifikat
16
+ verify: Dieses Dokument ist qualifiziert elektronisch signiert. Prüfung unter
11
17
  fields:
12
18
  date: Datum
13
19
  id: Rechnungsnr.
@@ -8,6 +8,12 @@ sections:
8
8
  notes: NOTES
9
9
  invoice: INVOICE
10
10
  signature: SIGNATURE
11
+ signed:
12
+ title: Digitally signed
13
+ by: Signed by
14
+ date: Date
15
+ issuer: Certificate
16
+ verify: This document carries a qualified electronic signature. Verify it at
11
17
  fields:
12
18
  date: Date
13
19
  id: Invoice#
@@ -178,10 +178,47 @@
178
178
  {{ content | safe }}<br />
179
179
  </div>
180
180
  <div class="col-span-1"></div>
181
+ {% if signature %}
182
+ <div class="col-span-5 flex flex-col justify-center m-8 mr-16">
183
+ <div class="w-full overflow-hidden rounded-xl border-y-2 shadow-md shadow-gray-300 border-gray-900! text-xs">
184
+ <div class="flex items-center gap-3 px-5 py-3 bg-gray-900 text-white">
185
+ <svg class="w-5 h-5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
186
+ stroke-linecap="round" stroke-linejoin="round">
187
+ <path d="M12 3l7 3v6c0 4.5-3 7.5-7 9-4-1.5-7-4.5-7-9V6l7-3z" />
188
+ <path d="M9 12l2 2 4-4" />
189
+ </svg>
190
+ <span class="font-extrabold uppercase tracking-wider">{{ t('signed.title') }}</span>
191
+ </div>
192
+ <div class="flex flex-col px-5 py-2">
193
+ {% if signature.name %}
194
+ <div class="flex justify-between gap-4 py-2">
195
+ <span class="font-semibold text-gray-400">{{ t('signed.by') }}</span>
196
+ <span class="font-semibold text-right">{{ signature.name }}</span>
197
+ </div>
198
+ {% endif %}
199
+ <div class="flex justify-between gap-4 py-2 border-t border-t-gray-100/30">
200
+ <span class="font-semibold text-gray-400">{{ t('signed.date') }}</span>
201
+ <span class="text-right">{{ signature.date }}</span>
202
+ </div>
203
+ {% if signature.issuer %}
204
+ <div class="flex justify-between gap-4 py-2 border-t border-t-gray-100/30">
205
+ <span class="font-semibold text-gray-400">{{ t('signed.issuer') }}</span>
206
+ <span class="text-right text-[0.6rem]">{{ signature.issuer }} · {{ signature.serial }}</span>
207
+ </div>
208
+ {% endif %}
209
+ </div>
210
+ <p class="px-5 py-3 bg-gray-50 text-[0.6rem] text-gray-400 leading-snug">
211
+ {{ t('signed.verify') }}
212
+ <a href="https://www.signaturpruefung.gv.at" class="font-semibold text-gray-900 underline">signaturpruefung.gv.at</a>
213
+ </p>
214
+ </div>
215
+ </div>
216
+ {% else %}
181
217
  <div
182
218
  class="col-span-5 flex flex-col justify-end text-center m-8 mr-16 p-4 pb-0 border-2 border-gray-50! rounded-xl text-xs text-gray-100 h-36">
183
219
  <p>{{ t('signature') }}</p>
184
220
  </div>
221
+ {% endif %}
185
222
  </section>
186
223
  </div>
187
224
  </body>