@polotno/pdf-export 0.1.19 → 0.1.20
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/lib/compare-render.d.ts +1 -0
- package/lib/compare-render.js +185 -0
- package/lib/ghostscript.js +0 -3
- package/lib/image.d.ts +3 -1
- package/lib/image.js +16 -3
- package/lib/index.js +5 -2
- package/lib/svg.js +14 -6
- package/lib/text.d.ts +13 -2
- package/lib/text.js +524 -104
- package/lib/utils.d.ts +1 -0
- package/lib/utils.js +38 -8
- package/package.json +17 -5
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Compare PDFs produced by polotno-node vs this library for a subset of samples.
|
|
3
|
+
|
|
4
|
+
Usage examples:
|
|
5
|
+
node lib/compare-render.js --limit 10
|
|
6
|
+
node lib/compare-render.js --glob "2021-03-*"
|
|
7
|
+
node lib/compare-render.js --start 0 --end 50
|
|
8
|
+
|
|
9
|
+
Outputs:
|
|
10
|
+
- builds PDFs under `comparisons/<sample>/polotno-node.pdf` and `comparisons/<sample>/current.pdf`
|
|
11
|
+
- writes a simple byte-size diff report to `comparisons/report.json`
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { promisify } from 'util';
|
|
17
|
+
import { createInstance } from 'polotno-node';
|
|
18
|
+
import { pdfToPng } from 'pdf-to-png-converter';
|
|
19
|
+
import pixelmatch from 'pixelmatch';
|
|
20
|
+
import { PNG } from 'pngjs';
|
|
21
|
+
import { jsonToPDF } from './index.js';
|
|
22
|
+
const readFile = promisify(fs.readFile);
|
|
23
|
+
const writeFile = promisify(fs.writeFile);
|
|
24
|
+
const mkdir = promisify(fs.mkdir);
|
|
25
|
+
const access = promisify(fs.access);
|
|
26
|
+
const readdir = promisify(fs.readdir);
|
|
27
|
+
const SAMPLES_DIR = path.resolve('./samples');
|
|
28
|
+
const OUTPUT_DIR = path.resolve('./comparisons');
|
|
29
|
+
function parseArgs() {
|
|
30
|
+
const args = process.argv.slice(2);
|
|
31
|
+
const result = {};
|
|
32
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
33
|
+
const a = args[i];
|
|
34
|
+
if (a === '--limit')
|
|
35
|
+
result.limit = Number(args[++i]);
|
|
36
|
+
else if (a === '--start')
|
|
37
|
+
result.start = Number(args[++i]);
|
|
38
|
+
else if (a === '--end')
|
|
39
|
+
result.end = Number(args[++i]);
|
|
40
|
+
else if (a === '--glob')
|
|
41
|
+
result.glob = String(args[++i]);
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
async function tryEnsureDir(dir) {
|
|
46
|
+
try {
|
|
47
|
+
await access(dir, fs.constants.F_OK);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
await mkdir(dir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function sha256(buf) {
|
|
54
|
+
return crypto.createHash('sha256').update(buf).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
async function loadPolotnoJson(filePath) {
|
|
57
|
+
const buf = await readFile(filePath, 'utf8');
|
|
58
|
+
return JSON.parse(buf);
|
|
59
|
+
}
|
|
60
|
+
let polotnoInstance = null;
|
|
61
|
+
async function getPolotnoInstance() {
|
|
62
|
+
if (polotnoInstance)
|
|
63
|
+
return polotnoInstance;
|
|
64
|
+
const key = process.env.POLOTNO_API_KEY || process.env.POLOTNO_API_TOKEN || '';
|
|
65
|
+
polotnoInstance = await createInstance({ key });
|
|
66
|
+
return polotnoInstance;
|
|
67
|
+
}
|
|
68
|
+
async function renderWithPolotnoNode(json, outPath) {
|
|
69
|
+
const instance = await getPolotnoInstance();
|
|
70
|
+
const base64 = await instance.jsonToPDFBase64(json, {
|
|
71
|
+
pixelRatio: 4,
|
|
72
|
+
});
|
|
73
|
+
await writeFile(outPath, Buffer.from(base64, 'base64'));
|
|
74
|
+
}
|
|
75
|
+
async function renderWithCurrent(json, outPath) {
|
|
76
|
+
await jsonToPDF(json, outPath, {});
|
|
77
|
+
}
|
|
78
|
+
function pickSampleDirs(allDirs, { start = 0, end, limit, glob }) {
|
|
79
|
+
let list = allDirs.filter((d) => !d.startsWith('.'));
|
|
80
|
+
if (glob) {
|
|
81
|
+
const re = new RegExp('^' + glob.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$');
|
|
82
|
+
list = list.filter((d) => re.test(d));
|
|
83
|
+
}
|
|
84
|
+
if (typeof start === 'number' && typeof end === 'number' && end > start) {
|
|
85
|
+
list = list.slice(start, end);
|
|
86
|
+
}
|
|
87
|
+
if (typeof limit === 'number' && limit > 0) {
|
|
88
|
+
list = list.slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
return list;
|
|
91
|
+
}
|
|
92
|
+
async function run() {
|
|
93
|
+
const args = parseArgs();
|
|
94
|
+
await tryEnsureDir(OUTPUT_DIR);
|
|
95
|
+
const all = await readdir(SAMPLES_DIR, { withFileTypes: true });
|
|
96
|
+
const sampleDirs = pickSampleDirs(all.filter((d) => d.isDirectory()).map((d) => d.name), args);
|
|
97
|
+
const report = [];
|
|
98
|
+
for (const dir of sampleDirs) {
|
|
99
|
+
const samplePath = path.join(SAMPLES_DIR, dir);
|
|
100
|
+
const jsonFiles = (await readdir(samplePath)).filter((f) => f.endsWith('.json') && f !== 'meta.json');
|
|
101
|
+
if (jsonFiles.length === 0)
|
|
102
|
+
continue;
|
|
103
|
+
const designPath = path.join(samplePath, jsonFiles[0]);
|
|
104
|
+
const json = await loadPolotnoJson(designPath);
|
|
105
|
+
const outDir = path.join(OUTPUT_DIR, dir);
|
|
106
|
+
await tryEnsureDir(outDir);
|
|
107
|
+
const polotnoOut = path.join(outDir, 'polotno-node.pdf');
|
|
108
|
+
const currentOut = path.join(outDir, 'current.pdf');
|
|
109
|
+
if (!fs.existsSync(polotnoOut)) {
|
|
110
|
+
// Render both
|
|
111
|
+
try {
|
|
112
|
+
await renderWithPolotnoNode(json, polotnoOut);
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
await writeFile(path.join(outDir, 'error-polotno.txt'), String(e?.stack || e?.message || e));
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await renderWithCurrent(json, currentOut);
|
|
121
|
+
}
|
|
122
|
+
catch (e) {
|
|
123
|
+
await writeFile(path.join(outDir, 'error-current.txt'), String(e?.stack || e?.message || e));
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
// Rasterize first page of each PDF to PNG (via pdf-to-png-converter)
|
|
127
|
+
try {
|
|
128
|
+
const [imgA] = await pdfToPng(polotnoOut, { disableFontFace: true, viewportScale: 2, pagesToProcess: [1] });
|
|
129
|
+
const [imgB] = await pdfToPng(currentOut, { disableFontFace: true, viewportScale: 2, pagesToProcess: [1] });
|
|
130
|
+
const pngA = imgA.content;
|
|
131
|
+
const pngB = imgB.content;
|
|
132
|
+
const pngAPath = path.join(outDir, 'polotno-node.png');
|
|
133
|
+
const pngBPath = path.join(outDir, 'current.png');
|
|
134
|
+
await writeFile(pngAPath, pngA);
|
|
135
|
+
await writeFile(pngBPath, pngB);
|
|
136
|
+
// Visual diff with pixelmatch
|
|
137
|
+
const imgAParsed = PNG.sync.read(pngA);
|
|
138
|
+
const imgBParsed = PNG.sync.read(pngB);
|
|
139
|
+
const width = Math.min(imgAParsed.width, imgBParsed.width);
|
|
140
|
+
const height = Math.min(imgAParsed.height, imgBParsed.height);
|
|
141
|
+
const cropA = new PNG({ width, height });
|
|
142
|
+
const cropB = new PNG({ width, height });
|
|
143
|
+
PNG.bitblt(imgAParsed, cropA, 0, 0, width, height, 0, 0);
|
|
144
|
+
PNG.bitblt(imgBParsed, cropB, 0, 0, width, height, 0, 0);
|
|
145
|
+
const diff = new PNG({ width, height });
|
|
146
|
+
const numDiff = pixelmatch(cropA.data, cropB.data, diff.data, width, height, {
|
|
147
|
+
threshold: 0.1,
|
|
148
|
+
includeAA: true,
|
|
149
|
+
});
|
|
150
|
+
const diffPath = path.join(outDir, 'diff.png');
|
|
151
|
+
await writeFile(diffPath, PNG.sync.write(diff));
|
|
152
|
+
const a = await readFile(polotnoOut);
|
|
153
|
+
const b = await readFile(currentOut);
|
|
154
|
+
const item = {
|
|
155
|
+
sample: dir,
|
|
156
|
+
polotnoBytes: a.length,
|
|
157
|
+
currentBytes: b.length,
|
|
158
|
+
bytesDiff: Math.abs(a.length - b.length),
|
|
159
|
+
polotnoSha256: sha256(a),
|
|
160
|
+
currentSha256: sha256(b),
|
|
161
|
+
identical: numDiff === 0,
|
|
162
|
+
diffPixels: numDiff,
|
|
163
|
+
imageWidth: width,
|
|
164
|
+
imageHeight: height,
|
|
165
|
+
};
|
|
166
|
+
report.push(item);
|
|
167
|
+
await writeFile(path.join(outDir, 'summary.json'), JSON.stringify(item, null, 2));
|
|
168
|
+
console.log(`${dir}: diffPixels=${numDiff} sizeA=${item.polotnoBytes} sizeB=${item.currentBytes}`);
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
await writeFile(path.join(outDir, 'error-diff.txt'), String(e?.stack || e?.message || e));
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
await writeFile(path.join(OUTPUT_DIR, 'report.json'), JSON.stringify(report, null, 2));
|
|
175
|
+
console.log(`Done. Compared ${report.length} samples. Report: ${path.join(OUTPUT_DIR, 'report.json')}`);
|
|
176
|
+
// Close polotno-node instance to allow process to exit
|
|
177
|
+
if (polotnoInstance) {
|
|
178
|
+
await polotnoInstance.close();
|
|
179
|
+
polotnoInstance = null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
run().catch((err) => {
|
|
183
|
+
console.error(err);
|
|
184
|
+
process.exitCode = 1;
|
|
185
|
+
});
|
package/lib/ghostscript.js
CHANGED
|
@@ -45,7 +45,6 @@ export async function convertToPDFX1a(inputPath, outputPath, options = {}) {
|
|
|
45
45
|
fs.writeFileSync(tempPSFile, psMetadata);
|
|
46
46
|
args.splice(-1, 0, tempPSFile); // Insert before input file
|
|
47
47
|
}
|
|
48
|
-
console.log('GhostScript command:', 'gs', args.join(' '));
|
|
49
48
|
const gs = spawn('gs', args);
|
|
50
49
|
let stderr = '';
|
|
51
50
|
gs.stderr.on('data', (data) => {
|
|
@@ -60,7 +59,6 @@ export async function convertToPDFX1a(inputPath, outputPath, options = {}) {
|
|
|
60
59
|
}
|
|
61
60
|
}
|
|
62
61
|
if (code === 0) {
|
|
63
|
-
console.log('PDF/X-1a conversion successful');
|
|
64
62
|
resolve();
|
|
65
63
|
}
|
|
66
64
|
else {
|
|
@@ -124,7 +122,6 @@ export async function validatePDFX1a(pdfPath) {
|
|
|
124
122
|
resolve(true);
|
|
125
123
|
}
|
|
126
124
|
else {
|
|
127
|
-
console.log('PDF/X-1a validation issues:', stderr);
|
|
128
125
|
resolve(false);
|
|
129
126
|
}
|
|
130
127
|
});
|
package/lib/image.d.ts
CHANGED
|
@@ -12,8 +12,10 @@ export interface ImageElement {
|
|
|
12
12
|
opacity?: number;
|
|
13
13
|
flipX?: boolean;
|
|
14
14
|
flipY?: boolean;
|
|
15
|
+
borderSize?: number;
|
|
16
|
+
borderColor?: string;
|
|
15
17
|
}
|
|
16
18
|
export declare function getProcessedImageKey(element: ImageElement): string;
|
|
17
19
|
export declare function cropImage(src: string, element: ImageElement, cache?: ImageCache | null): Promise<string>;
|
|
18
20
|
export declare function clipImage(src: string, element: ImageElement, cache?: ImageCache | null): Promise<string>;
|
|
19
|
-
export declare function renderImage(doc:
|
|
21
|
+
export declare function renderImage(doc: PDFKit.PDFDocument, element: ImageElement, cache?: ImageCache | null): Promise<void>;
|
package/lib/image.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadImage, PIXEL_RATIO, srcToBuffer } from './utils.js';
|
|
1
|
+
import { loadImage, PIXEL_RATIO, srcToBuffer, parseColor } from './utils.js';
|
|
2
2
|
import Canvas from 'canvas';
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
@@ -79,6 +79,16 @@ export async function clipImage(src, element, cache = null) {
|
|
|
79
79
|
ctx.globalCompositeOperation = 'source-over';
|
|
80
80
|
return canvas.toDataURL('image/png');
|
|
81
81
|
}
|
|
82
|
+
function applyBorder(doc, element) {
|
|
83
|
+
if (element.borderSize > 0) {
|
|
84
|
+
const borderColor = parseColor(element.borderColor).keyword || 'black';
|
|
85
|
+
doc
|
|
86
|
+
.rect(element.borderSize / 2, element.borderSize / 2, element.width - element.borderSize, element.height - element.borderSize)
|
|
87
|
+
.lineWidth(element.borderSize)
|
|
88
|
+
.strokeColor(borderColor)
|
|
89
|
+
.stroke();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
82
92
|
export async function renderImage(doc, element, cache = null) {
|
|
83
93
|
// Check if we have a cached processed version
|
|
84
94
|
const cacheKey = getProcessedImageKey(element);
|
|
@@ -89,8 +99,10 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
89
99
|
doc.image(filePath, 0, 0, {
|
|
90
100
|
width: element.width,
|
|
91
101
|
height: element.height,
|
|
92
|
-
opacity
|
|
102
|
+
// No opacity property in pdfkit
|
|
103
|
+
// opacity: element.opacity,
|
|
93
104
|
});
|
|
105
|
+
applyBorder(doc, element);
|
|
94
106
|
return;
|
|
95
107
|
}
|
|
96
108
|
let src = null;
|
|
@@ -131,7 +143,8 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
131
143
|
doc.image(filePath, 0, 0, {
|
|
132
144
|
width: element.width,
|
|
133
145
|
height: element.height,
|
|
134
|
-
opacity: element.opacity,
|
|
146
|
+
// opacity: element.opacity,
|
|
135
147
|
});
|
|
148
|
+
applyBorder(doc, element);
|
|
136
149
|
}
|
|
137
150
|
}
|
package/lib/index.js
CHANGED
|
@@ -33,7 +33,7 @@ async function renderElement({ doc, element, fonts, attrs, cache, }) {
|
|
|
33
33
|
}
|
|
34
34
|
else if (element.type === 'text') {
|
|
35
35
|
await loadFontIfNeeded(doc, element, fonts);
|
|
36
|
-
renderText(doc, element, attrs);
|
|
36
|
+
await renderText(doc, element, fonts, attrs);
|
|
37
37
|
}
|
|
38
38
|
else if (element.type === 'line') {
|
|
39
39
|
lineToPDF(doc, element);
|
|
@@ -78,7 +78,10 @@ export async function jsonToPDF(json, pdfFileName, attrs = {}) {
|
|
|
78
78
|
page.background.indexOf('.png') >= 0 ||
|
|
79
79
|
page.background.indexOf('.jpg') >= 0;
|
|
80
80
|
if (isURL) {
|
|
81
|
-
doc.image(await srcToBuffer(page.background, cache), 0, 0
|
|
81
|
+
doc.image(await srcToBuffer(page.background, cache), 0, 0, {
|
|
82
|
+
cover: [json.width, json.height],
|
|
83
|
+
align: 'center',
|
|
84
|
+
});
|
|
82
85
|
}
|
|
83
86
|
else {
|
|
84
87
|
doc.rect(0, 0, json.width, json.height);
|
package/lib/svg.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Util } from 'konva/lib/Util.js';
|
|
2
|
-
import fetch from 'node-fetch';
|
|
3
2
|
import { DOMParser, XMLSerializer } from 'xmldom';
|
|
3
|
+
import { fetchWithTimeout } from './utils.js';
|
|
4
4
|
function isInsideDef(element) {
|
|
5
5
|
while (element.parentNode) {
|
|
6
6
|
if (element.nodeName === 'defs') {
|
|
@@ -85,7 +85,7 @@ export async function urlToBase64(url, cache = null) {
|
|
|
85
85
|
if (cache && cache.buffers.has(url)) {
|
|
86
86
|
return cache.buffers.get(url);
|
|
87
87
|
}
|
|
88
|
-
const req = await
|
|
88
|
+
const req = await fetchWithTimeout(url);
|
|
89
89
|
let result;
|
|
90
90
|
if (req.buffer) {
|
|
91
91
|
const buffer = await req.buffer();
|
|
@@ -116,7 +116,7 @@ export async function urlToString(url, cache = null) {
|
|
|
116
116
|
svgString = Buffer.from(url.split('base64,')[1], 'base64').toString();
|
|
117
117
|
}
|
|
118
118
|
else {
|
|
119
|
-
const req = await
|
|
119
|
+
const req = await fetchWithTimeout(url);
|
|
120
120
|
svgString = await req.text();
|
|
121
121
|
}
|
|
122
122
|
// Store in cache
|
|
@@ -175,10 +175,18 @@ export function fixSize(svgString) {
|
|
|
175
175
|
const str = xmlSerializer.serializeToString(doc);
|
|
176
176
|
return str;
|
|
177
177
|
}
|
|
178
|
-
const sameColors = (
|
|
179
|
-
if (!
|
|
178
|
+
const sameColors = (color1, color2) => {
|
|
179
|
+
if (!color1 || !color2) {
|
|
180
180
|
return false;
|
|
181
181
|
}
|
|
182
|
+
if (color2 === 'currentColor' && color1 === 'black') {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const c1 = Util.colorToRGBA(color1);
|
|
186
|
+
const c2 = Util.colorToRGBA(color2);
|
|
187
|
+
if (!c1 || !c2) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
182
190
|
return c1.r === c2.r && c1.g === c2.g && c1.b === c2.b && c1.a === c2.a;
|
|
183
191
|
};
|
|
184
192
|
export function replaceColors(svgString, replaceMap) {
|
|
@@ -195,7 +203,7 @@ export function replaceColors(svgString, replaceMap) {
|
|
|
195
203
|
colors.forEach(({ prop, color }) => {
|
|
196
204
|
// find matched oldColor
|
|
197
205
|
const marchedOldValue = oldColors.find((oldColor) => {
|
|
198
|
-
return sameColors(
|
|
206
|
+
return sameColors(oldColor, color);
|
|
199
207
|
});
|
|
200
208
|
if (!marchedOldValue) {
|
|
201
209
|
return;
|
package/lib/text.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export interface TextElement {
|
|
|
2
2
|
fontFamily: string;
|
|
3
3
|
fontWeight?: string;
|
|
4
4
|
fontSize: number;
|
|
5
|
+
fontStyle?: string;
|
|
5
6
|
fill: string;
|
|
6
7
|
opacity: number;
|
|
7
8
|
strokeWidth: number;
|
|
@@ -23,6 +24,16 @@ export interface RenderAttrs {
|
|
|
23
24
|
pdfx1a?: boolean;
|
|
24
25
|
textVerticalResizeEnabled?: boolean;
|
|
25
26
|
}
|
|
26
|
-
export
|
|
27
|
+
export interface TextSegment {
|
|
28
|
+
text: string;
|
|
29
|
+
bold?: boolean;
|
|
30
|
+
italic?: boolean;
|
|
31
|
+
underline?: boolean;
|
|
32
|
+
color?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function getGoogleFontPath(fontFamily: string, fontWeight?: string, italic?: boolean): Promise<string>;
|
|
27
35
|
export declare function loadFontIfNeeded(doc: any, element: TextElement, fonts: Record<string, boolean>): Promise<string>;
|
|
28
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Main text rendering function
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderText(doc: PDFKit.PDFDocument, element: TextElement, fonts: Record<string, boolean>, attrs?: RenderAttrs): Promise<void>;
|
package/lib/text.js
CHANGED
|
@@ -1,13 +1,104 @@
|
|
|
1
1
|
import { parseColor, srcToBuffer } from './utils.js';
|
|
2
2
|
import getUrls from 'get-urls';
|
|
3
3
|
import fetch from 'node-fetch';
|
|
4
|
-
|
|
4
|
+
import { stripHtml } from "string-strip-html";
|
|
5
|
+
/**
|
|
6
|
+
* Check if text contains HTML tags
|
|
7
|
+
*/
|
|
8
|
+
function containsHTML(text) {
|
|
9
|
+
const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span)[^>]*>/i;
|
|
10
|
+
return htmlTagRegex.test(text);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse HTML text into styled segments
|
|
14
|
+
*/
|
|
15
|
+
function parseHTMLToSegments(html, baseElement) {
|
|
16
|
+
const segments = [];
|
|
17
|
+
const tagStack = [];
|
|
18
|
+
// Regex to match tags and text content
|
|
19
|
+
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = regex.exec(html)) !== null) {
|
|
22
|
+
if (match[4]) {
|
|
23
|
+
// Text content
|
|
24
|
+
const text = match[4];
|
|
25
|
+
// Calculate current styles from tag stack
|
|
26
|
+
let bold = false;
|
|
27
|
+
let italic = false;
|
|
28
|
+
let underline = false;
|
|
29
|
+
let color = undefined;
|
|
30
|
+
for (const tag of tagStack) {
|
|
31
|
+
if (tag.tag === 'strong' || tag.tag === 'b')
|
|
32
|
+
bold = true;
|
|
33
|
+
if (tag.tag === 'em' || tag.tag === 'i')
|
|
34
|
+
italic = true;
|
|
35
|
+
if (tag.tag === 'u')
|
|
36
|
+
underline = true;
|
|
37
|
+
if (tag.color)
|
|
38
|
+
color = tag.color;
|
|
39
|
+
}
|
|
40
|
+
segments.push({
|
|
41
|
+
text,
|
|
42
|
+
bold,
|
|
43
|
+
italic,
|
|
44
|
+
underline,
|
|
45
|
+
color
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
// Tag
|
|
50
|
+
const isClosing = match[1] === '/';
|
|
51
|
+
const tagName = match[2].toLowerCase();
|
|
52
|
+
const attributes = match[3];
|
|
53
|
+
if (isClosing) {
|
|
54
|
+
// Remove from stack
|
|
55
|
+
const index = tagStack.findIndex(t => t.tag === tagName);
|
|
56
|
+
if (index !== -1) {
|
|
57
|
+
tagStack.splice(index, 1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
// Add to stack
|
|
62
|
+
const tagData = { tag: tagName };
|
|
63
|
+
// Parse color from span style attribute
|
|
64
|
+
if (attributes) {
|
|
65
|
+
const colorMatch = /style=["'](?:[^"']*)?color:\s*([^;"']+)/i.exec(attributes);
|
|
66
|
+
if (colorMatch) {
|
|
67
|
+
tagData.color = colorMatch[1].trim();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
tagStack.push(tagData);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return segments;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Get font weight string based on bold/italic state
|
|
78
|
+
*/
|
|
79
|
+
function getFontWeight(bold, italic, baseFontWeight) {
|
|
80
|
+
if (bold) {
|
|
81
|
+
return 'bold';
|
|
82
|
+
}
|
|
83
|
+
return baseFontWeight || 'normal';
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Get font key for caching
|
|
87
|
+
*/
|
|
88
|
+
function getFontKey(fontFamily, bold, italic, baseFontWeight) {
|
|
89
|
+
const weight = getFontWeight(bold, italic, baseFontWeight);
|
|
90
|
+
const style = italic ? 'italic' : 'normal';
|
|
91
|
+
return `${fontFamily}-${weight}-${style}`;
|
|
92
|
+
}
|
|
93
|
+
export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
|
|
5
94
|
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
6
|
-
const
|
|
95
|
+
const italicParam = italic ? 'italic' : '';
|
|
96
|
+
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
|
|
7
97
|
const req = await fetch(url);
|
|
8
98
|
if (!req.ok) {
|
|
9
|
-
if (weight !== '400') {
|
|
10
|
-
|
|
99
|
+
if (weight !== '400' || italic) {
|
|
100
|
+
// Fallback: try normal weight without italic
|
|
101
|
+
return getGoogleFontPath(fontFamily, 'normal', false);
|
|
11
102
|
}
|
|
12
103
|
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
13
104
|
}
|
|
@@ -21,30 +112,220 @@ export async function loadFontIfNeeded(doc, element, fonts) {
|
|
|
21
112
|
doc.font(element.fontFamily);
|
|
22
113
|
return element.fontFamily;
|
|
23
114
|
}
|
|
24
|
-
const
|
|
115
|
+
const isItalic = element.fontStyle?.indexOf('italic') >= 0;
|
|
116
|
+
const isBold = element.fontWeight == 'bold';
|
|
117
|
+
const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
|
|
25
118
|
if (!fonts[fontKey]) {
|
|
26
|
-
const src = await getGoogleFontPath(element.fontFamily, element.fontWeight);
|
|
119
|
+
const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
|
|
27
120
|
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
28
121
|
fonts[fontKey] = true;
|
|
29
122
|
}
|
|
30
123
|
doc.font(fontKey);
|
|
31
124
|
return fontKey;
|
|
32
125
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
doc.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Load font for a rich text segment
|
|
128
|
+
*/
|
|
129
|
+
async function loadFontForSegment(doc, segment, element, fonts) {
|
|
130
|
+
const fontFamily = element.fontFamily;
|
|
131
|
+
const bold = segment.bold || element.fontWeight == 'bold' || false;
|
|
132
|
+
const italic = segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false;
|
|
133
|
+
// Check if universal font is already defined
|
|
134
|
+
if (fonts[fontFamily]) {
|
|
135
|
+
doc.font(fontFamily);
|
|
136
|
+
return fontFamily;
|
|
137
|
+
}
|
|
138
|
+
const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
|
|
139
|
+
if (!fonts[fontKey]) {
|
|
140
|
+
const weight = getFontWeight(bold, italic, element.fontWeight);
|
|
141
|
+
const src = await getGoogleFontPath(fontFamily, weight, italic);
|
|
142
|
+
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
143
|
+
fonts[fontKey] = true;
|
|
144
|
+
}
|
|
145
|
+
doc.font(fontKey);
|
|
146
|
+
return fontKey;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Parse HTML into tokens (text and tags)
|
|
150
|
+
*/
|
|
151
|
+
function tokenizeHTML(html) {
|
|
152
|
+
const tokens = [];
|
|
153
|
+
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
154
|
+
let match;
|
|
155
|
+
while ((match = regex.exec(html)) !== null) {
|
|
156
|
+
if (match[4]) {
|
|
157
|
+
// Text content
|
|
158
|
+
tokens.push({
|
|
159
|
+
type: 'text',
|
|
160
|
+
content: match[4]
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
// Tag
|
|
165
|
+
const isClosing = match[1] === '/';
|
|
166
|
+
const tagName = match[2].toLowerCase();
|
|
167
|
+
tokens.push({
|
|
168
|
+
type: 'tag',
|
|
169
|
+
content: match[0],
|
|
170
|
+
tagName: tagName,
|
|
171
|
+
isClosing: isClosing
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return tokens;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
|
|
179
|
+
* @param tokens - Array of parsed HTML tokens
|
|
180
|
+
* @param openTags - Tags that were opened in previous lines and should be carried forward
|
|
181
|
+
* @returns Reconstructed HTML string and the updated list of open tags
|
|
182
|
+
*/
|
|
183
|
+
function tokensToHTML(tokens, openTags) {
|
|
184
|
+
let html = '';
|
|
185
|
+
const tagStack = [...openTags]; // Clone the open tags
|
|
186
|
+
// Prepend any open tags
|
|
187
|
+
for (const tag of openTags) {
|
|
188
|
+
html += tag.fullTag;
|
|
189
|
+
}
|
|
190
|
+
// Process tokens
|
|
191
|
+
for (const token of tokens) {
|
|
192
|
+
if (token.type === 'text') {
|
|
193
|
+
html += token.content;
|
|
194
|
+
}
|
|
195
|
+
else if (token.type === 'tag') {
|
|
196
|
+
html += token.content;
|
|
197
|
+
if (token.isClosing) {
|
|
198
|
+
// Remove from stack
|
|
199
|
+
const idx = tagStack.findIndex(t => t.name === token.tagName);
|
|
200
|
+
if (idx !== -1) {
|
|
201
|
+
tagStack.splice(idx, 1);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
// Add to stack
|
|
206
|
+
tagStack.push({
|
|
207
|
+
name: token.tagName,
|
|
208
|
+
fullTag: token.content
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// Close any remaining open tags for this line
|
|
214
|
+
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
215
|
+
html += `</${tagStack[i].name}>`;
|
|
216
|
+
}
|
|
217
|
+
return { html, openTags: tagStack };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Split text into lines that fit within the element width while preserving HTML formatting
|
|
221
|
+
* Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
|
|
222
|
+
*/
|
|
223
|
+
function splitTextIntoLines(doc, element, props) {
|
|
224
|
+
const lines = [];
|
|
225
|
+
const paragraphs = element.text.split('\n');
|
|
226
|
+
for (const paragraph of paragraphs) {
|
|
227
|
+
// Tokenize the paragraph
|
|
228
|
+
const tokens = tokenizeHTML(paragraph);
|
|
229
|
+
// Extract plain text for width calculation
|
|
230
|
+
const plainText = tokens
|
|
231
|
+
.filter(t => t.type === 'text')
|
|
232
|
+
.map(t => t.content)
|
|
233
|
+
.join('');
|
|
234
|
+
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
235
|
+
// Justify alignment using native pdfkit instruments
|
|
236
|
+
if (paragraphWidth <= element.width || element.align === 'justify') {
|
|
237
|
+
// Paragraph fits on one line
|
|
238
|
+
lines.push({ text: paragraph, width: paragraphWidth });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
// Need to split paragraph into multiple lines
|
|
242
|
+
let currentLine = '';
|
|
243
|
+
let currentWidth = 0;
|
|
244
|
+
let currentTokens = [];
|
|
245
|
+
let openTags = [];
|
|
246
|
+
for (const token of tokens) {
|
|
247
|
+
if (token.type === 'tag') {
|
|
248
|
+
currentTokens.push(token);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// Text token - split by words
|
|
252
|
+
const textWords = token.content.split(' ');
|
|
253
|
+
for (let i = 0; i < textWords.length; i++) {
|
|
254
|
+
const word = textWords[i];
|
|
255
|
+
const testLine = currentLine ? `${currentLine}${i > 0 ? ' ' : ''}${word}` : word;
|
|
256
|
+
const testWidth = doc.widthOfString(testLine, props);
|
|
257
|
+
if (testWidth <= element.width) {
|
|
258
|
+
currentLine = testLine;
|
|
259
|
+
currentWidth = testWidth;
|
|
260
|
+
// Add text token (with space if not first word in token)
|
|
261
|
+
if (i > 0 || currentTokens.length > 0) {
|
|
262
|
+
let content = (i > 0 ? ' ' : '') + word;
|
|
263
|
+
currentTokens.push({
|
|
264
|
+
type: 'text',
|
|
265
|
+
content: content
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
currentTokens.push({
|
|
270
|
+
type: 'text',
|
|
271
|
+
content: word
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
// Line is too long, save current line and start new one
|
|
277
|
+
if (currentLine) {
|
|
278
|
+
const result = tokensToHTML(currentTokens, openTags);
|
|
279
|
+
lines.push({ text: result.html, width: currentWidth });
|
|
280
|
+
openTags = result.openTags;
|
|
281
|
+
currentTokens = [];
|
|
282
|
+
}
|
|
283
|
+
currentLine = word;
|
|
284
|
+
currentWidth = doc.widthOfString(word, props);
|
|
285
|
+
currentTokens.push({
|
|
286
|
+
type: 'text',
|
|
287
|
+
content: word
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// Add the last line
|
|
293
|
+
if (currentLine) {
|
|
294
|
+
const result = tokensToHTML(currentTokens, openTags);
|
|
295
|
+
lines.push({ text: result.html, width: currentWidth });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return lines;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Calculate horizontal offset for a line of text based on alignment
|
|
303
|
+
* @param element - Text element with alignment settings
|
|
304
|
+
* @param lineWidth - Width of the current line
|
|
305
|
+
* @returns X offset for positioning the line
|
|
306
|
+
*/
|
|
307
|
+
function calculateLineXOffset(element, lineWidth) {
|
|
308
|
+
const align = element.align;
|
|
309
|
+
if (align === 'right') {
|
|
310
|
+
return element.width - lineWidth;
|
|
311
|
+
}
|
|
312
|
+
else if (align === 'center') {
|
|
313
|
+
return (element.width - lineWidth) / 2;
|
|
314
|
+
}
|
|
315
|
+
else if (align === 'justify') {
|
|
316
|
+
// Justify alignment is handled by PDFKit's align property
|
|
317
|
+
return 0;
|
|
318
|
+
}
|
|
319
|
+
// Default: left alignment
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Calculate text rendering metrics including line height and baseline offset
|
|
324
|
+
*/
|
|
325
|
+
function calculateTextMetrics(doc, element) {
|
|
326
|
+
const textOptions = {
|
|
327
|
+
align: element.align === 'justify' ? 'justify' : 'left',
|
|
328
|
+
baseline: 'alphabetic',
|
|
48
329
|
lineGap: 1,
|
|
49
330
|
width: element.width,
|
|
50
331
|
underline: element.textDecoration.indexOf('underline') >= 0,
|
|
@@ -52,106 +333,245 @@ export function renderText(doc, element, attrs = {}) {
|
|
|
52
333
|
? element.letterSpacing * element.fontSize
|
|
53
334
|
: 0,
|
|
54
335
|
lineBreak: false,
|
|
55
|
-
stroke:
|
|
336
|
+
stroke: false,
|
|
337
|
+
fill: false
|
|
56
338
|
};
|
|
57
|
-
const currentLineHeight = doc.heightOfString('A',
|
|
339
|
+
const currentLineHeight = doc.heightOfString('A', textOptions);
|
|
58
340
|
const lineHeight = element.lineHeight * element.fontSize;
|
|
59
341
|
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
60
342
|
const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
343
|
+
// Calculate baseline offset based on font metrics (similar to Konva rendering)
|
|
344
|
+
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 + lineHeight / 2;
|
|
345
|
+
// Adjust line gap to match desired line height
|
|
346
|
+
const lineHeightDiff = currentLineHeight - lineHeight;
|
|
347
|
+
textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
|
|
348
|
+
const textLines = splitTextIntoLines(doc, element, textOptions);
|
|
349
|
+
return {
|
|
350
|
+
textOptions,
|
|
351
|
+
lineHeightPx: lineHeight,
|
|
352
|
+
baselineOffset,
|
|
353
|
+
textLines
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Calculate vertical alignment offset for text
|
|
358
|
+
*/
|
|
359
|
+
function calculateVerticalAlignment(doc, element, textOptions) {
|
|
360
|
+
if (!element.verticalAlign || element.verticalAlign === 'top') {
|
|
361
|
+
return 0;
|
|
73
362
|
}
|
|
74
|
-
|
|
363
|
+
const strippedContent = stripHtml(element.text).result;
|
|
364
|
+
const textHeight = doc.heightOfString(strippedContent, textOptions);
|
|
365
|
+
if (element.verticalAlign === 'middle') {
|
|
366
|
+
return (element.height - textHeight) / 2;
|
|
367
|
+
}
|
|
368
|
+
else if (element.verticalAlign === 'bottom') {
|
|
369
|
+
return element.height - textHeight;
|
|
370
|
+
}
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Reduce font size to fit text within element height
|
|
375
|
+
*/
|
|
376
|
+
function fitTextToHeight(doc, element, textOptions) {
|
|
377
|
+
const strippedContent = stripHtml(element.text).result;
|
|
378
|
+
for (let size = element.fontSize; size > 0; size -= 1) {
|
|
75
379
|
doc.fontSize(size);
|
|
76
|
-
const height = doc.heightOfString(
|
|
77
|
-
...props,
|
|
78
|
-
});
|
|
380
|
+
const height = doc.heightOfString(strippedContent, textOptions);
|
|
79
381
|
if (height <= element.height) {
|
|
80
382
|
break;
|
|
81
383
|
}
|
|
82
384
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Render text background box
|
|
388
|
+
*/
|
|
389
|
+
function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions) {
|
|
390
|
+
if (!element.backgroundEnabled) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const strippedContent = stripHtml(element.text).result;
|
|
394
|
+
const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
|
|
395
|
+
const cornerRadius = element.backgroundCornerRadius * (element.fontSize * element.lineHeight * 0.5);
|
|
396
|
+
const textWidth = doc.widthOfString(strippedContent, {
|
|
397
|
+
...textOptions,
|
|
398
|
+
width: element.width,
|
|
399
|
+
});
|
|
400
|
+
const textHeight = doc.heightOfString(strippedContent, {
|
|
401
|
+
...textOptions,
|
|
402
|
+
width: element.width,
|
|
403
|
+
});
|
|
404
|
+
let bgX = -padding / 2;
|
|
405
|
+
let bgY = verticalAlignmentOffset - padding / 2;
|
|
406
|
+
const bgWidth = textWidth + padding;
|
|
407
|
+
const bgHeight = textHeight + padding;
|
|
408
|
+
// Adjust horizontal position based on text alignment
|
|
409
|
+
if (element.align === 'center') {
|
|
410
|
+
bgX = (element.width - textWidth) / 2 - padding / 2;
|
|
411
|
+
}
|
|
412
|
+
else if (element.align === 'right') {
|
|
413
|
+
bgX = element.width - textWidth - padding / 2;
|
|
414
|
+
}
|
|
415
|
+
doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
|
|
416
|
+
doc.fillColor(parseColor(element.backgroundColor).hex);
|
|
417
|
+
doc.fill();
|
|
418
|
+
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Render text stroke using PDF/X-1a compatible method (multiple offset fills)
|
|
422
|
+
*/
|
|
423
|
+
function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
424
|
+
const strokeColor = parseColor(element.stroke).hex;
|
|
425
|
+
const strokeWidth = element.strokeWidth;
|
|
426
|
+
const isJustify = element.align === 'justify';
|
|
427
|
+
// Generate stroke offsets in a circle pattern
|
|
428
|
+
const offsets = [];
|
|
429
|
+
for (let angle = 0; angle < 360; angle += 45) {
|
|
430
|
+
const radian = (angle * Math.PI) / 180;
|
|
431
|
+
offsets.push({
|
|
432
|
+
x: Math.cos(radian) * strokeWidth,
|
|
433
|
+
y: Math.sin(radian) * strokeWidth,
|
|
95
434
|
});
|
|
96
|
-
let bgX = -backPadding / 2;
|
|
97
|
-
let bgY = -backPadding / 2;
|
|
98
|
-
let bgWidth = textWidth + backPadding;
|
|
99
|
-
let bgHeight = textHeight + backPadding;
|
|
100
|
-
if (element.align === 'center') {
|
|
101
|
-
bgX = (element.width - textWidth) / 2 - backPadding / 2;
|
|
102
|
-
}
|
|
103
|
-
else if (element.align === 'right') {
|
|
104
|
-
bgX = element.width - textWidth - backPadding / 2;
|
|
105
|
-
}
|
|
106
|
-
if (element.verticalAlign === 'middle') {
|
|
107
|
-
bgY = (element.height - textHeight) / 2 - backPadding / 2;
|
|
108
|
-
}
|
|
109
|
-
else if (element.verticalAlign === 'bottom') {
|
|
110
|
-
bgY = element.height - textHeight - backPadding / 2;
|
|
111
|
-
}
|
|
112
|
-
doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
|
|
113
|
-
doc.fillColor(parseColor(element.backgroundColor).hex);
|
|
114
|
-
doc.fill();
|
|
115
|
-
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
116
435
|
}
|
|
117
|
-
// Render text
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
for (let angle = 0; angle < 360; angle += 45) {
|
|
125
|
-
const radian = (angle * Math.PI) / 180;
|
|
126
|
-
offsets.push({
|
|
127
|
-
x: Math.cos(radian) * strokeWidth,
|
|
128
|
-
y: Math.sin(radian) * strokeWidth,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
// Draw stroke layers
|
|
132
|
-
doc.save();
|
|
133
|
-
doc.fillColor(strokeColor, element.opacity);
|
|
436
|
+
// Render stroke layer by drawing text multiple times with offsets
|
|
437
|
+
doc.save();
|
|
438
|
+
doc.fillColor(strokeColor, element.opacity);
|
|
439
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
440
|
+
const line = textLines[i];
|
|
441
|
+
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
442
|
+
const lineYOffset = yOffset + (i * lineHeightPx);
|
|
134
443
|
for (const offset of offsets) {
|
|
135
|
-
doc.text(
|
|
136
|
-
...
|
|
137
|
-
|
|
138
|
-
|
|
444
|
+
doc.text(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
|
|
445
|
+
...textOptions,
|
|
446
|
+
width: isJustify ? element.width : undefined,
|
|
447
|
+
stroke: false,
|
|
139
448
|
});
|
|
140
449
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
450
|
+
}
|
|
451
|
+
doc.restore();
|
|
452
|
+
// Render fill layer on top
|
|
453
|
+
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
454
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
455
|
+
const line = textLines[i];
|
|
456
|
+
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
457
|
+
const lineYOffset = yOffset + (i * lineHeightPx);
|
|
458
|
+
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
459
|
+
...textOptions,
|
|
460
|
+
width: isJustify ? element.width : undefined,
|
|
461
|
+
stroke: false,
|
|
148
462
|
});
|
|
149
463
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Render text stroke using standard PDF stroke
|
|
467
|
+
*/
|
|
468
|
+
function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
469
|
+
const isJustify = element.align === 'justify';
|
|
470
|
+
doc.save();
|
|
471
|
+
doc.lineWidth(element.strokeWidth);
|
|
472
|
+
doc.lineCap('round').lineJoin('round');
|
|
473
|
+
doc.strokeColor(parseColor(element.stroke).hex, element.opacity);
|
|
474
|
+
let cumulativeYOffset = 0;
|
|
475
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
476
|
+
const line = textLines[i];
|
|
477
|
+
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
478
|
+
const lineYOffset = yOffset + cumulativeYOffset;
|
|
479
|
+
const strippedLineText = stripHtml(line.text).result;
|
|
480
|
+
const heightOfLine = line.text === ''
|
|
481
|
+
? lineHeightPx
|
|
482
|
+
: doc.heightOfString(strippedLineText, textOptions);
|
|
483
|
+
cumulativeYOffset += heightOfLine;
|
|
484
|
+
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
485
|
+
...textOptions,
|
|
486
|
+
width: isJustify ? element.width : undefined,
|
|
487
|
+
height: heightOfLine,
|
|
488
|
+
stroke: true,
|
|
489
|
+
fill: false
|
|
155
490
|
});
|
|
156
491
|
}
|
|
492
|
+
doc.restore();
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Render text fill with rich text support (HTML segments)
|
|
496
|
+
*/
|
|
497
|
+
async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
498
|
+
if (!element.fill) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
const baseParsedColor = parseColor(element.fill);
|
|
502
|
+
const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
503
|
+
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
504
|
+
const isJustify = element.align === 'justify';
|
|
505
|
+
let cumulativeYOffset = 0;
|
|
506
|
+
for (let i = 0; i < textLines.length; i++) {
|
|
507
|
+
const line = textLines[i];
|
|
508
|
+
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
509
|
+
const lineYOffset = yOffset + cumulativeYOffset;
|
|
510
|
+
const strippedLineText = stripHtml(line.text).result;
|
|
511
|
+
const heightOfLine = line.text === ''
|
|
512
|
+
? lineHeightPx
|
|
513
|
+
: doc.heightOfString(strippedLineText, textOptions);
|
|
514
|
+
cumulativeYOffset += heightOfLine;
|
|
515
|
+
// Position cursor at line start
|
|
516
|
+
doc.text('', lineXOffset, lineYOffset, { height: 0, width: 0 });
|
|
517
|
+
// Parse line into styled segments
|
|
518
|
+
const segments = parseHTMLToSegments(line.text, element);
|
|
519
|
+
// Render each segment with its own styling
|
|
520
|
+
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
521
|
+
const segment = segments[segmentIndex];
|
|
522
|
+
const isLastSegment = segmentIndex === segments.length - 1;
|
|
523
|
+
// Load appropriate font for this segment
|
|
524
|
+
await loadFontForSegment(doc, segment, element, fonts);
|
|
525
|
+
doc.fontSize(element.fontSize);
|
|
526
|
+
// Apply segment color
|
|
527
|
+
const segmentColor = segment.color
|
|
528
|
+
? parseColor(segment.color).hex
|
|
529
|
+
: parseColor(element.fill).hex;
|
|
530
|
+
const segmentParsedColor = segment.color
|
|
531
|
+
? parseColor(segment.color)
|
|
532
|
+
: parseColor(element.fill);
|
|
533
|
+
const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
534
|
+
doc.fillColor(segmentColor, segmentOpacity);
|
|
535
|
+
// Render segment text
|
|
536
|
+
doc.text(segment.text, {
|
|
537
|
+
...textOptions,
|
|
538
|
+
width: isJustify ? element.width : undefined,
|
|
539
|
+
height: heightOfLine,
|
|
540
|
+
continued: !isLastSegment,
|
|
541
|
+
underline: segment.underline || textOptions.underline || false,
|
|
542
|
+
lineBreak: !!segment.underline, // Workaround for pdfkit bug
|
|
543
|
+
stroke: false,
|
|
544
|
+
fill: true
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/**
|
|
550
|
+
* Main text rendering function
|
|
551
|
+
*/
|
|
552
|
+
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
553
|
+
doc.fontSize(element.fontSize);
|
|
554
|
+
const hasStroke = element.strokeWidth > 0;
|
|
555
|
+
const isPDFX1a = attrs.pdfx1a;
|
|
556
|
+
// Calculate text metrics and line positioning
|
|
557
|
+
const metrics = calculateTextMetrics(doc, element);
|
|
558
|
+
const verticalAlignmentOffset = calculateVerticalAlignment(doc, element, metrics.textOptions);
|
|
559
|
+
// Fit text to element height if needed
|
|
560
|
+
fitTextToHeight(doc, element, metrics.textOptions);
|
|
561
|
+
// Calculate final vertical offset
|
|
562
|
+
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
563
|
+
// Render background if enabled
|
|
564
|
+
renderTextBackground(doc, element, verticalAlignmentOffset, metrics.textOptions);
|
|
565
|
+
// Render text based on stroke and PDF/X-1a requirements
|
|
566
|
+
if (hasStroke && isPDFX1a) {
|
|
567
|
+
// PDF/X-1a mode: simulate stroke with offset fills
|
|
568
|
+
renderPDFX1aStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
// Standard rendering: stroke first, then fill
|
|
572
|
+
if (hasStroke) {
|
|
573
|
+
renderStandardStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
574
|
+
}
|
|
575
|
+
await renderTextFill(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
576
|
+
}
|
|
157
577
|
}
|
package/lib/utils.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import parseColor from 'parse-color';
|
|
2
2
|
export declare const DPI = 75;
|
|
3
3
|
export declare const PIXEL_RATIO = 2;
|
|
4
|
+
export declare function fetchWithTimeout(url: string, timeout?: number, retries?: number): Promise<any>;
|
|
4
5
|
export declare function pxToPt(px: number): number;
|
|
5
6
|
export interface ImageCache {
|
|
6
7
|
images: Map<string, any>;
|
package/lib/utils.js
CHANGED
|
@@ -4,6 +4,41 @@ import Canvas from 'canvas';
|
|
|
4
4
|
import sharp from 'sharp';
|
|
5
5
|
export const DPI = 75;
|
|
6
6
|
export const PIXEL_RATIO = 2;
|
|
7
|
+
// Fetch with timeout and retry logic
|
|
8
|
+
export async function fetchWithTimeout(url, timeout = 30000, retries = 3) {
|
|
9
|
+
for (let attempt = 1; attempt <= retries; attempt++) {
|
|
10
|
+
try {
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
13
|
+
const response = await fetch(url, {
|
|
14
|
+
signal: controller.signal,
|
|
15
|
+
});
|
|
16
|
+
clearTimeout(timeoutId);
|
|
17
|
+
if (!response.ok) {
|
|
18
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
19
|
+
}
|
|
20
|
+
return response;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const isLastAttempt = attempt === retries;
|
|
24
|
+
const isAbortError = error.name === 'AbortError';
|
|
25
|
+
const isTimeoutError = error.code === 'ETIMEDOUT' || error.type === 'request-timeout';
|
|
26
|
+
if (isLastAttempt) {
|
|
27
|
+
throw new Error(`Failed to fetch ${url} after ${retries} attempts: ${error.message}`);
|
|
28
|
+
}
|
|
29
|
+
// Only retry on network/timeout errors, not on 4xx client errors
|
|
30
|
+
if (isAbortError || isTimeoutError || error.code?.startsWith('E')) {
|
|
31
|
+
console.warn(`Fetch attempt ${attempt}/${retries} failed for ${url}, retrying...`);
|
|
32
|
+
// Exponential backoff: 1s, 2s, 4s, etc.
|
|
33
|
+
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt - 1) * 1000));
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
// Don't retry on other errors (e.g., 404, 403)
|
|
37
|
+
throw error;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Failed to fetch ${url}`);
|
|
41
|
+
}
|
|
7
42
|
export function pxToPt(px) {
|
|
8
43
|
return (px * DPI) / 100;
|
|
9
44
|
}
|
|
@@ -26,10 +61,7 @@ export async function loadImage(src, cache = null) {
|
|
|
26
61
|
}
|
|
27
62
|
else {
|
|
28
63
|
try {
|
|
29
|
-
const response = await
|
|
30
|
-
if (!response.ok) {
|
|
31
|
-
throw new Error(`Failed to fetch image: ${src} (Status: ${response.status})`);
|
|
32
|
-
}
|
|
64
|
+
const response = await fetchWithTimeout(src);
|
|
33
65
|
buffer = await response.buffer();
|
|
34
66
|
const { fileTypeFromBuffer } = await import('file-type');
|
|
35
67
|
const typeData = await fileTypeFromBuffer(buffer);
|
|
@@ -38,6 +70,7 @@ export async function loadImage(src, cache = null) {
|
|
|
38
70
|
}
|
|
39
71
|
}
|
|
40
72
|
catch (error) {
|
|
73
|
+
console.log(error);
|
|
41
74
|
throw new Error(`Failed to process image from ${src}: ${error.message}`);
|
|
42
75
|
}
|
|
43
76
|
}
|
|
@@ -65,10 +98,7 @@ export async function srcToBase64(src, cache = null) {
|
|
|
65
98
|
base64 = src.split('base64,')[1];
|
|
66
99
|
}
|
|
67
100
|
else {
|
|
68
|
-
const res = await
|
|
69
|
-
if (!res.ok) {
|
|
70
|
-
throw new Error(`Failed to fetch: ${src} (Status: ${res.status})`);
|
|
71
|
-
}
|
|
101
|
+
const res = await fetchWithTimeout(src);
|
|
72
102
|
const data = await res.buffer();
|
|
73
103
|
base64 = data.toString('base64');
|
|
74
104
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polotno/pdf-export",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Convert Polotno JSON into vector PDF",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"build": "tsc",
|
|
16
16
|
"test": "vitest",
|
|
17
|
-
"test:update-snapshots": "vitest -u"
|
|
17
|
+
"test:update-snapshots": "vitest -u",
|
|
18
|
+
"dev": "vite client",
|
|
19
|
+
"compare": "node lib/compare-render.js",
|
|
20
|
+
"postinstall": "patch-package"
|
|
18
21
|
},
|
|
19
22
|
"author": "Anton Lavrenov",
|
|
20
23
|
"files": [
|
|
@@ -28,19 +31,28 @@
|
|
|
28
31
|
"konva": "^10.0.8",
|
|
29
32
|
"node-fetch": "^3.3.2",
|
|
30
33
|
"parse-color": "^1.0.0",
|
|
31
|
-
"pdfkit": "^0.17.2",
|
|
32
34
|
"pdf2pic": "^3.2.0",
|
|
35
|
+
"pdfkit": "^0.17.2",
|
|
33
36
|
"polotno": "^2.29.5",
|
|
34
37
|
"sharp": "^0.34.4",
|
|
38
|
+
"string-strip-html": "^13.5.0",
|
|
35
39
|
"svg-to-pdfkit": "^0.1.8",
|
|
36
40
|
"xmldom": "^0.6.0"
|
|
37
41
|
},
|
|
38
42
|
"devDependencies": {
|
|
39
|
-
"@types/node": "^24.
|
|
43
|
+
"@types/node": "^24.10.0",
|
|
44
|
+
"@types/parse-color": "^1.0.3",
|
|
40
45
|
"@types/pdfkit": "^0.17.3",
|
|
41
46
|
"jest-image-snapshot": "^6.5.1",
|
|
47
|
+
"patch-package": "^8.0.1",
|
|
42
48
|
"pdf-img-convert": "^2.0.0",
|
|
49
|
+
"pdf-to-png-converter": "^3.10.0",
|
|
50
|
+
"pixelmatch": "^7.1.0",
|
|
51
|
+
"pngjs": "^7.0.0",
|
|
52
|
+
"polotno-node": "^2.12.30",
|
|
43
53
|
"typescript": "~5.9.3",
|
|
44
|
-
"
|
|
54
|
+
"@vitejs/plugin-react": "^5.1.0",
|
|
55
|
+
"vite": "^7.2.0",
|
|
56
|
+
"vitest": "^4.0.7"
|
|
45
57
|
}
|
|
46
58
|
}
|