@polotno/pdf-export 0.1.18
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/README.md +57 -0
- package/index.js +130 -0
- package/lib/figure.js +49 -0
- package/lib/ghostscript.js +162 -0
- package/lib/group.js +9 -0
- package/lib/image.js +92 -0
- package/lib/line.js +82 -0
- package/lib/svg-render.js +29 -0
- package/lib/svg.js +245 -0
- package/lib/text.js +184 -0
- package/lib/utils.js +78 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Polotno to Vector PDF
|
|
2
|
+
|
|
3
|
+
Convert polotno JSON into vector PDF file from NodeJS with optional PDF/X-1a print-ready export.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install @polotno/pdf-export
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Basic Usage
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import { jsonToPDF } from '@polotno/pdf-export';
|
|
14
|
+
|
|
15
|
+
async function run() {
|
|
16
|
+
const json = JSON.parse(fs.readFileSync('./polotno.json'));
|
|
17
|
+
|
|
18
|
+
// Standard PDF export
|
|
19
|
+
await jsonToPDF(json, './output.pdf');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
run();
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## PDF/X-1a Print-Ready Export
|
|
26
|
+
|
|
27
|
+
For professional printing, use the PDF/X-1a option which ensures:
|
|
28
|
+
|
|
29
|
+
- CMYK color space conversion
|
|
30
|
+
- Transparency flattening
|
|
31
|
+
- Font embedding/outlining
|
|
32
|
+
- Print industry compliance
|
|
33
|
+
|
|
34
|
+
```js
|
|
35
|
+
// PDF/X-1a export
|
|
36
|
+
await jsonToPDF(json, './print-ready.pdf', {
|
|
37
|
+
pdfx1a: true,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// PDF/X-1a with custom metadata
|
|
41
|
+
await jsonToPDF(json, './book-cover.pdf', {
|
|
42
|
+
pdfx1a: true,
|
|
43
|
+
validate: true, // Optional validation
|
|
44
|
+
metadata: {
|
|
45
|
+
title: 'My Book Cover',
|
|
46
|
+
author: 'Author Name',
|
|
47
|
+
application: 'KDP CoverCreator v1.0',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- **GhostScript** must be installed for PDF/X-1a conversion
|
|
55
|
+
- macOS: `brew install ghostscript`
|
|
56
|
+
- Ubuntu: `apt-get install ghostscript`
|
|
57
|
+
- Windows: Download from [ghostscript.com](https://www.ghostscript.com/)
|
package/index.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
const PDFDocument = require('pdfkit');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { srcToBuffer, parseColor } = require('./lib/utils');
|
|
5
|
+
const { renderImage } = require('./lib/image');
|
|
6
|
+
const { loadFontIfNeeded, renderText } = require('./lib/text');
|
|
7
|
+
const { renderFigure } = require('./lib/figure');
|
|
8
|
+
const { renderGroup } = require('./lib/group');
|
|
9
|
+
const { lineToPDF } = require('./lib/line');
|
|
10
|
+
const { renderSVG } = require('./lib/svg-render');
|
|
11
|
+
const { convertToPDFX1a, validatePDFX1a } = require('./lib/ghostscript');
|
|
12
|
+
const SVGtoPDF = require('svg-to-pdfkit');
|
|
13
|
+
|
|
14
|
+
PDFDocument.prototype.addSVG = function (svg, x, y, options) {
|
|
15
|
+
return SVGtoPDF(this, svg, x, y, options), this;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
async function renderElement({ doc, element, fonts, attrs }) {
|
|
19
|
+
if (!element.visible || !element.showInExport) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
doc.save();
|
|
24
|
+
if (element.type !== 'group') {
|
|
25
|
+
doc.translate(element.x, element.y);
|
|
26
|
+
doc.rotate(element.rotation);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (element.opacity !== undefined) {
|
|
30
|
+
doc.opacity(element.opacity);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (element.type === 'group') {
|
|
34
|
+
await renderGroup(doc, element, renderElement, fonts, attrs);
|
|
35
|
+
} else if (element.type === 'text') {
|
|
36
|
+
await loadFontIfNeeded(doc, element, fonts);
|
|
37
|
+
renderText(doc, element, attrs);
|
|
38
|
+
} else if (element.type === 'line') {
|
|
39
|
+
lineToPDF(doc, element);
|
|
40
|
+
} else if (element.type === 'image') {
|
|
41
|
+
await renderImage(doc, element);
|
|
42
|
+
} else if (element.type === 'svg') {
|
|
43
|
+
await renderSVG(doc, element);
|
|
44
|
+
} else if (element.type === 'figure') {
|
|
45
|
+
renderFigure(doc, element);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
doc.restore();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports.jsonToPDF = async function jsonToPDF(
|
|
52
|
+
json,
|
|
53
|
+
pdfFileName,
|
|
54
|
+
attrs = {}
|
|
55
|
+
) {
|
|
56
|
+
const fonts = {};
|
|
57
|
+
|
|
58
|
+
var doc = new PDFDocument({
|
|
59
|
+
size: [json.width, json.height],
|
|
60
|
+
autoFirstPage: false,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
for (const font of json.fonts) {
|
|
64
|
+
doc.registerFont(font.fontFamily, await srcToBuffer(font.url));
|
|
65
|
+
fonts[font.fontFamily] = true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const page of json.pages) {
|
|
69
|
+
doc.addPage();
|
|
70
|
+
if (page.background) {
|
|
71
|
+
const isURL =
|
|
72
|
+
page.background.indexOf('http') >= 0 ||
|
|
73
|
+
page.background.indexOf('.png') >= 0 ||
|
|
74
|
+
page.background.indexOf('.jpg') >= 0;
|
|
75
|
+
|
|
76
|
+
if (isURL) {
|
|
77
|
+
doc.image(await srcToBuffer(page.background), 0, 0);
|
|
78
|
+
} else {
|
|
79
|
+
doc.rect(0, 0, json.width, json.height);
|
|
80
|
+
doc.fill(parseColor(page.background).hex);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const element of page.children) {
|
|
84
|
+
await renderElement({ doc, element, fonts, attrs });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
doc.end();
|
|
89
|
+
|
|
90
|
+
await new Promise((r) =>
|
|
91
|
+
doc.pipe(fs.createWriteStream(pdfFileName)).on('finish', r)
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Optional PDF/X-1a conversion
|
|
95
|
+
if (attrs.pdfx1a) {
|
|
96
|
+
console.log('Converting to PDF/X-1a...');
|
|
97
|
+
const tempFileName = pdfFileName.replace('.pdf', '-temp.pdf');
|
|
98
|
+
|
|
99
|
+
// Rename current file to temp
|
|
100
|
+
fs.renameSync(pdfFileName, tempFileName);
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
// Convert temp file to PDF/X-1a
|
|
104
|
+
await convertToPDFX1a(tempFileName, pdfFileName, {
|
|
105
|
+
metadata: attrs.metadata || {},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Clean up temp file
|
|
109
|
+
fs.unlinkSync(tempFileName);
|
|
110
|
+
|
|
111
|
+
// Optional validation
|
|
112
|
+
if (attrs.validate) {
|
|
113
|
+
const isValid = await validatePDFX1a(pdfFileName);
|
|
114
|
+
if (!isValid) {
|
|
115
|
+
console.warn(
|
|
116
|
+
'Warning: Generated PDF may not be fully PDF/X-1a compliant'
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
console.log('PDF/X-1a conversion completed');
|
|
122
|
+
} catch (error) {
|
|
123
|
+
// Restore original file if conversion fails
|
|
124
|
+
if (fs.existsSync(tempFileName)) {
|
|
125
|
+
fs.renameSync(tempFileName, pdfFileName);
|
|
126
|
+
}
|
|
127
|
+
throw new Error(`PDF/X-1a conversion failed: ${error.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
package/lib/figure.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const { parseColor } = require('./utils');
|
|
2
|
+
const { figureToSvg } = require('polotno/utils/figure-to-svg');
|
|
3
|
+
|
|
4
|
+
function renderFigure(doc, element) {
|
|
5
|
+
const svgStr = figureToSvg({
|
|
6
|
+
...element,
|
|
7
|
+
fill: (() => {
|
|
8
|
+
if (!element.fill) return 'transparent';
|
|
9
|
+
const color = parseColor(element.fill);
|
|
10
|
+
if (!color || !color.rgba) return element.fill;
|
|
11
|
+
const oldOpacity = color.rgba[3] || 1;
|
|
12
|
+
const rgba = color.rgba
|
|
13
|
+
.slice(0, 3)
|
|
14
|
+
.concat(oldOpacity * element.opacity)
|
|
15
|
+
.join(',');
|
|
16
|
+
return `rgba(${rgba})`;
|
|
17
|
+
})(),
|
|
18
|
+
stroke: (() => {
|
|
19
|
+
if (!element.stroke || element.strokeWidth === 0) return 'none';
|
|
20
|
+
const color = parseColor(element.stroke);
|
|
21
|
+
if (!color || !color.rgba) return element.stroke;
|
|
22
|
+
const oldOpacity = color.rgba[3] || 1;
|
|
23
|
+
const rgba = color.rgba
|
|
24
|
+
.slice(0, 3)
|
|
25
|
+
.concat(oldOpacity * element.opacity)
|
|
26
|
+
.join(',');
|
|
27
|
+
return `rgba(${rgba})`;
|
|
28
|
+
})(),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
doc.addSVG(svgStr, 0, 0, {
|
|
32
|
+
preserveAspectRatio: 'xMinYMin meet',
|
|
33
|
+
width: element.width,
|
|
34
|
+
height: element.height,
|
|
35
|
+
opacity: element.opacity,
|
|
36
|
+
colorCallback: (colors) => {
|
|
37
|
+
console.log('colors', colors);
|
|
38
|
+
if (!colors) {
|
|
39
|
+
return colors;
|
|
40
|
+
}
|
|
41
|
+
const [color, opacity] = colors;
|
|
42
|
+
return [color, opacity * element.opacity];
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = {
|
|
48
|
+
renderFigure,
|
|
49
|
+
};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
const { spawn } = require('child_process');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Convert PDF to PDF/X-1a using GhostScript
|
|
7
|
+
* @param {string} inputPath - Path to input PDF
|
|
8
|
+
* @param {string} outputPath - Path to output PDF/X-1a file
|
|
9
|
+
* @param {Object} options - Conversion options
|
|
10
|
+
* @returns {Promise<void>}
|
|
11
|
+
*/
|
|
12
|
+
async function convertToPDFX1a(inputPath, outputPath, options = {}) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
// PDF/X-1a conversion parameters with stroke preservation
|
|
15
|
+
const args = [
|
|
16
|
+
'-dNOPAUSE',
|
|
17
|
+
'-dBATCH',
|
|
18
|
+
'-dSAFER',
|
|
19
|
+
'-sDEVICE=pdfwrite',
|
|
20
|
+
'-dCompatibilityLevel=1.3',
|
|
21
|
+
'-dPDFX=true',
|
|
22
|
+
'-dColorConversionStrategy=/CMYK',
|
|
23
|
+
'-dProcessColorModel=/DeviceCMYK',
|
|
24
|
+
'-dUseCIEColor=true',
|
|
25
|
+
'-sColorConversionStrategy=CMYK',
|
|
26
|
+
'-sProcessColorModel=DeviceCMYK',
|
|
27
|
+
'-dOverrideICC=true',
|
|
28
|
+
// Preserve text rendering modes and strokes
|
|
29
|
+
'-dPreserveAnnots=true',
|
|
30
|
+
'-dPreserveCopyPage=true',
|
|
31
|
+
'-dPreserveDeviceN=true',
|
|
32
|
+
'-dDoThumbnails=false',
|
|
33
|
+
'-dDetectDuplicateImages=false',
|
|
34
|
+
'-dCompressFonts=false',
|
|
35
|
+
'-dSubsetFonts=false',
|
|
36
|
+
'-dEmbedAllFonts=true',
|
|
37
|
+
// Better handling of text rendering modes
|
|
38
|
+
'-dPDFSETTINGS=/prepress',
|
|
39
|
+
`-sOutputFile=${outputPath}`,
|
|
40
|
+
inputPath,
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// Add custom metadata if provided
|
|
44
|
+
if (options.metadata) {
|
|
45
|
+
// Create temporary PostScript file with metadata
|
|
46
|
+
const psMetadata = createMetadataPS(options.metadata);
|
|
47
|
+
const tempPSFile = path.join(path.dirname(outputPath), 'metadata.ps');
|
|
48
|
+
fs.writeFileSync(tempPSFile, psMetadata);
|
|
49
|
+
args.splice(-1, 0, tempPSFile); // Insert before input file
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log('GhostScript command:', 'gs', args.join(' '));
|
|
53
|
+
|
|
54
|
+
const gs = spawn('gs', args);
|
|
55
|
+
let stderr = '';
|
|
56
|
+
|
|
57
|
+
gs.stderr.on('data', (data) => {
|
|
58
|
+
stderr += data.toString();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
gs.on('close', (code) => {
|
|
62
|
+
// Clean up temp metadata file
|
|
63
|
+
if (options.metadata) {
|
|
64
|
+
const tempPSFile = path.join(path.dirname(outputPath), 'metadata.ps');
|
|
65
|
+
if (fs.existsSync(tempPSFile)) {
|
|
66
|
+
fs.unlinkSync(tempPSFile);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (code === 0) {
|
|
71
|
+
console.log('PDF/X-1a conversion successful');
|
|
72
|
+
resolve();
|
|
73
|
+
} else {
|
|
74
|
+
reject(new Error(`GhostScript failed with code ${code}: ${stderr}`));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
gs.on('error', (error) => {
|
|
79
|
+
reject(new Error(`Failed to start GhostScript: ${error.message}`));
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create PostScript metadata for PDF/X-1a
|
|
86
|
+
* @param {Object} metadata - Metadata object
|
|
87
|
+
* @returns {string} PostScript code
|
|
88
|
+
*/
|
|
89
|
+
function createMetadataPS(metadata) {
|
|
90
|
+
const {
|
|
91
|
+
title = 'Cover Creator Export',
|
|
92
|
+
author = 'KDP Cover Creator',
|
|
93
|
+
application = 'KDP CoverCreator',
|
|
94
|
+
producer = 'Polotno PDF Export',
|
|
95
|
+
} = metadata;
|
|
96
|
+
|
|
97
|
+
return `
|
|
98
|
+
%!PS
|
|
99
|
+
% PDF/X-1a Metadata
|
|
100
|
+
[/Title (${title})
|
|
101
|
+
/Author (${author})
|
|
102
|
+
/Creator (${application})
|
|
103
|
+
/Producer (${producer})
|
|
104
|
+
/CreationDate (D:${new Date().toISOString().replace(/[-:]/g, '').slice(0, 14)})
|
|
105
|
+
/ModDate (D:${new Date().toISOString().replace(/[-:]/g, '').slice(0, 14)})
|
|
106
|
+
/DOCINFO pdfmark
|
|
107
|
+
|
|
108
|
+
% Output Intent for PDF/X-1a
|
|
109
|
+
[/OutputIntent
|
|
110
|
+
/GTS_PDFX
|
|
111
|
+
/Info (PDF/X-1a Output Intent)
|
|
112
|
+
/OutputConditionIdentifier (CGATS TR 001)
|
|
113
|
+
/RegistryName (http://www.color.org)
|
|
114
|
+
/PDFX pdfmark
|
|
115
|
+
`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate if PDF is PDF/X-1a compliant
|
|
120
|
+
* @param {string} pdfPath - Path to PDF file
|
|
121
|
+
* @returns {Promise<boolean>}
|
|
122
|
+
*/
|
|
123
|
+
async function validatePDFX1a(pdfPath) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const args = [
|
|
126
|
+
'-dNOPAUSE',
|
|
127
|
+
'-dBATCH',
|
|
128
|
+
'-sDEVICE=nullpage',
|
|
129
|
+
'-dPDFX=true',
|
|
130
|
+
pdfPath,
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
const gs = spawn('gs', args);
|
|
134
|
+
let stderr = '';
|
|
135
|
+
|
|
136
|
+
gs.stderr.on('data', (data) => {
|
|
137
|
+
stderr += data.toString();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
gs.on('close', (code) => {
|
|
141
|
+
if (
|
|
142
|
+
code === 0 &&
|
|
143
|
+
!stderr.includes('Error') &&
|
|
144
|
+
!stderr.includes('Warning')
|
|
145
|
+
) {
|
|
146
|
+
resolve(true);
|
|
147
|
+
} else {
|
|
148
|
+
console.log('PDF/X-1a validation issues:', stderr);
|
|
149
|
+
resolve(false);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
gs.on('error', (error) => {
|
|
154
|
+
reject(new Error(`Failed to validate PDF: ${error.message}`));
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
convertToPDFX1a,
|
|
161
|
+
validatePDFX1a,
|
|
162
|
+
};
|
package/lib/group.js
ADDED
package/lib/image.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
const { loadImage, PIXEL_RATIO, srcToBuffer } = require('./utils');
|
|
2
|
+
const Canvas = require('canvas');
|
|
3
|
+
|
|
4
|
+
async function cropImage(src, element) {
|
|
5
|
+
const image = await loadImage(src);
|
|
6
|
+
const canvas = new Canvas.Canvas();
|
|
7
|
+
|
|
8
|
+
canvas.width = element.width * PIXEL_RATIO;
|
|
9
|
+
canvas.height = element.height * PIXEL_RATIO;
|
|
10
|
+
|
|
11
|
+
const ctx = canvas.getContext('2d');
|
|
12
|
+
|
|
13
|
+
let { cropX, cropY } = element;
|
|
14
|
+
|
|
15
|
+
const availableWidth = image.width * element.cropWidth;
|
|
16
|
+
const availableHeight = image.height * element.cropHeight;
|
|
17
|
+
|
|
18
|
+
const aspectRatio = element.width / element.height;
|
|
19
|
+
|
|
20
|
+
let cropAbsoluteWidth;
|
|
21
|
+
let cropAbsoluteHeight;
|
|
22
|
+
|
|
23
|
+
const imageRatio = availableWidth / availableHeight;
|
|
24
|
+
const allowScale = element.type === 'svg';
|
|
25
|
+
|
|
26
|
+
if (allowScale) {
|
|
27
|
+
cropAbsoluteWidth = availableWidth;
|
|
28
|
+
cropAbsoluteHeight = availableHeight;
|
|
29
|
+
} else if (aspectRatio >= imageRatio) {
|
|
30
|
+
cropAbsoluteWidth = availableWidth;
|
|
31
|
+
cropAbsoluteHeight = availableWidth / aspectRatio;
|
|
32
|
+
} else {
|
|
33
|
+
cropAbsoluteWidth = availableHeight * aspectRatio;
|
|
34
|
+
cropAbsoluteHeight = availableHeight;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
ctx.drawImage(
|
|
38
|
+
image,
|
|
39
|
+
cropX * image.width,
|
|
40
|
+
cropY * image.height,
|
|
41
|
+
cropAbsoluteWidth,
|
|
42
|
+
cropAbsoluteHeight,
|
|
43
|
+
0,
|
|
44
|
+
0,
|
|
45
|
+
canvas.width,
|
|
46
|
+
canvas.height
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return canvas.toDataURL('image/png');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function clipImage(src, element) {
|
|
53
|
+
const image = await loadImage(src);
|
|
54
|
+
const clipImage = await loadImage(element.clipSrc);
|
|
55
|
+
|
|
56
|
+
const canvas = new Canvas.Canvas(element.width, element.height);
|
|
57
|
+
const ctx = canvas.getContext('2d');
|
|
58
|
+
|
|
59
|
+
ctx.drawImage(image, 0, 0, element.width, element.height);
|
|
60
|
+
|
|
61
|
+
const clipCanvas = new Canvas.Canvas(element.width, element.height);
|
|
62
|
+
const clipCtx = clipCanvas.getContext('2d');
|
|
63
|
+
|
|
64
|
+
clipCtx.drawImage(clipImage, 0, 0, element.width, element.height);
|
|
65
|
+
|
|
66
|
+
ctx.globalCompositeOperation = 'destination-in';
|
|
67
|
+
ctx.drawImage(clipCanvas, 0, 0);
|
|
68
|
+
|
|
69
|
+
ctx.globalCompositeOperation = 'source-over';
|
|
70
|
+
|
|
71
|
+
return canvas.toDataURL('image/png');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function renderImage(doc, element) {
|
|
75
|
+
let src = await cropImage(element.src, element);
|
|
76
|
+
if (element.clipSrc) {
|
|
77
|
+
src = await clipImage(src, element);
|
|
78
|
+
}
|
|
79
|
+
if (src) {
|
|
80
|
+
doc.image(await srcToBuffer(src), 0, 0, {
|
|
81
|
+
width: element.width,
|
|
82
|
+
height: element.height,
|
|
83
|
+
opacity: element.opacity,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
cropImage,
|
|
90
|
+
clipImage,
|
|
91
|
+
renderImage,
|
|
92
|
+
};
|
package/lib/line.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { Util } = require('konva');
|
|
2
|
+
const h = (type, props, ...children) => {
|
|
3
|
+
return { type, props, children: children || [] };
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
const getLineHead = ({ element, type, doc }) => {
|
|
7
|
+
doc.lineWidth(element.height);
|
|
8
|
+
doc.lineCap('round');
|
|
9
|
+
doc.lineJoin('round');
|
|
10
|
+
doc.opacity(element.opacity);
|
|
11
|
+
|
|
12
|
+
const rgba = Util.colorToRGBA(element.color);
|
|
13
|
+
const fillColor = rgbToHex(rgba);
|
|
14
|
+
|
|
15
|
+
if (type === 'arrow') {
|
|
16
|
+
doc.moveTo(element.height * 3, -element.height * 2)
|
|
17
|
+
.lineTo(0, 0)
|
|
18
|
+
.lineTo(element.height * 3, element.height * 2);
|
|
19
|
+
doc.stroke()
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
} else if (type === 'triangle') {
|
|
23
|
+
doc.polygon([element.height * 3, -element.height * 2],
|
|
24
|
+
[0, 0],
|
|
25
|
+
[element.height * 3, element.height * 2]);
|
|
26
|
+
|
|
27
|
+
} else if (type === 'bar') {
|
|
28
|
+
doc.polygon([0, -element.height * 2],
|
|
29
|
+
[0, 0],
|
|
30
|
+
[0, element.height * 2]);
|
|
31
|
+
|
|
32
|
+
} else if (type === 'circle') {
|
|
33
|
+
doc.circle(element.height * 2, 0, element.height * 2);
|
|
34
|
+
|
|
35
|
+
} else if (type === 'square') {
|
|
36
|
+
doc.rect(0, -element.height * 2,
|
|
37
|
+
element.height * 4,
|
|
38
|
+
element.height * 4);
|
|
39
|
+
|
|
40
|
+
} else {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
doc.fillAndStroke(fillColor, fillColor);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
module.exports.lineToPDF = (doc, element) => {
|
|
48
|
+
doc.translate(0, element.height / 2);
|
|
49
|
+
doc.lineWidth(element.height);
|
|
50
|
+
doc.moveTo(0, 0);
|
|
51
|
+
doc.lineTo(element.width, 0);
|
|
52
|
+
|
|
53
|
+
if (element.dash && element.dash.length > 0) {
|
|
54
|
+
doc.dash(element.dash.map(dash => dash * element.height));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const rgba = Util.colorToRGBA(element.color);
|
|
58
|
+
doc.strokeColor(rgbToHex(rgba));
|
|
59
|
+
doc.stroke();
|
|
60
|
+
|
|
61
|
+
if (element.dash && element.dash.length > 0) {
|
|
62
|
+
doc.undash();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getLineHead({element, doc: doc, type: element.startHead});
|
|
66
|
+
getLineHead({element, doc: doc.translate(element.width, 0).rotate(180), type: element.endHead});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function rgbToHex({ r, g, b}) {
|
|
70
|
+
// Ensure each value is within the valid range
|
|
71
|
+
r = Math.max(0, Math.min(255, r));
|
|
72
|
+
g = Math.max(0, Math.min(255, g));
|
|
73
|
+
b = Math.max(0, Math.min(255, b));
|
|
74
|
+
|
|
75
|
+
// Convert each value to a 2-digit hexadecimal string
|
|
76
|
+
const hexR = r.toString(16).padStart(2, '0');
|
|
77
|
+
const hexG = g.toString(16).padStart(2, '0');
|
|
78
|
+
const hexB = b.toString(16).padStart(2, '0');
|
|
79
|
+
|
|
80
|
+
// Return the concatenated hex string
|
|
81
|
+
return `#${hexR}${hexG}${hexB}`;
|
|
82
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const svg = require('./svg');
|
|
2
|
+
|
|
3
|
+
async function renderSVG(doc, element) {
|
|
4
|
+
const svgStr = await svg.urlToString(element.src);
|
|
5
|
+
const src = svg.replaceColors(
|
|
6
|
+
svgStr,
|
|
7
|
+
new Map(Object.entries(element.colorsReplace))
|
|
8
|
+
);
|
|
9
|
+
const str = await svg.urlToString(src);
|
|
10
|
+
|
|
11
|
+
doc.addSVG(str, 0, 0, {
|
|
12
|
+
// Use 'none' to allow stretching to exact dimensions, 'xMinYMin meet' to preserve aspect ratio
|
|
13
|
+
preserveAspectRatio: 'none',
|
|
14
|
+
width: element.width,
|
|
15
|
+
height: element.height,
|
|
16
|
+
opacity: element.opacity,
|
|
17
|
+
colorCallback: (colors) => {
|
|
18
|
+
if (!colors) {
|
|
19
|
+
return colors;
|
|
20
|
+
}
|
|
21
|
+
const [color, opacity] = colors;
|
|
22
|
+
return [color, opacity * element.opacity];
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = {
|
|
28
|
+
renderSVG,
|
|
29
|
+
};
|
package/lib/svg.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const Konva = require('konva');
|
|
2
|
+
var nodeFetch = require('node-fetch').default;
|
|
3
|
+
const xmldom = require('xmldom');
|
|
4
|
+
|
|
5
|
+
const DOMParser = xmldom.DOMParser;
|
|
6
|
+
const XMLSerializer = xmldom.XMLSerializer;
|
|
7
|
+
|
|
8
|
+
const originalFetch = nodeFetch;
|
|
9
|
+
|
|
10
|
+
const fetch = (url) => {
|
|
11
|
+
if (url.indexOf('base64') >= 0) {
|
|
12
|
+
return {
|
|
13
|
+
text: async () => {
|
|
14
|
+
let buff = Buffer.from(url.split('base64,')[1], 'base64');
|
|
15
|
+
return buff.toString('ascii');
|
|
16
|
+
},
|
|
17
|
+
buffer: async () => {
|
|
18
|
+
return Buffer.from(url.split('base64,')[1], 'base64');
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return originalFetch(url);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
function isInsideDef(element) {
|
|
26
|
+
while (element.parentNode) {
|
|
27
|
+
if (element.nodeName === 'defs') {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
element = element.parentNode;
|
|
31
|
+
}
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getElementColors(e) {
|
|
36
|
+
const style = parseStyleAttribute(e.getAttribute('style'));
|
|
37
|
+
|
|
38
|
+
const colors = {
|
|
39
|
+
fill: '',
|
|
40
|
+
stroke: '',
|
|
41
|
+
};
|
|
42
|
+
if (e.getAttribute('fill') && e.getAttribute('fill') !== 'none') {
|
|
43
|
+
colors.fill = e.getAttribute('fill');
|
|
44
|
+
}
|
|
45
|
+
if (!colors.fill && style && style.fill && style.fill !== 'none') {
|
|
46
|
+
colors.fill = style.fill;
|
|
47
|
+
}
|
|
48
|
+
if (e.getAttribute('stroke')) {
|
|
49
|
+
colors.stroke = e.getAttribute('stroke');
|
|
50
|
+
}
|
|
51
|
+
if (!colors.stroke && style && style.stroke) {
|
|
52
|
+
colors.stroke = style.stroke;
|
|
53
|
+
}
|
|
54
|
+
if (!colors.stroke && !colors.fill) {
|
|
55
|
+
colors.fill = 'black';
|
|
56
|
+
}
|
|
57
|
+
return colors;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SVG_SHAPES = ['path', 'rect', 'circle'];
|
|
61
|
+
|
|
62
|
+
function getAllElementsWithColor(doc) {
|
|
63
|
+
var matchingElements = [];
|
|
64
|
+
var allElements = doc.getElementsByTagName('*');
|
|
65
|
+
for (var i = 0, n = allElements.length; i < n; i++) {
|
|
66
|
+
const element = allElements[i];
|
|
67
|
+
if (isInsideDef(element)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (element.getAttribute('fill') !== null) {
|
|
71
|
+
matchingElements.push(element);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const style = element.getAttribute('style');
|
|
75
|
+
|
|
76
|
+
if (style != null && style.indexOf('fill') >= 0) {
|
|
77
|
+
matchingElements.push(element);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (element.getAttribute('stroke') !== null) {
|
|
81
|
+
matchingElements.push(element);
|
|
82
|
+
} else if (element.style && element.style['fill']) {
|
|
83
|
+
matchingElements.push(element);
|
|
84
|
+
} else if (SVG_SHAPES.indexOf(element.nodeName) >= 0) {
|
|
85
|
+
matchingElements.push(element);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return matchingElements;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports.urlToBase64 = async function urlToBase64(url) {
|
|
92
|
+
const req = await fetch(url);
|
|
93
|
+
if (req.buffer) {
|
|
94
|
+
const buffer = await req.buffer();
|
|
95
|
+
return `data:image/svg+xml;base64,${buffer.toString('base64')}`;
|
|
96
|
+
} else {
|
|
97
|
+
const svgString = await req.text();
|
|
98
|
+
return svgToURL(svgString);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
module.exports.urlToString = async function urlToString(url) {
|
|
103
|
+
if (url.startsWith('data:')) {
|
|
104
|
+
// console.log(Buffer.from(url.split('base64,')[1], 'base64').toString());
|
|
105
|
+
return Buffer.from(url.split('base64,')[1], 'base64').toString();
|
|
106
|
+
}
|
|
107
|
+
const req = await fetch(url, { mode: 'cors' });
|
|
108
|
+
const svgString = await req.text();
|
|
109
|
+
return svgString;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
module.exports.getColors = function getColors(svgString) {
|
|
113
|
+
var parser = new DOMParser();
|
|
114
|
+
var doc = parser.parseFromString(svgString, 'text/xml');
|
|
115
|
+
|
|
116
|
+
const elements = getAllElementsWithColor(doc);
|
|
117
|
+
|
|
118
|
+
const colors = [];
|
|
119
|
+
|
|
120
|
+
elements.forEach((e) => {
|
|
121
|
+
const { fill, stroke } = getElementColors(e);
|
|
122
|
+
const results = [fill, stroke];
|
|
123
|
+
results.forEach((color) => {
|
|
124
|
+
if (!color) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const rgba = Konva.Util.colorToRGBA(color);
|
|
128
|
+
if (!rgba) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (colors.indexOf(color) === -1) {
|
|
132
|
+
colors.push(color);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
return colors;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
module.exports.svgToURL = function svgToURL(s) {
|
|
140
|
+
const uri = Buffer.from(unescape(encodeURIComponent(s))).toString('base64');
|
|
141
|
+
return 'data:image/svg+xml;base64,' + uri;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
module.exports.getSvgSize = async function getSvgSize(url) {
|
|
145
|
+
const svgString = await urlToString(url);
|
|
146
|
+
var parser = new DOMParser();
|
|
147
|
+
var doc = parser.parseFromString(svgString, 'image/svg+xml');
|
|
148
|
+
const viewBox = doc.documentElement.getAttribute('viewBox');
|
|
149
|
+
const [x, y, width, height] = viewBox?.split(' ') || [];
|
|
150
|
+
return { width: parseFloat(width), height: parseFloat(height) };
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
module.exports.fixSize = function fixSize(svgString) {
|
|
154
|
+
var parser = new DOMParser();
|
|
155
|
+
var doc = parser.parseFromString(svgString, 'image/svg+xml');
|
|
156
|
+
const viewBox = doc.documentElement.getAttribute('viewBox');
|
|
157
|
+
const [x, y, width, height] = viewBox?.split(' ') || [];
|
|
158
|
+
if (!doc.documentElement.getAttribute('width')) {
|
|
159
|
+
doc.documentElement.setAttribute('width', width + 'px');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!doc.documentElement.getAttribute('height')) {
|
|
163
|
+
doc.documentElement.setAttribute('height', height + 'px');
|
|
164
|
+
}
|
|
165
|
+
var xmlSerializer = new XMLSerializer();
|
|
166
|
+
const str = xmlSerializer.serializeToString(doc);
|
|
167
|
+
return str;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const sameColors = (c1, c2) => {
|
|
171
|
+
if (!c2 || !c2) {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return c1.r === c2.r && c1.g === c2.g && c1.b === c2.b && c1.a === c2.a;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
module.exports.replaceColors = function replaceColors(svgString, replaceMap) {
|
|
178
|
+
var parser = new DOMParser();
|
|
179
|
+
var doc = parser.parseFromString(svgString, 'text/xml');
|
|
180
|
+
|
|
181
|
+
const elements = getAllElementsWithColor(doc);
|
|
182
|
+
|
|
183
|
+
const oldColors = Array.from(replaceMap.keys());
|
|
184
|
+
|
|
185
|
+
elements.forEach((el) => {
|
|
186
|
+
const { fill, stroke } = getElementColors(el);
|
|
187
|
+
const colors = [
|
|
188
|
+
{ prop: 'fill', color: fill },
|
|
189
|
+
{ prop: 'stroke', color: stroke },
|
|
190
|
+
];
|
|
191
|
+
colors.forEach(({ prop, color }) => {
|
|
192
|
+
// find matched oldColor
|
|
193
|
+
const marchedOldValue = oldColors.find((oldColor) => {
|
|
194
|
+
return sameColors(
|
|
195
|
+
Konva.Util.colorToRGBA(oldColor),
|
|
196
|
+
Konva.Util.colorToRGBA(color)
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
if (!marchedOldValue) {
|
|
200
|
+
return;
|
|
201
|
+
} else {
|
|
202
|
+
el.setAttribute(prop, replaceMap.get(marchedOldValue));
|
|
203
|
+
|
|
204
|
+
const style = parseStyleAttribute(el.getAttribute('style'));
|
|
205
|
+
|
|
206
|
+
if (style && style[prop]) {
|
|
207
|
+
style[prop] = replaceMap.get(marchedOldValue);
|
|
208
|
+
el.setAttribute('style', buildStyleAttribute(style));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// el[prop] = replaceMap.get(marchedOldValue);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
var xmlSerializer = new XMLSerializer();
|
|
216
|
+
const str = xmlSerializer.serializeToString(doc);
|
|
217
|
+
|
|
218
|
+
// console.log(str);
|
|
219
|
+
// Array.from(replaceMap.keys()).forEach((oldColor) => {
|
|
220
|
+
// svgString = svgString.replace(
|
|
221
|
+
// new RegExp(oldColor, 'g'),
|
|
222
|
+
// replaceMap.get(oldColor) as string
|
|
223
|
+
// );
|
|
224
|
+
// });
|
|
225
|
+
return module.exports.svgToURL(str);
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
function parseStyleAttribute(style) {
|
|
229
|
+
if (!style) {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
const styles = style.split(';');
|
|
233
|
+
const result = {};
|
|
234
|
+
styles.forEach((style) => {
|
|
235
|
+
const [key, value] = style.split(':');
|
|
236
|
+
result[key] = value;
|
|
237
|
+
});
|
|
238
|
+
return result;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function buildStyleAttribute(styleObject) {
|
|
242
|
+
return Object.keys(styleObject)
|
|
243
|
+
.map((key) => `${key}:${styleObject[key]}`)
|
|
244
|
+
.join(';');
|
|
245
|
+
}
|
package/lib/text.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const { parseColor, srcToBuffer } = require('./utils');
|
|
2
|
+
const getUrls = require('get-urls').default;
|
|
3
|
+
const fetch = require('node-fetch').default;
|
|
4
|
+
|
|
5
|
+
async function getGoogleFontPath(fontFamily, fontWeight = 'normal') {
|
|
6
|
+
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
7
|
+
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${weight}`;
|
|
8
|
+
const req = await fetch(url);
|
|
9
|
+
if (!req.ok) {
|
|
10
|
+
if (weight !== '400') {
|
|
11
|
+
return getGoogleFontPath(fontFamily, 'normal');
|
|
12
|
+
}
|
|
13
|
+
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
14
|
+
}
|
|
15
|
+
const text = await req.text();
|
|
16
|
+
const urls = getUrls(text);
|
|
17
|
+
return urls.values().next().value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function loadFontIfNeeded(doc, element, fonts) {
|
|
21
|
+
// check if universal font is already defined
|
|
22
|
+
if (fonts[element.fontFamily]) {
|
|
23
|
+
doc.font(element.fontFamily);
|
|
24
|
+
return element.fontFamily;
|
|
25
|
+
}
|
|
26
|
+
const fontKey = `${element.fontFamily}-${element.fontWeight || 'normal'}`;
|
|
27
|
+
if (!fonts[fontKey]) {
|
|
28
|
+
const src = await getGoogleFontPath(element.fontFamily, element.fontWeight);
|
|
29
|
+
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
30
|
+
fonts[fontKey] = true;
|
|
31
|
+
}
|
|
32
|
+
doc.font(fontKey);
|
|
33
|
+
return fontKey;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function renderText(doc, element, attrs = {}) {
|
|
37
|
+
doc.fontSize(element.fontSize);
|
|
38
|
+
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
39
|
+
|
|
40
|
+
// Handle stroked text differently for PDF/X-1a compatibility
|
|
41
|
+
const hasStroke = element.strokeWidth > 0;
|
|
42
|
+
const isPDFX1a = attrs.pdfx1a;
|
|
43
|
+
|
|
44
|
+
if (hasStroke && !isPDFX1a) {
|
|
45
|
+
// Standard PDF: use PDFKit's built-in stroke support
|
|
46
|
+
doc.lineWidth(element.strokeWidth / 2);
|
|
47
|
+
doc.strokeColor(parseColor(element.stroke).hex);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const props = {
|
|
51
|
+
align: element.align,
|
|
52
|
+
fill: element.fill,
|
|
53
|
+
baseline: 'top',
|
|
54
|
+
lineGap: 1,
|
|
55
|
+
width: element.width,
|
|
56
|
+
underline: element.textDecoration.indexOf('underline') >= 0,
|
|
57
|
+
characterSpacing: element.letterSpacing
|
|
58
|
+
? element.letterSpacing * element.fontSize
|
|
59
|
+
: 0,
|
|
60
|
+
lineBreak: false,
|
|
61
|
+
stroke: hasStroke && !isPDFX1a, // Only use stroke for non-PDF/X-1a
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const currentLineHeight = doc.heightOfString('A', props);
|
|
65
|
+
const lineHeight = element.lineHeight * element.fontSize;
|
|
66
|
+
|
|
67
|
+
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
68
|
+
const fontBoundingBoxDescent =
|
|
69
|
+
(doc._font.descender / 1000) * element.fontSize;
|
|
70
|
+
const translateY = (fontBoundingBoxAscent - fontBoundingBoxDescent) / 2;
|
|
71
|
+
|
|
72
|
+
const diff = currentLineHeight - lineHeight;
|
|
73
|
+
props.lineGap = props.lineGap - diff;
|
|
74
|
+
|
|
75
|
+
let yOffset = 0;
|
|
76
|
+
if ((attrs.textVerticalResizeEnabled || true) && element.verticalAlign) {
|
|
77
|
+
const textHeight = doc.heightOfString(element.text, props);
|
|
78
|
+
if (element.verticalAlign === 'middle') {
|
|
79
|
+
yOffset = (element.height - textHeight) / 2;
|
|
80
|
+
} else if (element.verticalAlign === 'bottom') {
|
|
81
|
+
yOffset = element.height - textHeight;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (var size = element.fontSize; size > 0; size -= 1) {
|
|
86
|
+
doc.fontSize(size);
|
|
87
|
+
const height = doc.heightOfString(element.text, {
|
|
88
|
+
...props,
|
|
89
|
+
});
|
|
90
|
+
if (height <= element.height) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const halfLineHeight = ((element.lineHeight - 1) / 2) * element.fontSize;
|
|
96
|
+
|
|
97
|
+
if (element.backgroundEnabled) {
|
|
98
|
+
const backPadding =
|
|
99
|
+
element.backgroundPadding * (element.fontSize * element.lineHeight);
|
|
100
|
+
const cornerRadius =
|
|
101
|
+
element.backgroundCornerRadius *
|
|
102
|
+
(element.fontSize * element.lineHeight * 0.5);
|
|
103
|
+
|
|
104
|
+
const textWidth = doc.widthOfString(element.text, {
|
|
105
|
+
...props,
|
|
106
|
+
width: element.width,
|
|
107
|
+
});
|
|
108
|
+
const textHeight = doc.heightOfString(element.text, {
|
|
109
|
+
...props,
|
|
110
|
+
width: element.width,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let bgX = -backPadding / 2;
|
|
114
|
+
let bgY = -backPadding / 2;
|
|
115
|
+
let bgWidth = textWidth + backPadding;
|
|
116
|
+
let bgHeight = textHeight + backPadding;
|
|
117
|
+
|
|
118
|
+
if (element.align === 'center') {
|
|
119
|
+
bgX = (element.width - textWidth) / 2 - backPadding / 2;
|
|
120
|
+
} else if (element.align === 'right') {
|
|
121
|
+
bgX = element.width - textWidth - backPadding / 2;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (element.verticalAlign === 'middle') {
|
|
125
|
+
bgY = (element.height - textHeight) / 2 - backPadding / 2;
|
|
126
|
+
} else if (element.verticalAlign === 'bottom') {
|
|
127
|
+
bgY = element.height - textHeight - backPadding / 2;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
|
|
131
|
+
doc.fillColor(parseColor(element.backgroundColor).hex);
|
|
132
|
+
doc.fill();
|
|
133
|
+
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Render text with PDF/X-1a compatible stroke simulation
|
|
137
|
+
if (hasStroke && isPDFX1a) {
|
|
138
|
+
// For PDF/X-1a: simulate stroke by drawing text multiple times
|
|
139
|
+
const strokeColor = parseColor(element.stroke).hex;
|
|
140
|
+
const strokeWidth = element.strokeWidth;
|
|
141
|
+
|
|
142
|
+
// Draw stroke by rendering text multiple times with offsets
|
|
143
|
+
const offsets = [];
|
|
144
|
+
for (let angle = 0; angle < 360; angle += 45) {
|
|
145
|
+
const radian = (angle * Math.PI) / 180;
|
|
146
|
+
offsets.push({
|
|
147
|
+
x: Math.cos(radian) * strokeWidth,
|
|
148
|
+
y: Math.sin(radian) * strokeWidth,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Draw stroke layers
|
|
153
|
+
doc.save();
|
|
154
|
+
doc.fillColor(strokeColor, element.opacity);
|
|
155
|
+
for (const offset of offsets) {
|
|
156
|
+
doc.text(element.text, offset.x, yOffset + halfLineHeight + offset.y, {
|
|
157
|
+
...props,
|
|
158
|
+
stroke: false, // Force no stroke for compatibility
|
|
159
|
+
height: element.height + element.fontSize,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
doc.restore();
|
|
163
|
+
|
|
164
|
+
// Draw fill text on top
|
|
165
|
+
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
166
|
+
doc.text(element.text, 0, yOffset + halfLineHeight, {
|
|
167
|
+
...props,
|
|
168
|
+
stroke: false, // Force no stroke for compatibility
|
|
169
|
+
height: element.height + element.fontSize,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
// Standard rendering
|
|
173
|
+
doc.text(element.text, 0, yOffset + halfLineHeight, {
|
|
174
|
+
...props,
|
|
175
|
+
height: element.height + element.fontSize,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = {
|
|
181
|
+
getGoogleFontPath,
|
|
182
|
+
renderText,
|
|
183
|
+
loadFontIfNeeded,
|
|
184
|
+
};
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
const parseColor = require('parse-color');
|
|
2
|
+
const fetch = require('node-fetch').default;
|
|
3
|
+
const fileTypePromise = import('file-type');
|
|
4
|
+
const sharp = require('sharp');
|
|
5
|
+
const Canvas = require('canvas');
|
|
6
|
+
|
|
7
|
+
const DPI = 75;
|
|
8
|
+
const PIXEL_RATIO = 2;
|
|
9
|
+
|
|
10
|
+
function pxToPt(px) {
|
|
11
|
+
return (px * DPI) / 100;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function loadImage(src) {
|
|
15
|
+
let buffer;
|
|
16
|
+
let mime = 'unknown';
|
|
17
|
+
|
|
18
|
+
if (src.startsWith('data:')) {
|
|
19
|
+
const matches = src.match(/^data:(.+);base64,(.*)$/);
|
|
20
|
+
if (matches) {
|
|
21
|
+
mime = matches[1];
|
|
22
|
+
buffer = Buffer.from(matches[2], 'base64');
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error('Invalid data URL');
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(src);
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
`Failed to fetch image: ${src} (Status: ${response.status})`
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
buffer = await response.buffer();
|
|
35
|
+
const fileType = await fileTypePromise;
|
|
36
|
+
const typeData = await fileType.fileTypeFromBuffer(buffer);
|
|
37
|
+
if (typeData) {
|
|
38
|
+
({ mime } = typeData);
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`Failed to process image from ${src}: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let imageBuffer = buffer;
|
|
46
|
+
if (mime !== 'image/png' && mime !== 'image/jpeg') {
|
|
47
|
+
imageBuffer = await sharp(buffer).toFormat('png').toBuffer();
|
|
48
|
+
mime = 'image/png';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return await Canvas.loadImage(imageBuffer);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function srcToBase64(src) {
|
|
55
|
+
if (src.indexOf('base64') >= 0) {
|
|
56
|
+
return src.split('base64,')[1];
|
|
57
|
+
}
|
|
58
|
+
const res = await fetch(src);
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
throw new Error(`Failed to fetch: ${src} (Status: ${res.status})`);
|
|
61
|
+
}
|
|
62
|
+
const data = await res.buffer();
|
|
63
|
+
return data.toString('base64');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function srcToBuffer(src) {
|
|
67
|
+
return Buffer.from(await srcToBase64(src), 'base64');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
pxToPt,
|
|
72
|
+
loadImage,
|
|
73
|
+
srcToBase64,
|
|
74
|
+
srcToBuffer,
|
|
75
|
+
DPI,
|
|
76
|
+
PIXEL_RATIO,
|
|
77
|
+
parseColor,
|
|
78
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@polotno/pdf-export",
|
|
3
|
+
"version": "0.1.18",
|
|
4
|
+
"description": "Convert Polotno JSON into vector PDF",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "vitest",
|
|
8
|
+
"test:update-snapshots": "vitest -u"
|
|
9
|
+
},
|
|
10
|
+
"author": "Anton Lavrenov",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"README.md",
|
|
14
|
+
"lib"
|
|
15
|
+
],
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@canvas/image": "^2.0.0",
|
|
18
|
+
"@file-type/xml": "^0.4.3",
|
|
19
|
+
"canvas": "^3.1.1",
|
|
20
|
+
"file-type": "^21.0.0",
|
|
21
|
+
"get-urls": "^12.1.0",
|
|
22
|
+
"konva": "^9.3.20",
|
|
23
|
+
"node-fetch": "^3.3.2",
|
|
24
|
+
"parse-color": "^1.0.0",
|
|
25
|
+
"pdf2pic": "^3.2.0",
|
|
26
|
+
"pdfkit": "^0.17.1",
|
|
27
|
+
"polotno": "^2.24.1",
|
|
28
|
+
"sharp": "^0.34.2",
|
|
29
|
+
"svg-to-pdfkit": "^0.1.8",
|
|
30
|
+
"xmldom": "^0.6.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/pdfkit": "^0.14.0",
|
|
34
|
+
"jest-image-snapshot": "^6.5.1",
|
|
35
|
+
"pdf-img-convert": "^2.0.0",
|
|
36
|
+
"vitest": "^3.2.4"
|
|
37
|
+
}
|
|
38
|
+
}
|