Skip to content

Commit 4ad8466

Browse files
authored
Merge pull request #42 from leancodepl/feature/lf-34-signed-pdfs
feat: LF-34 add pdf signing feature
2 parents 103fa70 + bfc915e commit 4ad8466

14 files changed

Lines changed: 1899 additions & 535 deletions

package-lock.json

Lines changed: 1254 additions & 514 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,23 @@
1212
"@nestjs/core": "^11.1.9",
1313
"@nestjs/passport": "^11.0.5",
1414
"@nestjs/platform-express": "^11.1.9",
15+
"@ory/client": "^1.16.3",
16+
"@pdf-lib/fontkit": "^1.1.1",
17+
"@signpdf/placeholder-pdf-lib": "^3.3.0",
18+
"@signpdf/placeholder-plain": "^3.3.0",
19+
"@signpdf/signer-p12": "^3.3.0",
20+
"@signpdf/signpdf": "^3.3.0",
21+
"@signpdf/utils": "^3.3.0",
22+
"@types/node-forge": "^1.3.14",
1523
"core-js": "^3.47.0",
1624
"date-fns": "^4.1.0",
1725
"jsonwebtoken": "^9.0.3",
1826
"jwks-rsa": "^3.2.0",
19-
"@ory/client": "^1.16.3",
27+
"node-forge": "^1.3.3",
2028
"passport": "^0.7.0",
2129
"passport-custom": "^1.1.1",
2230
"passport-jwt": "^4.0.0",
31+
"pdf-lib": "^1.17.1",
2332
"puppeteer": "^24.32.1",
2433
"puppeteer-cluster": "^0.25.0",
2534
"react": ">=19.0.0",
@@ -36,17 +45,17 @@
3645
"@leancodepl/prettier-config": "9.7.2",
3746
"@nestjs/schematics": "11.0.9",
3847
"@nestjs/testing": "11.1.9",
39-
"@nx/eslint-plugin": "22.4.5",
40-
"@nx/jest": "22.4.5",
41-
"@nx/js": "22.4.5",
42-
"@nx/nest": "22.4.5",
43-
"@nx/react": "22.4.5",
44-
"@nx/web": "22.4.5",
45-
"@nx/webpack": "22.4.5",
46-
"@nx/workspace": "22.4.5",
47-
"@swc/cli": "0.7.9",
48-
"@swc/core": "1.15.3",
49-
"@swc/helpers": "0.5.17",
48+
"@nx/eslint-plugin": "22.5.0",
49+
"@nx/jest": "22.5.0",
50+
"@nx/js": "22.5.0",
51+
"@nx/nest": "22.5.0",
52+
"@nx/react": "22.5.0",
53+
"@nx/web": "22.5.0",
54+
"@nx/webpack": "22.5.0",
55+
"@nx/workspace": "22.5.0",
56+
"@swc/cli": "0.7.10",
57+
"@swc/core": "1.15.8",
58+
"@swc/helpers": "0.5.18",
5059
"@swc/jest": "0.2.39",
5160
"@swc/plugin-styled-components": "12.0.1",
5261
"@testing-library/react": "16.3.0",
@@ -67,7 +76,7 @@
6776
"jest": "30.2.0",
6877
"jest-environment-jsdom": "30.2.0",
6978
"jest-junit": "16.0.0",
70-
"nx": "22.4.5",
79+
"nx": "22.5.0",
7180
"prettier": "^3.7.4",
7281
"typescript": "5.9.3",
7382
"verdaccio": "6.2.4"

packages/pdf-renderer/README.md

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,164 @@ const StyledDiv = styled.div`
115115
`;
116116
```
117117

118+
### PDF Signing
119+
120+
The module supports digitally signing PDF documents using P12/PFX certificates. The `PdfSigner` service is exported from
121+
the module and can be used standalone, or you can use the convenient `asSignedBuffer` / `asSignedStream` methods returned
122+
by `generatePdf`.
123+
124+
#### Signing via `generatePdf`
125+
126+
The `generatePdf` method returns `asSignedBuffer` and `asSignedStream` in addition to the unsigned variants. Both accept
127+
a `SignPdfOptions` object:
128+
129+
```ts
130+
import { PdfRenderer, SignPdfOptions } from "@leancodepl/pdf-renderer";
131+
import { readFileSync } from "fs";
132+
133+
@Controller("pdf-renderer")
134+
export class AppController {
135+
constructor(private readonly pdfRenderer: PdfRenderer) {}
136+
137+
@Get("signedPdf")
138+
async signedPdf(@Res() res: Response) {
139+
const signOptions: SignPdfOptions = {
140+
p12Buffer: readFileSync("/path/to/certificate.p12"),
141+
passphrase: "cert-password",
142+
name: "John Doe",
143+
reason: "Document approval",
144+
location: "Warsaw, Poland",
145+
contactInfo: "john@example.com",
146+
};
147+
148+
const stream = await this.pdfRenderer
149+
.generatePdf({ element: <SampleComponent />, fonts: [OpenSansRegular] })
150+
.asSignedStream(signOptions);
151+
152+
res.header("Content-Type", "application/pdf");
153+
res.header("Content-Disposition", 'attachment; filename="signed.pdf"');
154+
155+
stream.pipe(res);
156+
}
157+
}
158+
```
159+
160+
#### `SignPdfOptions`
161+
162+
```ts
163+
type SignPdfOptions = {
164+
/** P12/PFX certificate bundle as a Buffer */
165+
p12Buffer: Buffer;
166+
/** Passphrase for the P12 certificate */
167+
passphrase?: string;
168+
/** Reason for signing */
169+
reason?: string;
170+
/** Contact information of the signer */
171+
contactInfo?: string;
172+
/** Location where the document was signed */
173+
location?: string;
174+
/** Name of the signer */
175+
name?: string;
176+
/**
177+
* Maximum length (in bytes) reserved for the PKCS#7 signature.
178+
* Increase this if signing fails due to a large certificate chain.
179+
* @default 8192
180+
*/
181+
signatureMaxLength?: number;
182+
/**
183+
* Custom [x1, y1, x2, y2] rectangle for the visible signature widget.
184+
* When not provided, the signature is placed at the top of the last page,
185+
* stretched across the full width.
186+
* Set to [0, 0, 0, 0] to make the signature invisible.
187+
*/
188+
widgetRect?: [number, number, number, number];
189+
/**
190+
* When true, uses the ETSI.CAdES.detached SubFilter for PAdES
191+
* (PDF Advanced Electronic Signatures) instead of the default
192+
* adbe.pkcs7.detached.
193+
* @default false
194+
*/
195+
pades?: boolean;
196+
/**
197+
* Label text shown above the signer name in the visible signature widget.
198+
* @default "Digitally signed by"
199+
*/
200+
signatureLabel?: string;
201+
/**
202+
* Pre-rendered PNG image buffer for a custom signature appearance.
203+
* When provided, this image is embedded in the signature widget instead of
204+
* the default operator-based text appearance.
205+
*/
206+
signatureImage?: Buffer;
207+
};
208+
```
209+
210+
#### Visible signature appearance
211+
212+
By default, the signature widget renders a text-based appearance showing the signer's name, date, and reason. You can
213+
customize this in three ways:
214+
215+
1. **Default appearance** - a built-in text layout with the signer name, label, date, and optional reason.
216+
2. **Custom React component** - pass a `signature` component to `generatePdf`. It receives `SignatureAppearanceProps`
217+
and is rendered to a PNG image that replaces the default text appearance.
218+
3. **Pre-rendered image** - pass a `signatureImage` buffer (PNG) in `SignPdfOptions` to use an arbitrary image.
219+
220+
##### Custom signature component example
221+
222+
```ts
223+
import { SignatureAppearanceProps } from "@leancodepl/pdf-renderer";
224+
225+
const CustomSignature: React.FC<SignatureAppearanceProps> = ({ name, date, reason }) => (
226+
<div style={{ padding: 10, border: "1px solid gray", fontFamily: "Open Sans" }}>
227+
<strong>{name}</strong>
228+
<div>{date}</div>
229+
{reason && <div>Reason: {reason}</div>}
230+
</div>
231+
);
232+
233+
// Pass it to generatePdf:
234+
const stream = await pdfRenderer
235+
.generatePdf({
236+
element: <SampleComponent />,
237+
fonts: [OpenSansRegular],
238+
signature: CustomSignature,
239+
signatureFonts: [OpenSansRegular], // optional, falls back to fonts
240+
})
241+
.asSignedStream(signOptions);
242+
```
243+
244+
#### Using `PdfSigner` directly
245+
246+
You can also inject and use the `PdfSigner` service directly for more control:
247+
248+
```ts
249+
import { PdfSigner, SignPdfOptions } from "@leancodepl/pdf-renderer";
250+
251+
@Injectable()
252+
export class MyService {
253+
constructor(private readonly pdfSigner: PdfSigner) {}
254+
255+
async signExistingPdf(pdfBuffer: Buffer, options: SignPdfOptions): Promise<Buffer> {
256+
return this.pdfSigner.sign(pdfBuffer, options);
257+
}
258+
}
259+
```
260+
261+
The `sign` method appends a new page with the signature widget and applies the digital signature.
262+
263+
#### Invisible signatures
264+
265+
To create a digitally signed PDF without a visible signature widget, set `widgetRect` to `[0, 0, 0, 0]`:
266+
267+
```ts
268+
const signedBuffer = await pdfRenderer
269+
.generatePdf({ element: <SampleComponent /> })
270+
.asSignedBuffer({
271+
...signOptions,
272+
widgetRect: [0, 0, 0, 0],
273+
});
274+
```
275+
118276
### Styles
119277

120278
For styling your PDF you should use styled components.

packages/pdf-renderer/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,22 @@
1414
"license": "Apache-2.0",
1515
"dependencies": {
1616
"@nestjs/common": ">=11.0.0",
17+
"@signpdf/placeholder-pdf-lib": ">=3.3.0",
18+
"@signpdf/signer-p12": ">=3.3.0",
19+
"@signpdf/signpdf": ">=3.3.0",
20+
"@signpdf/utils": ">=3.3.0",
21+
"@pdf-lib/fontkit": ">=1.1.1",
22+
"pdf-lib": ">=1.17.1",
23+
"node-forge": ">=1.3.0",
1724
"puppeteer": ">=24.0.0",
1825
"puppeteer-cluster": ">=0.25.0",
1926
"react": ">=19.0.0",
2027
"react-dom": ">=19.0.0",
2128
"styled-components": ">=6.0.0"
2229
},
2330
"devDependencies": {
24-
"@nestjs/testing": ">=11.0.0"
31+
"@nestjs/testing": ">=11.0.0",
32+
"@types/node-forge": ">=1.3.0"
2533
},
2634
"keywords": [
2735
"LeanCode",

packages/pdf-renderer/project.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,14 @@
2424
"tsConfig": "packages/pdf-renderer/tsconfig.lib.json",
2525
"packageJson": "packages/pdf-renderer/package.json",
2626
"main": "packages/pdf-renderer/src/index.ts",
27-
"assets": ["packages/pdf-renderer/*.md"]
27+
"assets": [
28+
"packages/pdf-renderer/*.md",
29+
{
30+
"input": "packages/pdf-renderer/src/assets",
31+
"glob": "**/*",
32+
"output": "./src/assets"
33+
}
34+
]
2835
}
2936
},
3037
"publish": {
502 KB
Binary file not shown.
503 KB
Binary file not shown.

packages/pdf-renderer/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from "./lib/pdfRenderer.module"
22
export * from "./lib/pdfRenderer.service"
33
export { FontConfiguration, FontsConfiguration } from "./lib/fontLibrary.service"
4+
export { PdfSigner, SignatureAppearanceProps, SignPdfOptions } from "./lib/pdfSigner.service"

packages/pdf-renderer/src/lib/pdfGenerator.service.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ export class PdfGenerator {
7474
return (await page.screenshot(data)) as Buffer
7575
}
7676

77+
static readonly SIGNATURE_DEVICE_SCALE_FACTOR = 2
78+
79+
private static generateElementScreenshotTask: TaskFunction<{ html: string }, Buffer> = async ({ page, data }) => {
80+
const viewport = page.viewport()
81+
await page.setViewport({
82+
width: viewport?.width ?? 800,
83+
height: viewport?.height ?? 600,
84+
deviceScaleFactor: PdfGenerator.SIGNATURE_DEVICE_SCALE_FACTOR,
85+
})
86+
87+
await page.setContent(data.html, {
88+
timeout: 60000,
89+
waitUntil: ["load", "domcontentloaded"],
90+
})
91+
92+
const clip = await page.evaluate(() => {
93+
const el = document.body.firstElementChild
94+
if (!el) return null
95+
const rect = el.getBoundingClientRect()
96+
return {
97+
x: rect.x,
98+
y: rect.y,
99+
width: Math.ceil(rect.width),
100+
height: Math.ceil(rect.height),
101+
}
102+
})
103+
104+
return (await page.screenshot({
105+
type: "png",
106+
omitBackground: true,
107+
...(clip && clip.width > 0 && clip.height > 0 ? { clip } : { fullPage: true }),
108+
})) as Buffer
109+
}
110+
77111
async generateBuffer(params: GeneratePdfPageParams) {
78112
return this.browserPool.run(PdfGenerator.generatePdfBufferTask, params)
79113
}
@@ -85,4 +119,8 @@ export class PdfGenerator {
85119
async generateScreenshot(params: GeneratePageScreenshotParams) {
86120
return this.browserPool.run(PdfGenerator.generateScreenshotTask, params)
87121
}
122+
123+
async generateElementScreenshot(html: string): Promise<Buffer> {
124+
return this.browserPool.run(PdfGenerator.generateElementScreenshotTask, { html })
125+
}
88126
}

packages/pdf-renderer/src/lib/pdfRenderer.module.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { PdfRenderer } from ".."
33
import { BrowserPool } from "./browserPool.service"
44
import { FontLibrary, FontsConfiguration, FontsConfigurationToken } from "./fontLibrary.service"
55
import { PdfGenerator } from "./pdfGenerator.service"
6+
import { PdfSigner } from "./pdfSigner.service"
67
import { ReactRenderer } from "./reactRenderer.service"
78

89
export type PdfRendererConfiguration = {
@@ -15,14 +16,15 @@ export class PdfRendererModule {
1516
static register({ isGlobal = true, fontsConfiguration }: PdfRendererConfiguration): DynamicModule {
1617
return {
1718
global: isGlobal,
18-
exports: [PdfRenderer],
19+
exports: [PdfRenderer, PdfSigner],
1920
module: PdfRendererModule,
2021
providers: [
2122
FontLibrary,
2223
BrowserPool,
2324
PdfGenerator,
2425
ReactRenderer,
2526
PdfRenderer,
27+
PdfSigner,
2628
{
2729
provide: FontsConfigurationToken,
2830
useValue: fontsConfiguration,

0 commit comments

Comments
 (0)