@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 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
@@ -0,0 +1,9 @@
1
+ async function renderGroup(doc, element, renderElement, fonts, attrs) {
2
+ for (const child of element.children) {
3
+ await renderElement({ doc, element: child, fonts, attrs });
4
+ }
5
+ }
6
+
7
+ module.exports = {
8
+ renderGroup,
9
+ };
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
+ }