n8n-nodes-pdf-writer 1.0.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.
- package/LICENSE +21 -0
- package/README.md +164 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +5 -0
- package/dist/nodes/PdfWriter/PdfWriter.node.d.ts +9 -0
- package/dist/nodes/PdfWriter/PdfWriter.node.js +473 -0
- package/dist/nodes/PdfWriter/pdf-writer.svg +15 -0
- package/package.json +49 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ArchitWeb
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# n8n-nodes-pdf-writer
|
|
2
|
+
|
|
3
|
+
A custom n8n community node with two operations:
|
|
4
|
+
- **Write Text** – write text at specific **X/Y coordinates** on any PDF page
|
|
5
|
+
- **Merge PDFs** – merge specific pages from multiple PDF files into one
|
|
6
|
+
|
|
7
|
+
This node was designed to fill a necessary gap,
|
|
8
|
+
and we've decided to make it available for anyone who needs this functionality in n8n.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
- ✅ Write text at precise X/Y coordinates on any page
|
|
15
|
+
- ✅ Label each text entry for easy identification
|
|
16
|
+
- ✅ Multiple text entries per execution
|
|
17
|
+
- ✅ Target a specific page or **all pages** at once
|
|
18
|
+
- ✅ Y-origin toggle: measure from **top** or **bottom** of the page
|
|
19
|
+
- ✅ Choose from 7 built-in fonts (Helvetica, Times, Courier families)
|
|
20
|
+
- ✅ Set font size, color (hex), rotation, and opacity
|
|
21
|
+
- ✅ Merge multiple PDFs into one — all pages or **select specific pages per file**
|
|
22
|
+
- ✅ Page selection supports ranges and lists (e.g. `1,3,5-8`)
|
|
23
|
+
- ✅ Works with binary PDF data from any n8n node
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
In the n8n UI: **Settings → Community Nodes → Install** → enter `n8n-nodes-pdf-writer`
|
|
30
|
+
|
|
31
|
+
Then restart n8n.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Operations
|
|
36
|
+
|
|
37
|
+
### Write Text
|
|
38
|
+
|
|
39
|
+
Writes one or more text entries at specified X/Y positions on a PDF.
|
|
40
|
+
|
|
41
|
+
| Parameter | Description |
|
|
42
|
+
|---|---|
|
|
43
|
+
| **Input PDF Field** | Binary field name holding the source PDF (default: `data`) |
|
|
44
|
+
| **Output Field Name** | Binary field name to write the result to (default: `data`) |
|
|
45
|
+
|
|
46
|
+
#### Text Entry fields
|
|
47
|
+
|
|
48
|
+
| Field | Description |
|
|
49
|
+
|---|---|
|
|
50
|
+
| **Label** | A name to identify this entry (e.g. "Header", "Footer", "Stamp") |
|
|
51
|
+
| **Text** | The text string to draw |
|
|
52
|
+
| **Page** | Page number (1-based). Set to `0` to apply to all pages |
|
|
53
|
+
| **X Position** | Points from the left edge |
|
|
54
|
+
| **Y Position** | Points from the selected Y origin |
|
|
55
|
+
| **Y Origin** | `From Bottom` (PDF default) or `From Top` (more intuitive) |
|
|
56
|
+
| **Font Size** | Size in points |
|
|
57
|
+
| **Font** | Helvetica, Helvetica Bold, Times Roman, Courier, etc. |
|
|
58
|
+
| **Color (Hex)** | Text color, e.g. `#FF0000` for red |
|
|
59
|
+
| **Rotation** | Degrees counter-clockwise |
|
|
60
|
+
| **Opacity** | 0.0 (invisible) → 1.0 (solid) |
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
### Merge PDFs
|
|
65
|
+
|
|
66
|
+
Takes **all input items** (each carrying a PDF binary) and merges them into a single PDF. You can include all pages or select specific pages from each file.
|
|
67
|
+
|
|
68
|
+
| Parameter | Description |
|
|
69
|
+
|---|---|
|
|
70
|
+
| **Binary Field** | Binary field name that holds the PDF in each input item (default: `data`) |
|
|
71
|
+
| **Pages Per Item** | `All Pages` or `Select Pages Per Item` |
|
|
72
|
+
| **Pages** | Page selection per item — supports `all`, single pages (`1,3,5`), and ranges (`2-4`). Pages are 1-based. Supports n8n expressions. |
|
|
73
|
+
| **Output Field Name** | Binary field name for the merged PDF output (default: `data`) |
|
|
74
|
+
| **Output File Name** | File name of the merged PDF (default: `merged.pdf`) |
|
|
75
|
+
|
|
76
|
+
#### Page selection format
|
|
77
|
+
|
|
78
|
+
| Input | Meaning |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `all` | All pages from this PDF |
|
|
81
|
+
| `1,3,5` | Pages 1, 3 and 5 only |
|
|
82
|
+
| `2-6` | Pages 2 through 6 |
|
|
83
|
+
| `1,3,5-8` | Pages 1, 3 and 5 through 8 |
|
|
84
|
+
|
|
85
|
+
You can use n8n expressions in the Pages field to select different pages from each item dynamically:
|
|
86
|
+
```
|
|
87
|
+
{{ $itemIndex === 0 ? "1-3" : $itemIndex === 1 ? "2,5" : "all" }}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## Coordinate System (Write Text)
|
|
93
|
+
|
|
94
|
+
PDF coordinates by default start at the **bottom-left corner** (0, 0). A standard A4 page is 595 × 842 points.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
(0, 842) ─────────────── (595, 842) ← Top
|
|
98
|
+
│ │
|
|
99
|
+
│ Y increases ↑ │
|
|
100
|
+
│ │
|
|
101
|
+
(0, 0) ──────────────── (595, 0) ← Bottom (PDF origin)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When **Y Origin = "From Top"** is selected, the node automatically converts for you:
|
|
105
|
+
`y_pdf = page_height − y_from_top`
|
|
106
|
+
|
|
107
|
+
**Tip:** A4 = 595 × 842 pt | Letter = 612 × 792 pt | 1 inch = 72 points
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Example Workflows
|
|
112
|
+
|
|
113
|
+
### Write Text
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
[Read Binary File] → [PDF Text Writer (Write Text)] → [Write Binary File]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
1. Use **Read Binary File** (or HTTP Request, etc.) to get a PDF into a binary field
|
|
120
|
+
2. Add a **PDF Text Writer** node, set Operation to **Write Text**
|
|
121
|
+
3. Add text entries with label, content, position and style
|
|
122
|
+
4. The output binary field contains the modified PDF
|
|
123
|
+
|
|
124
|
+
#### Sample text entry configuration
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"label": "Confidential Stamp",
|
|
128
|
+
"text": "CONFIDENTIAL",
|
|
129
|
+
"page": 1,
|
|
130
|
+
"x": 200,
|
|
131
|
+
"y": 50,
|
|
132
|
+
"yOrigin": "top",
|
|
133
|
+
"fontSize": 24,
|
|
134
|
+
"font": "Helvetica-Bold",
|
|
135
|
+
"color": "#FF0000",
|
|
136
|
+
"rotation": 0,
|
|
137
|
+
"opacity": 0.8
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Merge PDFs
|
|
142
|
+
|
|
143
|
+
```
|
|
144
|
+
[Item 1: PDF A] ─┐
|
|
145
|
+
[Item 2: PDF B] ─┼─→ [PDF Text Writer (Merge PDFs)] → [Write Binary File]
|
|
146
|
+
[Item 3: PDF C] ─┘
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
1. Feed multiple items, each with a PDF binary field
|
|
150
|
+
2. Add a **PDF Text Writer** node, set Operation to **Merge PDFs**
|
|
151
|
+
3. Set **Pages Per Item** to `Select Pages Per Item` and configure a Pages expression
|
|
152
|
+
4. The single output item contains the merged PDF
|
|
153
|
+
|
|
154
|
+
---
|
|
155
|
+
|
|
156
|
+
## Dependencies
|
|
157
|
+
|
|
158
|
+
- [`pdf-lib`](https://pdf-lib.js.org/) – Pure JavaScript PDF manipulation
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PdfWriter } from './nodes/PdfWriter/PdfWriter.node';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PdfWriter = void 0;
|
|
4
|
+
var PdfWriter_node_1 = require("./nodes/PdfWriter/PdfWriter.node");
|
|
5
|
+
Object.defineProperty(exports, "PdfWriter", { enumerable: true, get: function () { return PdfWriter_node_1.PdfWriter; } });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { IExecuteFunctions, INodeExecutionData, INodeType, INodeTypeDescription } from 'n8n-workflow';
|
|
2
|
+
export declare class PdfWriter implements INodeType {
|
|
3
|
+
description: INodeTypeDescription;
|
|
4
|
+
/**
|
|
5
|
+
* Parse a page selection string like "1,3,5-8" into 0-based page indices.
|
|
6
|
+
*/
|
|
7
|
+
private static parsePageSelection;
|
|
8
|
+
execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.PdfWriter = void 0;
|
|
4
|
+
const n8n_workflow_1 = require("n8n-workflow");
|
|
5
|
+
const pdf_lib_1 = require("pdf-lib");
|
|
6
|
+
class PdfWriter {
|
|
7
|
+
constructor() {
|
|
8
|
+
this.description = {
|
|
9
|
+
displayName: 'PDF Writer',
|
|
10
|
+
name: 'PdfWriter',
|
|
11
|
+
icon: 'file:pdf-writer.svg',
|
|
12
|
+
group: ['transform'],
|
|
13
|
+
version: 1,
|
|
14
|
+
subtitle: '={{ $parameter["operation"] === "merge" ? "Merge PDFs" : "Write text on PDF" }}',
|
|
15
|
+
description: 'Write text on PDF pages or merge specific pages from multiple PDFs',
|
|
16
|
+
defaults: {
|
|
17
|
+
name: 'PDF Writer',
|
|
18
|
+
},
|
|
19
|
+
inputs: [{ type: 'main' }],
|
|
20
|
+
outputs: [{ type: 'main' }],
|
|
21
|
+
properties: [
|
|
22
|
+
// ─── Operation ────────────────────────────────────────────────────────
|
|
23
|
+
{
|
|
24
|
+
displayName: 'Operation',
|
|
25
|
+
name: 'operation',
|
|
26
|
+
type: 'options',
|
|
27
|
+
noDataExpression: true,
|
|
28
|
+
default: 'writeText',
|
|
29
|
+
options: [
|
|
30
|
+
{
|
|
31
|
+
name: 'Write Text',
|
|
32
|
+
value: 'writeText',
|
|
33
|
+
description: 'Write text at specific X/Y coordinates on a PDF',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
name: 'Merge PDFs',
|
|
37
|
+
value: 'merge',
|
|
38
|
+
description: 'Merge specific pages from multiple PDF files into one',
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
// ─── Write Text: Input ────────────────────────────────────────────────
|
|
43
|
+
{
|
|
44
|
+
displayName: 'Input PDF Field',
|
|
45
|
+
name: 'inputField',
|
|
46
|
+
type: 'string',
|
|
47
|
+
default: 'data',
|
|
48
|
+
description: 'Name of the binary field that contains the input PDF (e.g. "data")',
|
|
49
|
+
displayOptions: {
|
|
50
|
+
show: {
|
|
51
|
+
operation: ['writeText'],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
displayName: 'Output Field Name',
|
|
57
|
+
name: 'outputField',
|
|
58
|
+
type: 'string',
|
|
59
|
+
default: 'data',
|
|
60
|
+
description: 'Name of the binary field to write the modified PDF to',
|
|
61
|
+
displayOptions: {
|
|
62
|
+
show: {
|
|
63
|
+
operation: ['writeText'],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
// ─── Merge: Input ─────────────────────────────────────────────────────
|
|
68
|
+
{
|
|
69
|
+
displayName: 'Binary Field',
|
|
70
|
+
name: 'mergeBinaryField',
|
|
71
|
+
type: 'string',
|
|
72
|
+
default: 'data',
|
|
73
|
+
description: 'Name of the binary field containing the PDF in each input item',
|
|
74
|
+
displayOptions: {
|
|
75
|
+
show: {
|
|
76
|
+
operation: ['merge'],
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
displayName: 'Pages Per Item',
|
|
82
|
+
name: 'mergeMode',
|
|
83
|
+
type: 'options',
|
|
84
|
+
default: 'all',
|
|
85
|
+
options: [
|
|
86
|
+
{
|
|
87
|
+
name: 'All Pages',
|
|
88
|
+
value: 'all',
|
|
89
|
+
description: 'Include all pages from each input PDF',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: 'Select Pages Per Item',
|
|
93
|
+
value: 'select',
|
|
94
|
+
description: 'Specify which pages to take from each input item (configured per item via expression)',
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
displayOptions: {
|
|
98
|
+
show: {
|
|
99
|
+
operation: ['merge'],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
displayName: 'Pages',
|
|
105
|
+
name: 'mergePages',
|
|
106
|
+
type: 'string',
|
|
107
|
+
default: 'all',
|
|
108
|
+
description: 'Pages to include from this PDF. Use comma-separated values and ranges: e.g. "1,3,5-8", "2-4", or "all" for all pages. Pages are 1-based.',
|
|
109
|
+
placeholder: 'e.g. 1,3,5-8',
|
|
110
|
+
displayOptions: {
|
|
111
|
+
show: {
|
|
112
|
+
operation: ['merge'],
|
|
113
|
+
mergeMode: ['select'],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
displayName: 'Output Field Name',
|
|
119
|
+
name: 'mergeOutputField',
|
|
120
|
+
type: 'string',
|
|
121
|
+
default: 'data',
|
|
122
|
+
description: 'Name of the binary field to write the merged PDF to',
|
|
123
|
+
displayOptions: {
|
|
124
|
+
show: {
|
|
125
|
+
operation: ['merge'],
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
displayName: 'Output File Name',
|
|
131
|
+
name: 'mergeFileName',
|
|
132
|
+
type: 'string',
|
|
133
|
+
default: 'merged.pdf',
|
|
134
|
+
description: 'File name for the merged PDF output',
|
|
135
|
+
displayOptions: {
|
|
136
|
+
show: {
|
|
137
|
+
operation: ['merge'],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
// ─── Write Text: Text entries (fixed list) ────────────────────────────
|
|
142
|
+
{
|
|
143
|
+
displayName: 'Text Entries',
|
|
144
|
+
displayOptions: {
|
|
145
|
+
show: {
|
|
146
|
+
operation: ['writeText'],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
name: 'textEntries',
|
|
150
|
+
type: 'fixedCollection',
|
|
151
|
+
placeholder: 'Add Text Entry',
|
|
152
|
+
typeOptions: {
|
|
153
|
+
multipleValues: true,
|
|
154
|
+
},
|
|
155
|
+
default: {},
|
|
156
|
+
options: [
|
|
157
|
+
{
|
|
158
|
+
name: 'entry',
|
|
159
|
+
displayName: 'Text Entry',
|
|
160
|
+
values: [
|
|
161
|
+
{
|
|
162
|
+
displayName: 'Label',
|
|
163
|
+
name: 'label',
|
|
164
|
+
type: 'string',
|
|
165
|
+
default: '',
|
|
166
|
+
description: 'A name to identify this text entry (e.g. "Header", "Footer", "Stamp")',
|
|
167
|
+
placeholder: 'e.g. Header',
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
displayName: 'Text',
|
|
171
|
+
name: 'text',
|
|
172
|
+
type: 'string',
|
|
173
|
+
default: '',
|
|
174
|
+
description: 'The text to write on the PDF',
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
displayName: 'Page',
|
|
178
|
+
name: 'page',
|
|
179
|
+
type: 'number',
|
|
180
|
+
default: 1,
|
|
181
|
+
description: 'Page number to write on (1-based index). Use 0 for all pages.',
|
|
182
|
+
typeOptions: {
|
|
183
|
+
minValue: 0,
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
displayName: 'X Position',
|
|
188
|
+
name: 'x',
|
|
189
|
+
type: 'number',
|
|
190
|
+
default: 50,
|
|
191
|
+
description: 'Horizontal position in points from the left edge of the page',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
displayName: 'Y Position',
|
|
195
|
+
name: 'y',
|
|
196
|
+
type: 'number',
|
|
197
|
+
default: 50,
|
|
198
|
+
description: 'Vertical position in points from the bottom edge of the page (PDF coordinate system). Use "Y from Top" option to switch.',
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
displayName: 'Y Origin',
|
|
202
|
+
name: 'yOrigin',
|
|
203
|
+
type: 'options',
|
|
204
|
+
default: 'bottom',
|
|
205
|
+
options: [
|
|
206
|
+
{
|
|
207
|
+
name: 'From Bottom (PDF default)',
|
|
208
|
+
value: 'bottom',
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: 'From Top',
|
|
212
|
+
value: 'top',
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
description: 'Whether Y is measured from the bottom or the top of the page',
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
displayName: 'Font Size',
|
|
219
|
+
name: 'fontSize',
|
|
220
|
+
type: 'number',
|
|
221
|
+
default: 12,
|
|
222
|
+
typeOptions: { minValue: 1 },
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
displayName: 'Font',
|
|
226
|
+
name: 'font',
|
|
227
|
+
type: 'options',
|
|
228
|
+
default: 'Helvetica',
|
|
229
|
+
options: [
|
|
230
|
+
{ name: 'Helvetica', value: 'Helvetica' },
|
|
231
|
+
{ name: 'Helvetica Bold', value: 'Helvetica-Bold' },
|
|
232
|
+
{ name: 'Helvetica Oblique', value: 'Helvetica-Oblique' },
|
|
233
|
+
{ name: 'Times Roman', value: 'Times-Roman' },
|
|
234
|
+
{ name: 'Times Bold', value: 'Times-Bold' },
|
|
235
|
+
{ name: 'Courier', value: 'Courier' },
|
|
236
|
+
{ name: 'Courier Bold', value: 'Courier-Bold' },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
displayName: 'Color (Hex)',
|
|
241
|
+
name: 'color',
|
|
242
|
+
type: 'color',
|
|
243
|
+
default: '#000000',
|
|
244
|
+
description: 'Text color in hex format',
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
displayName: 'Rotation (degrees)',
|
|
248
|
+
name: 'rotation',
|
|
249
|
+
type: 'number',
|
|
250
|
+
default: 0,
|
|
251
|
+
description: 'Text rotation in degrees (counter-clockwise)',
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
displayName: 'Opacity',
|
|
255
|
+
name: 'opacity',
|
|
256
|
+
type: 'number',
|
|
257
|
+
default: 1,
|
|
258
|
+
typeOptions: { minValue: 0, maxValue: 1, numberStepSize: 0.1 },
|
|
259
|
+
description: 'Text opacity from 0 (transparent) to 1 (opaque)',
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
],
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Parse a page selection string like "1,3,5-8" into 0-based page indices.
|
|
270
|
+
*/
|
|
271
|
+
static parsePageSelection(selection, totalPages) {
|
|
272
|
+
const trimmed = selection.trim().toLowerCase();
|
|
273
|
+
if (trimmed === 'all' || trimmed === '') {
|
|
274
|
+
return Array.from({ length: totalPages }, (_, i) => i);
|
|
275
|
+
}
|
|
276
|
+
const indices = [];
|
|
277
|
+
const parts = trimmed.split(',');
|
|
278
|
+
for (const part of parts) {
|
|
279
|
+
const rangeParts = part.trim().split('-');
|
|
280
|
+
if (rangeParts.length === 2) {
|
|
281
|
+
const start = parseInt(rangeParts[0].trim(), 10);
|
|
282
|
+
const end = parseInt(rangeParts[1].trim(), 10);
|
|
283
|
+
if (isNaN(start) || isNaN(end) || start < 1 || end < 1) {
|
|
284
|
+
throw new Error(`Invalid page range: "${part.trim()}". Pages must be >= 1.`);
|
|
285
|
+
}
|
|
286
|
+
for (let p = Math.min(start, end); p <= Math.max(start, end); p++) {
|
|
287
|
+
if (p > totalPages) {
|
|
288
|
+
throw new Error(`Page ${p} does not exist. The PDF has ${totalPages} page(s).`);
|
|
289
|
+
}
|
|
290
|
+
indices.push(p - 1); // convert to 0-based
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else if (rangeParts.length === 1) {
|
|
294
|
+
const pageNum = parseInt(rangeParts[0].trim(), 10);
|
|
295
|
+
if (isNaN(pageNum) || pageNum < 1) {
|
|
296
|
+
throw new Error(`Invalid page number: "${rangeParts[0].trim()}". Pages must be >= 1.`);
|
|
297
|
+
}
|
|
298
|
+
if (pageNum > totalPages) {
|
|
299
|
+
throw new Error(`Page ${pageNum} does not exist. The PDF has ${totalPages} page(s).`);
|
|
300
|
+
}
|
|
301
|
+
indices.push(pageNum - 1);
|
|
302
|
+
}
|
|
303
|
+
else {
|
|
304
|
+
throw new Error(`Invalid page selection: "${part.trim()}". Use format like "1,3,5-8".`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return indices;
|
|
308
|
+
}
|
|
309
|
+
async execute() {
|
|
310
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
|
|
311
|
+
const items = this.getInputData();
|
|
312
|
+
const returnData = [];
|
|
313
|
+
const operation = this.getNodeParameter('operation', 0);
|
|
314
|
+
// ─── Merge PDFs ─────────────────────────────────────────────────────
|
|
315
|
+
if (operation === 'merge') {
|
|
316
|
+
try {
|
|
317
|
+
const mergeBinaryField = this.getNodeParameter('mergeBinaryField', 0);
|
|
318
|
+
const mergeOutputField = this.getNodeParameter('mergeOutputField', 0);
|
|
319
|
+
const mergeFileName = this.getNodeParameter('mergeFileName', 0);
|
|
320
|
+
const mergeMode = this.getNodeParameter('mergeMode', 0);
|
|
321
|
+
const mergedPdf = await pdf_lib_1.PDFDocument.create();
|
|
322
|
+
for (let i = 0; i < items.length; i++) {
|
|
323
|
+
const binaryData = items[i].binary;
|
|
324
|
+
if (!binaryData || !binaryData[mergeBinaryField]) {
|
|
325
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Item ${i + 1} does not have binary field "${mergeBinaryField}"`, { itemIndex: i });
|
|
326
|
+
}
|
|
327
|
+
const pdfBuffer = await this.helpers.getBinaryDataBuffer(i, mergeBinaryField);
|
|
328
|
+
if (binaryData[mergeBinaryField].mimeType && binaryData[mergeBinaryField].mimeType !== 'application/pdf') {
|
|
329
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Item ${i + 1} binary field "${mergeBinaryField}" is not a PDF (got "${binaryData[mergeBinaryField].mimeType}").`, { itemIndex: i });
|
|
330
|
+
}
|
|
331
|
+
const sourcePdf = await pdf_lib_1.PDFDocument.load(pdfBuffer);
|
|
332
|
+
const totalPages = sourcePdf.getPageCount();
|
|
333
|
+
let pageIndices;
|
|
334
|
+
if (mergeMode === 'select') {
|
|
335
|
+
const pagesStr = this.getNodeParameter('mergePages', i);
|
|
336
|
+
pageIndices = PdfWriter.parsePageSelection(pagesStr, totalPages);
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
pageIndices = sourcePdf.getPageIndices();
|
|
340
|
+
}
|
|
341
|
+
const copiedPages = await mergedPdf.copyPages(sourcePdf, pageIndices);
|
|
342
|
+
for (const page of copiedPages) {
|
|
343
|
+
mergedPdf.addPage(page);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const mergedBytes = await mergedPdf.save();
|
|
347
|
+
const mergedBuffer = Buffer.from(mergedBytes);
|
|
348
|
+
const newBinaryData = await this.helpers.prepareBinaryData(mergedBuffer, mergeFileName || 'merged.pdf', 'application/pdf');
|
|
349
|
+
returnData.push({
|
|
350
|
+
json: {
|
|
351
|
+
pdfMerged: true,
|
|
352
|
+
inputFiles: items.length,
|
|
353
|
+
pageCount: mergedPdf.getPageCount(),
|
|
354
|
+
},
|
|
355
|
+
binary: {
|
|
356
|
+
[mergeOutputField]: newBinaryData,
|
|
357
|
+
},
|
|
358
|
+
pairedItem: { item: 0 },
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
if (this.continueOnFail()) {
|
|
363
|
+
returnData.push({
|
|
364
|
+
json: { error: error.message },
|
|
365
|
+
pairedItem: { item: 0 },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
throw error;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return [returnData];
|
|
373
|
+
}
|
|
374
|
+
// ─── Write Text ─────────────────────────────────────────────────────
|
|
375
|
+
for (let i = 0; i < items.length; i++) {
|
|
376
|
+
try {
|
|
377
|
+
const inputField = this.getNodeParameter('inputField', i);
|
|
378
|
+
const outputField = this.getNodeParameter('outputField', i);
|
|
379
|
+
const textEntriesParam = this.getNodeParameter('textEntries', i);
|
|
380
|
+
const entries = (_a = textEntriesParam.entry) !== null && _a !== void 0 ? _a : [];
|
|
381
|
+
// Get the binary PDF data
|
|
382
|
+
const binaryData = this.helpers.assertBinaryData(i, inputField);
|
|
383
|
+
if (binaryData.mimeType && binaryData.mimeType !== 'application/pdf') {
|
|
384
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Binary field "${inputField}" is not a PDF (got "${binaryData.mimeType}").`, { itemIndex: i });
|
|
385
|
+
}
|
|
386
|
+
const pdfBuffer = await this.helpers.getBinaryDataBuffer(i, inputField);
|
|
387
|
+
// Load the PDF
|
|
388
|
+
const pdfDoc = await pdf_lib_1.PDFDocument.load(pdfBuffer);
|
|
389
|
+
const pages = pdfDoc.getPages();
|
|
390
|
+
// Font map
|
|
391
|
+
const fontMap = {
|
|
392
|
+
'Helvetica': pdf_lib_1.StandardFonts.Helvetica,
|
|
393
|
+
'Helvetica-Bold': pdf_lib_1.StandardFonts.HelveticaBold,
|
|
394
|
+
'Helvetica-Oblique': pdf_lib_1.StandardFonts.HelveticaOblique,
|
|
395
|
+
'Times-Roman': pdf_lib_1.StandardFonts.TimesRoman,
|
|
396
|
+
'Times-Bold': pdf_lib_1.StandardFonts.TimesRomanBold,
|
|
397
|
+
'Courier': pdf_lib_1.StandardFonts.Courier,
|
|
398
|
+
'Courier-Bold': pdf_lib_1.StandardFonts.CourierBold,
|
|
399
|
+
};
|
|
400
|
+
// Cache for embedded fonts to avoid re-embedding the same font
|
|
401
|
+
const fontCache = new Map();
|
|
402
|
+
// Process each text entry
|
|
403
|
+
for (const entry of entries) {
|
|
404
|
+
const targetPageNum = (_b = entry.page) !== null && _b !== void 0 ? _b : 1;
|
|
405
|
+
const pagesToWrite = targetPageNum === 0
|
|
406
|
+
? pages
|
|
407
|
+
: [pages[targetPageNum - 1]].filter(Boolean);
|
|
408
|
+
if (pagesToWrite.length === 0) {
|
|
409
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Page ${targetPageNum} does not exist. The PDF has ${pages.length} page(s).`, { itemIndex: i });
|
|
410
|
+
}
|
|
411
|
+
const fontKey = (_c = entry.font) !== null && _c !== void 0 ? _c : 'Helvetica';
|
|
412
|
+
if (!fontCache.has(fontKey)) {
|
|
413
|
+
fontCache.set(fontKey, await pdfDoc.embedFont((_d = fontMap[fontKey]) !== null && _d !== void 0 ? _d : pdf_lib_1.StandardFonts.Helvetica));
|
|
414
|
+
}
|
|
415
|
+
const embeddedFont = fontCache.get(fontKey);
|
|
416
|
+
// Parse and validate hex color
|
|
417
|
+
const rawHex = ((_e = entry.color) !== null && _e !== void 0 ? _e : '#000000').replace('#', '');
|
|
418
|
+
if (!/^[0-9a-fA-F]{6}$/.test(rawHex)) {
|
|
419
|
+
throw new n8n_workflow_1.NodeOperationError(this.getNode(), `Invalid color value: "${entry.color}". Expected a 6-digit hex color (e.g. "#FF0000").`, { itemIndex: i });
|
|
420
|
+
}
|
|
421
|
+
const r = parseInt(rawHex.substring(0, 2), 16) / 255;
|
|
422
|
+
const g = parseInt(rawHex.substring(2, 4), 16) / 255;
|
|
423
|
+
const b = parseInt(rawHex.substring(4, 6), 16) / 255;
|
|
424
|
+
for (const page of pagesToWrite) {
|
|
425
|
+
const { height } = page.getSize();
|
|
426
|
+
let yCoord = (_f = entry.y) !== null && _f !== void 0 ? _f : 50;
|
|
427
|
+
if (entry.yOrigin === 'top') {
|
|
428
|
+
yCoord = height - yCoord;
|
|
429
|
+
}
|
|
430
|
+
page.drawText((_g = entry.text) !== null && _g !== void 0 ? _g : '', {
|
|
431
|
+
x: (_h = entry.x) !== null && _h !== void 0 ? _h : 50,
|
|
432
|
+
y: yCoord,
|
|
433
|
+
size: (_j = entry.fontSize) !== null && _j !== void 0 ? _j : 12,
|
|
434
|
+
font: embeddedFont,
|
|
435
|
+
color: (0, pdf_lib_1.rgb)(r, g, b),
|
|
436
|
+
rotate: (0, pdf_lib_1.degrees)((_k = entry.rotation) !== null && _k !== void 0 ? _k : 0),
|
|
437
|
+
opacity: (_l = entry.opacity) !== null && _l !== void 0 ? _l : 1,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Save the modified PDF
|
|
442
|
+
const modifiedPdfBytes = await pdfDoc.save();
|
|
443
|
+
const modifiedBuffer = Buffer.from(modifiedPdfBytes);
|
|
444
|
+
const newBinaryData = await this.helpers.prepareBinaryData(modifiedBuffer, (_m = binaryData.fileName) !== null && _m !== void 0 ? _m : 'output.pdf', 'application/pdf');
|
|
445
|
+
returnData.push({
|
|
446
|
+
json: {
|
|
447
|
+
...items[i].json,
|
|
448
|
+
pdfModified: true,
|
|
449
|
+
pageCount: pages.length,
|
|
450
|
+
textEntriesWritten: entries.length,
|
|
451
|
+
},
|
|
452
|
+
binary: {
|
|
453
|
+
...((_o = items[i].binary) !== null && _o !== void 0 ? _o : {}),
|
|
454
|
+
[outputField]: newBinaryData,
|
|
455
|
+
},
|
|
456
|
+
pairedItem: { item: i },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
if (this.continueOnFail()) {
|
|
461
|
+
returnData.push({
|
|
462
|
+
json: { error: error.message },
|
|
463
|
+
pairedItem: { item: i },
|
|
464
|
+
});
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return [returnData];
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
exports.PdfWriter = PdfWriter;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 60 60">
|
|
2
|
+
<!-- PDF page background -->
|
|
3
|
+
<rect x="8" y="4" width="36" height="46" rx="3" ry="3" fill="#e74c3c"/>
|
|
4
|
+
<!-- Folded corner -->
|
|
5
|
+
<polygon points="32,4 44,16 32,16" fill="#c0392b"/>
|
|
6
|
+
<!-- White content area -->
|
|
7
|
+
<rect x="12" y="18" width="28" height="26" rx="1" fill="white" opacity="0.9"/>
|
|
8
|
+
<!-- Text lines -->
|
|
9
|
+
<rect x="15" y="22" width="22" height="2.5" rx="1" fill="#e74c3c"/>
|
|
10
|
+
<rect x="15" y="27" width="18" height="2.5" rx="1" fill="#e74c3c"/>
|
|
11
|
+
<rect x="15" y="32" width="20" height="2.5" rx="1" fill="#e74c3c"/>
|
|
12
|
+
<!-- Cursor / pin indicator -->
|
|
13
|
+
<circle cx="46" cy="46" r="10" fill="#3498db"/>
|
|
14
|
+
<text x="46" y="50" text-anchor="middle" font-size="12" font-weight="bold" fill="white" font-family="Arial">T</text>
|
|
15
|
+
</svg>
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "n8n-nodes-pdf-writer",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "n8n node to write text at X/Y coordinates on PDF pages and merge multiple PDFs with page selection",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"n8n-community-node-package",
|
|
7
|
+
"pdf",
|
|
8
|
+
"text",
|
|
9
|
+
"coordinates",
|
|
10
|
+
"merge",
|
|
11
|
+
"pdf-merge"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://github.com/architweb/n8n-node-pdf-write",
|
|
15
|
+
"author": {
|
|
16
|
+
"name": "ArchitWeb"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/architweb/n8n-node-pdf-write.git"
|
|
21
|
+
},
|
|
22
|
+
"main": "index.js",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc && npm run copy-icons",
|
|
25
|
+
"copy-icons": "node -e \"const fs=require('fs'),p=require('path');const src='nodes/PdfWriter',dst='dist/nodes/PdfWriter';fs.mkdirSync(dst,{recursive:true});fs.readdirSync(src).filter(f=>f.endsWith('.svg')).forEach(f=>fs.copyFileSync(p.join(src,f),p.join(dst,f)))\"",
|
|
26
|
+
"dev": "tsc --watch"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"n8n": {
|
|
32
|
+
"n8nNodesApiVersion": 1,
|
|
33
|
+
"credentials": [],
|
|
34
|
+
"nodes": [
|
|
35
|
+
"dist/nodes/PdfWriter/PdfWriter.node.js"
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"pdf-lib": "^1.17.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^18.0.0",
|
|
43
|
+
"n8n-workflow": "*",
|
|
44
|
+
"typescript": "^5.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"n8n-workflow": "*"
|
|
48
|
+
}
|
|
49
|
+
}
|