@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.
@@ -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
+ });
@@ -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: any, element: ImageElement, cache?: ImageCache | null): Promise<void>;
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: element.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 fetch(url);
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 fetch(url);
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 = (c1, c2) => {
179
- if (!c1 || !c2) {
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(Util.colorToRGBA(oldColor), Util.colorToRGBA(color));
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 declare function getGoogleFontPath(fontFamily: string, fontWeight?: string): Promise<string>;
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
- export declare function renderText(doc: any, element: TextElement, attrs?: RenderAttrs): void;
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
- export async function getGoogleFontPath(fontFamily, fontWeight = 'normal') {
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 url = `https://fonts.googleapis.com/css?family=${fontFamily}:${weight}`;
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
- return getGoogleFontPath(fontFamily, 'normal');
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 fontKey = `${element.fontFamily}-${element.fontWeight || 'normal'}`;
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
- export function renderText(doc, element, attrs = {}) {
34
- doc.fontSize(element.fontSize);
35
- doc.fillColor(parseColor(element.fill).hex, element.opacity);
36
- // Handle stroked text differently for PDF/X-1a compatibility
37
- const hasStroke = element.strokeWidth > 0;
38
- const isPDFX1a = attrs.pdfx1a;
39
- if (hasStroke && !isPDFX1a) {
40
- // Standard PDF: use PDFKit's built-in stroke support
41
- doc.lineWidth(element.strokeWidth / 2);
42
- doc.strokeColor(parseColor(element.stroke).hex);
43
- }
44
- const props = {
45
- align: element.align,
46
- fill: element.fill,
47
- baseline: 'top',
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: hasStroke && !isPDFX1a, // Only use stroke for non-PDF/X-1a
336
+ stroke: false,
337
+ fill: false
56
338
  };
57
- const currentLineHeight = doc.heightOfString('A', props);
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
- const translateY = (fontBoundingBoxAscent - fontBoundingBoxDescent) / 2;
62
- const diff = currentLineHeight - lineHeight;
63
- props.lineGap = props.lineGap - diff;
64
- let yOffset = 0;
65
- if ((attrs.textVerticalResizeEnabled || true) && element.verticalAlign) {
66
- const textHeight = doc.heightOfString(element.text, props);
67
- if (element.verticalAlign === 'middle') {
68
- yOffset = (element.height - textHeight) / 2;
69
- }
70
- else if (element.verticalAlign === 'bottom') {
71
- yOffset = element.height - textHeight;
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
- for (var size = element.fontSize; size > 0; size -= 1) {
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(element.text, {
77
- ...props,
78
- });
380
+ const height = doc.heightOfString(strippedContent, textOptions);
79
381
  if (height <= element.height) {
80
382
  break;
81
383
  }
82
384
  }
83
- const halfLineHeight = ((element.lineHeight - 1) / 2) * element.fontSize;
84
- if (element.backgroundEnabled) {
85
- const backPadding = element.backgroundPadding * (element.fontSize * element.lineHeight);
86
- const cornerRadius = element.backgroundCornerRadius *
87
- (element.fontSize * element.lineHeight * 0.5);
88
- const textWidth = doc.widthOfString(element.text, {
89
- ...props,
90
- width: element.width,
91
- });
92
- const textHeight = doc.heightOfString(element.text, {
93
- ...props,
94
- width: element.width,
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 with PDF/X-1a compatible stroke simulation
118
- if (hasStroke && isPDFX1a) {
119
- // For PDF/X-1a: simulate stroke by drawing text multiple times
120
- const strokeColor = parseColor(element.stroke).hex;
121
- const strokeWidth = element.strokeWidth;
122
- // Draw stroke by rendering text multiple times with offsets
123
- const offsets = [];
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(element.text, offset.x, yOffset + halfLineHeight + offset.y, {
136
- ...props,
137
- stroke: false, // Force no stroke for compatibility
138
- height: element.height + element.fontSize,
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
- doc.restore();
142
- // Draw fill text on top
143
- doc.fillColor(parseColor(element.fill).hex, element.opacity);
144
- doc.text(element.text, 0, yOffset + halfLineHeight, {
145
- ...props,
146
- stroke: false, // Force no stroke for compatibility
147
- height: element.height + element.fontSize,
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
- else {
151
- // Standard rendering
152
- doc.text(element.text, 0, yOffset + halfLineHeight, {
153
- ...props,
154
- height: element.height + element.fontSize,
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 fetch(src);
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 fetch(src);
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.19",
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.9.1",
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
- "vitest": "^4.0.3"
54
+ "@vitejs/plugin-react": "^5.1.0",
55
+ "vite": "^7.2.0",
56
+ "vitest": "^4.0.7"
45
57
  }
46
58
  }