@polotno/pdf-export 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -8
- package/lib/index.d.ts +66 -8
- package/lib/index.js +25 -145
- package/package.json +17 -18
- package/lib/compare-render.d.ts +0 -1
- package/lib/compare-render.js +0 -185
- package/lib/figure.d.ts +0 -10
- package/lib/figure.js +0 -54
- package/lib/filters.d.ts +0 -2
- package/lib/filters.js +0 -163
- package/lib/ghostscript.d.ts +0 -21
- package/lib/ghostscript.js +0 -132
- package/lib/group.d.ts +0 -5
- package/lib/group.js +0 -5
- package/lib/image.d.ts +0 -38
- package/lib/image.js +0 -279
- package/lib/line.d.ts +0 -10
- package/lib/line.js +0 -66
- package/lib/pdf-import/coordinate-transform.d.ts +0 -51
- package/lib/pdf-import/coordinate-transform.js +0 -99
- package/lib/pdf-import/element-builder.d.ts +0 -21
- package/lib/pdf-import/element-builder.js +0 -163
- package/lib/pdf-import/font-mapper.d.ts +0 -17
- package/lib/pdf-import/font-mapper.js +0 -142
- package/lib/pdf-import/index.d.ts +0 -35
- package/lib/pdf-import/index.js +0 -105
- package/lib/pdf-import/parser.d.ts +0 -29
- package/lib/pdf-import/parser.js +0 -285
- package/lib/pdf-import/text-analysis.d.ts +0 -17
- package/lib/pdf-import/text-analysis.js +0 -186
- package/lib/pdf-import/types.d.ts +0 -101
- package/lib/pdf-import/types.js +0 -1
- package/lib/scripts/compare-json.d.ts +0 -1
- package/lib/scripts/compare-json.js +0 -141
- package/lib/spot-colors.d.ts +0 -38
- package/lib/spot-colors.js +0 -141
- package/lib/svg-render.d.ts +0 -9
- package/lib/svg-render.js +0 -63
- package/lib/svg.d.ts +0 -12
- package/lib/svg.js +0 -224
- package/lib/text/fonts.d.ts +0 -16
- package/lib/text/fonts.js +0 -113
- package/lib/text/index.d.ts +0 -8
- package/lib/text/index.js +0 -42
- package/lib/text/layout.d.ts +0 -22
- package/lib/text/layout.js +0 -522
- package/lib/text/parser.d.ts +0 -46
- package/lib/text/parser.js +0 -415
- package/lib/text/render.d.ts +0 -8
- package/lib/text/render.js +0 -237
- package/lib/text/types.d.ts +0 -91
- package/lib/text/types.js +0 -1
- package/lib/text.d.ts +0 -39
- package/lib/text.js +0 -576
- package/lib/utils.d.ts +0 -16
- package/lib/utils.js +0 -124
package/lib/text.js
DELETED
|
@@ -1,576 +0,0 @@
|
|
|
1
|
-
import { parseColor, srcToBuffer, fetchWithTimeout } from './utils.js';
|
|
2
|
-
import getUrls from 'get-urls';
|
|
3
|
-
import { stripHtml } from "string-strip-html";
|
|
4
|
-
/**
|
|
5
|
-
* Check if text contains HTML tags
|
|
6
|
-
*/
|
|
7
|
-
function containsHTML(text) {
|
|
8
|
-
const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span)[^>]*>/i;
|
|
9
|
-
return htmlTagRegex.test(text);
|
|
10
|
-
}
|
|
11
|
-
/**
|
|
12
|
-
* Parse HTML text into styled segments
|
|
13
|
-
*/
|
|
14
|
-
function parseHTMLToSegments(html, baseElement) {
|
|
15
|
-
const segments = [];
|
|
16
|
-
const tagStack = [];
|
|
17
|
-
// Regex to match tags and text content
|
|
18
|
-
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
19
|
-
let match;
|
|
20
|
-
while ((match = regex.exec(html)) !== null) {
|
|
21
|
-
if (match[4]) {
|
|
22
|
-
// Text content
|
|
23
|
-
const text = match[4];
|
|
24
|
-
// Calculate current styles from tag stack
|
|
25
|
-
let bold = false;
|
|
26
|
-
let italic = false;
|
|
27
|
-
let underline = false;
|
|
28
|
-
let color = undefined;
|
|
29
|
-
for (const tag of tagStack) {
|
|
30
|
-
if (tag.tag === 'strong' || tag.tag === 'b')
|
|
31
|
-
bold = true;
|
|
32
|
-
if (tag.tag === 'em' || tag.tag === 'i')
|
|
33
|
-
italic = true;
|
|
34
|
-
if (tag.tag === 'u')
|
|
35
|
-
underline = true;
|
|
36
|
-
if (tag.color)
|
|
37
|
-
color = tag.color;
|
|
38
|
-
}
|
|
39
|
-
segments.push({
|
|
40
|
-
text,
|
|
41
|
-
bold,
|
|
42
|
-
italic,
|
|
43
|
-
underline,
|
|
44
|
-
color
|
|
45
|
-
});
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
// Tag
|
|
49
|
-
const isClosing = match[1] === '/';
|
|
50
|
-
const tagName = match[2].toLowerCase();
|
|
51
|
-
const attributes = match[3];
|
|
52
|
-
if (isClosing) {
|
|
53
|
-
// Remove from stack
|
|
54
|
-
const index = tagStack.findIndex(t => t.tag === tagName);
|
|
55
|
-
if (index !== -1) {
|
|
56
|
-
tagStack.splice(index, 1);
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
// Add to stack
|
|
61
|
-
const tagData = { tag: tagName };
|
|
62
|
-
// Parse color from span style attribute
|
|
63
|
-
if (attributes) {
|
|
64
|
-
const colorMatch = /style=["'](?:[^"']*)?color:\s*([^;"']+)/i.exec(attributes);
|
|
65
|
-
if (colorMatch) {
|
|
66
|
-
tagData.color = colorMatch[1].trim();
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
tagStack.push(tagData);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return segments;
|
|
74
|
-
}
|
|
75
|
-
/**
|
|
76
|
-
* Get font weight string based on bold/italic state
|
|
77
|
-
*/
|
|
78
|
-
function getFontWeight(bold, italic, baseFontWeight) {
|
|
79
|
-
if (bold) {
|
|
80
|
-
return 'bold';
|
|
81
|
-
}
|
|
82
|
-
return baseFontWeight || 'normal';
|
|
83
|
-
}
|
|
84
|
-
/**
|
|
85
|
-
* Get font key for caching
|
|
86
|
-
*/
|
|
87
|
-
function getFontKey(fontFamily, bold, italic, baseFontWeight) {
|
|
88
|
-
const weight = getFontWeight(bold, italic, baseFontWeight);
|
|
89
|
-
const style = italic ? 'italic' : 'normal';
|
|
90
|
-
return `${fontFamily}-${weight}-${style}`;
|
|
91
|
-
}
|
|
92
|
-
export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
|
|
93
|
-
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
94
|
-
const italicParam = italic ? 'italic' : '';
|
|
95
|
-
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
|
|
96
|
-
const req = await fetchWithTimeout(url);
|
|
97
|
-
if (!req.ok) {
|
|
98
|
-
if (weight !== '400' || italic) {
|
|
99
|
-
// Fallback: try normal weight without italic
|
|
100
|
-
return getGoogleFontPath(fontFamily, 'normal', false);
|
|
101
|
-
}
|
|
102
|
-
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
103
|
-
}
|
|
104
|
-
const text = await req.text();
|
|
105
|
-
const urls = getUrls(text);
|
|
106
|
-
return urls.values().next().value;
|
|
107
|
-
}
|
|
108
|
-
export async function loadFontIfNeeded(doc, element, fonts) {
|
|
109
|
-
// check if universal font is already defined
|
|
110
|
-
if (fonts[element.fontFamily]) {
|
|
111
|
-
doc.font(element.fontFamily);
|
|
112
|
-
return element.fontFamily;
|
|
113
|
-
}
|
|
114
|
-
const isItalic = element.fontStyle?.indexOf('italic') >= 0;
|
|
115
|
-
const isBold = element.fontWeight == 'bold';
|
|
116
|
-
const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
|
|
117
|
-
if (!fonts[fontKey]) {
|
|
118
|
-
const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
|
|
119
|
-
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
120
|
-
fonts[fontKey] = true;
|
|
121
|
-
}
|
|
122
|
-
doc.font(fontKey);
|
|
123
|
-
return fontKey;
|
|
124
|
-
}
|
|
125
|
-
/**
|
|
126
|
-
* Load font for a rich text segment
|
|
127
|
-
*/
|
|
128
|
-
async function loadFontForSegment(doc, segment, element, fonts) {
|
|
129
|
-
const fontFamily = element.fontFamily;
|
|
130
|
-
const bold = segment.bold || element.fontWeight == 'bold' || false;
|
|
131
|
-
const italic = segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false;
|
|
132
|
-
// Check if universal font is already defined
|
|
133
|
-
if (fonts[fontFamily]) {
|
|
134
|
-
doc.font(fontFamily);
|
|
135
|
-
return fontFamily;
|
|
136
|
-
}
|
|
137
|
-
const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
|
|
138
|
-
if (!fonts[fontKey]) {
|
|
139
|
-
const weight = getFontWeight(bold, italic, element.fontWeight);
|
|
140
|
-
const src = await getGoogleFontPath(fontFamily, weight, italic);
|
|
141
|
-
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
142
|
-
fonts[fontKey] = true;
|
|
143
|
-
}
|
|
144
|
-
doc.font(fontKey);
|
|
145
|
-
return fontKey;
|
|
146
|
-
}
|
|
147
|
-
/**
|
|
148
|
-
* Parse HTML into tokens (text and tags)
|
|
149
|
-
*/
|
|
150
|
-
function tokenizeHTML(html) {
|
|
151
|
-
const tokens = [];
|
|
152
|
-
const regex = /<(\/?)(strong|b|em|i|u|span)([^>]*)>|([^<]+)/gi;
|
|
153
|
-
let match;
|
|
154
|
-
while ((match = regex.exec(html)) !== null) {
|
|
155
|
-
if (match[4]) {
|
|
156
|
-
// Text content
|
|
157
|
-
tokens.push({
|
|
158
|
-
type: 'text',
|
|
159
|
-
content: match[4]
|
|
160
|
-
});
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
// Tag
|
|
164
|
-
const isClosing = match[1] === '/';
|
|
165
|
-
const tagName = match[2].toLowerCase();
|
|
166
|
-
tokens.push({
|
|
167
|
-
type: 'tag',
|
|
168
|
-
content: match[0],
|
|
169
|
-
tagName: tagName,
|
|
170
|
-
isClosing: isClosing
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
return tokens;
|
|
175
|
-
}
|
|
176
|
-
/**
|
|
177
|
-
* Reconstruct HTML from tokens while maintaining proper tag nesting across line breaks
|
|
178
|
-
* @param tokens - Array of parsed HTML tokens
|
|
179
|
-
* @param openTags - Tags that were opened in previous lines and should be carried forward
|
|
180
|
-
* @returns Reconstructed HTML string and the updated list of open tags
|
|
181
|
-
*/
|
|
182
|
-
function tokensToHTML(tokens, openTags) {
|
|
183
|
-
let html = '';
|
|
184
|
-
const tagStack = [...openTags]; // Clone the open tags
|
|
185
|
-
// Prepend any open tags
|
|
186
|
-
for (const tag of openTags) {
|
|
187
|
-
html += tag.fullTag;
|
|
188
|
-
}
|
|
189
|
-
// Process tokens
|
|
190
|
-
for (const token of tokens) {
|
|
191
|
-
if (token.type === 'text') {
|
|
192
|
-
html += token.content;
|
|
193
|
-
}
|
|
194
|
-
else if (token.type === 'tag') {
|
|
195
|
-
html += token.content;
|
|
196
|
-
if (token.isClosing) {
|
|
197
|
-
// Remove from stack
|
|
198
|
-
const idx = tagStack.findIndex(t => t.name === token.tagName);
|
|
199
|
-
if (idx !== -1) {
|
|
200
|
-
tagStack.splice(idx, 1);
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
else {
|
|
204
|
-
// Add to stack
|
|
205
|
-
tagStack.push({
|
|
206
|
-
name: token.tagName,
|
|
207
|
-
fullTag: token.content
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
// Close any remaining open tags for this line
|
|
213
|
-
for (let i = tagStack.length - 1; i >= 0; i--) {
|
|
214
|
-
html += `</${tagStack[i].name}>`;
|
|
215
|
-
}
|
|
216
|
-
return { html, openTags: tagStack };
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Split text into lines that fit within the element width while preserving HTML formatting
|
|
220
|
-
* Handles word wrapping and ensures HTML tags are properly opened/closed across line breaks
|
|
221
|
-
*/
|
|
222
|
-
function splitTextIntoLines(doc, element, props) {
|
|
223
|
-
const lines = [];
|
|
224
|
-
const paragraphs = element.text.split('\n');
|
|
225
|
-
for (const paragraph of paragraphs) {
|
|
226
|
-
// Tokenize the paragraph
|
|
227
|
-
const tokens = tokenizeHTML(paragraph);
|
|
228
|
-
// Extract plain text for width calculation
|
|
229
|
-
const plainText = tokens
|
|
230
|
-
.filter(t => t.type === 'text')
|
|
231
|
-
.map(t => t.content)
|
|
232
|
-
.join('');
|
|
233
|
-
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
234
|
-
// Justify alignment using native pdfkit instruments
|
|
235
|
-
if (paragraphWidth <= element.width || element.align === 'justify') {
|
|
236
|
-
// Paragraph fits on one line
|
|
237
|
-
lines.push({ text: paragraph, width: paragraphWidth });
|
|
238
|
-
}
|
|
239
|
-
else {
|
|
240
|
-
// Need to split paragraph into multiple lines
|
|
241
|
-
let currentLine = '';
|
|
242
|
-
let currentWidth = 0;
|
|
243
|
-
let currentTokens = [];
|
|
244
|
-
let openTags = [];
|
|
245
|
-
for (const token of tokens) {
|
|
246
|
-
if (token.type === 'tag') {
|
|
247
|
-
currentTokens.push(token);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
// Text token - split by words
|
|
251
|
-
const textWords = token.content.split(' ');
|
|
252
|
-
for (let i = 0; i < textWords.length; i++) {
|
|
253
|
-
const word = textWords[i];
|
|
254
|
-
const testLine = currentLine ? `${currentLine}${i > 0 ? ' ' : ''}${word}` : word;
|
|
255
|
-
const testWidth = doc.widthOfString(testLine, props);
|
|
256
|
-
if (testWidth <= element.width) {
|
|
257
|
-
currentLine = testLine;
|
|
258
|
-
currentWidth = testWidth;
|
|
259
|
-
// Add text token (with space if not first word in token)
|
|
260
|
-
if (i > 0 || currentTokens.length > 0) {
|
|
261
|
-
let content = (i > 0 ? ' ' : '') + word;
|
|
262
|
-
currentTokens.push({
|
|
263
|
-
type: 'text',
|
|
264
|
-
content: content
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
else {
|
|
268
|
-
currentTokens.push({
|
|
269
|
-
type: 'text',
|
|
270
|
-
content: word
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
// Line is too long, save current line and start new one
|
|
276
|
-
if (currentLine) {
|
|
277
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
278
|
-
lines.push({ text: result.html, width: currentWidth });
|
|
279
|
-
openTags = result.openTags;
|
|
280
|
-
currentTokens = [];
|
|
281
|
-
}
|
|
282
|
-
currentLine = word;
|
|
283
|
-
currentWidth = doc.widthOfString(word, props);
|
|
284
|
-
currentTokens.push({
|
|
285
|
-
type: 'text',
|
|
286
|
-
content: word
|
|
287
|
-
});
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
// Add the last line
|
|
292
|
-
if (currentLine) {
|
|
293
|
-
const result = tokensToHTML(currentTokens, openTags);
|
|
294
|
-
lines.push({ text: result.html, width: currentWidth });
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
return lines;
|
|
299
|
-
}
|
|
300
|
-
/**
|
|
301
|
-
* Calculate horizontal offset for a line of text based on alignment
|
|
302
|
-
* @param element - Text element with alignment settings
|
|
303
|
-
* @param lineWidth - Width of the current line
|
|
304
|
-
* @returns X offset for positioning the line
|
|
305
|
-
*/
|
|
306
|
-
function calculateLineXOffset(element, lineWidth) {
|
|
307
|
-
const align = element.align;
|
|
308
|
-
if (align === 'right') {
|
|
309
|
-
return element.width - lineWidth;
|
|
310
|
-
}
|
|
311
|
-
else if (align === 'center') {
|
|
312
|
-
return (element.width - lineWidth) / 2;
|
|
313
|
-
}
|
|
314
|
-
else if (align === 'justify') {
|
|
315
|
-
// Justify alignment is handled by PDFKit's align property
|
|
316
|
-
return 0;
|
|
317
|
-
}
|
|
318
|
-
// Default: left alignment
|
|
319
|
-
return 0;
|
|
320
|
-
}
|
|
321
|
-
/**
|
|
322
|
-
* Calculate text rendering metrics including line height and baseline offset
|
|
323
|
-
*/
|
|
324
|
-
function calculateTextMetrics(doc, element) {
|
|
325
|
-
const textOptions = {
|
|
326
|
-
align: element.align === 'justify' ? 'justify' : 'left',
|
|
327
|
-
baseline: 'alphabetic',
|
|
328
|
-
lineGap: 1,
|
|
329
|
-
width: element.width,
|
|
330
|
-
underline: element.textDecoration.indexOf('underline') >= 0,
|
|
331
|
-
characterSpacing: element.letterSpacing
|
|
332
|
-
? element.letterSpacing * element.fontSize
|
|
333
|
-
: 0,
|
|
334
|
-
lineBreak: false,
|
|
335
|
-
stroke: false,
|
|
336
|
-
fill: false
|
|
337
|
-
};
|
|
338
|
-
const currentLineHeight = doc.heightOfString('A', textOptions);
|
|
339
|
-
const lineHeight = element.lineHeight * element.fontSize;
|
|
340
|
-
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
341
|
-
const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
|
|
342
|
-
// Calculate baseline offset based on font metrics (similar to Konva rendering)
|
|
343
|
-
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 + lineHeight / 2;
|
|
344
|
-
// Adjust line gap to match desired line height
|
|
345
|
-
const lineHeightDiff = currentLineHeight - lineHeight;
|
|
346
|
-
textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
|
|
347
|
-
const textLines = splitTextIntoLines(doc, element, textOptions);
|
|
348
|
-
return {
|
|
349
|
-
textOptions,
|
|
350
|
-
lineHeightPx: lineHeight,
|
|
351
|
-
baselineOffset,
|
|
352
|
-
textLines
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
/**
|
|
356
|
-
* Calculate vertical alignment offset for text
|
|
357
|
-
*/
|
|
358
|
-
function calculateVerticalAlignment(doc, element, textOptions) {
|
|
359
|
-
if (!element.verticalAlign || element.verticalAlign === 'top') {
|
|
360
|
-
return 0;
|
|
361
|
-
}
|
|
362
|
-
const strippedContent = stripHtml(element.text).result;
|
|
363
|
-
const textHeight = doc.heightOfString(strippedContent, textOptions);
|
|
364
|
-
if (element.verticalAlign === 'middle') {
|
|
365
|
-
return (element.height - textHeight) / 2;
|
|
366
|
-
}
|
|
367
|
-
else if (element.verticalAlign === 'bottom') {
|
|
368
|
-
return element.height - textHeight;
|
|
369
|
-
}
|
|
370
|
-
return 0;
|
|
371
|
-
}
|
|
372
|
-
/**
|
|
373
|
-
* Reduce font size to fit text within element height
|
|
374
|
-
*/
|
|
375
|
-
function fitTextToHeight(doc, element, textOptions) {
|
|
376
|
-
const strippedContent = stripHtml(element.text).result;
|
|
377
|
-
for (let size = element.fontSize; size > 0; size -= 1) {
|
|
378
|
-
doc.fontSize(size);
|
|
379
|
-
const height = doc.heightOfString(strippedContent, textOptions);
|
|
380
|
-
if (height <= element.height) {
|
|
381
|
-
break;
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* Render text background box
|
|
387
|
-
*/
|
|
388
|
-
function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions) {
|
|
389
|
-
if (!element.backgroundEnabled) {
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
|
-
const strippedContent = stripHtml(element.text).result;
|
|
393
|
-
const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
|
|
394
|
-
const cornerRadius = element.backgroundCornerRadius * (element.fontSize * element.lineHeight * 0.5);
|
|
395
|
-
const textWidth = doc.widthOfString(strippedContent, {
|
|
396
|
-
...textOptions,
|
|
397
|
-
width: element.width,
|
|
398
|
-
});
|
|
399
|
-
const textHeight = doc.heightOfString(strippedContent, {
|
|
400
|
-
...textOptions,
|
|
401
|
-
width: element.width,
|
|
402
|
-
});
|
|
403
|
-
let bgX = -padding / 2;
|
|
404
|
-
let bgY = verticalAlignmentOffset - padding / 2;
|
|
405
|
-
const bgWidth = textWidth + padding;
|
|
406
|
-
const bgHeight = textHeight + padding;
|
|
407
|
-
// Adjust horizontal position based on text alignment
|
|
408
|
-
if (element.align === 'center') {
|
|
409
|
-
bgX = (element.width - textWidth) / 2 - padding / 2;
|
|
410
|
-
}
|
|
411
|
-
else if (element.align === 'right') {
|
|
412
|
-
bgX = element.width - textWidth - padding / 2;
|
|
413
|
-
}
|
|
414
|
-
doc.roundedRect(bgX, bgY, bgWidth, bgHeight, cornerRadius);
|
|
415
|
-
doc.fillColor(parseColor(element.backgroundColor).hex);
|
|
416
|
-
doc.fill();
|
|
417
|
-
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Render text stroke using PDF/X-1a compatible method (multiple offset fills)
|
|
421
|
-
*/
|
|
422
|
-
function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
423
|
-
const strokeColor = parseColor(element.stroke).hex;
|
|
424
|
-
const strokeWidth = element.strokeWidth;
|
|
425
|
-
const isJustify = element.align === 'justify';
|
|
426
|
-
// Generate stroke offsets in a circle pattern
|
|
427
|
-
const offsets = [];
|
|
428
|
-
for (let angle = 0; angle < 360; angle += 45) {
|
|
429
|
-
const radian = (angle * Math.PI) / 180;
|
|
430
|
-
offsets.push({
|
|
431
|
-
x: Math.cos(radian) * strokeWidth,
|
|
432
|
-
y: Math.sin(radian) * strokeWidth,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
// Render stroke layer by drawing text multiple times with offsets
|
|
436
|
-
doc.save();
|
|
437
|
-
doc.fillColor(strokeColor, element.opacity);
|
|
438
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
439
|
-
const line = textLines[i];
|
|
440
|
-
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
441
|
-
const lineYOffset = yOffset + (i * lineHeightPx);
|
|
442
|
-
for (const offset of offsets) {
|
|
443
|
-
doc.text(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
|
|
444
|
-
...textOptions,
|
|
445
|
-
width: isJustify ? element.width : undefined,
|
|
446
|
-
stroke: false,
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
doc.restore();
|
|
451
|
-
// Render fill layer on top
|
|
452
|
-
doc.fillColor(parseColor(element.fill).hex, element.opacity);
|
|
453
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
454
|
-
const line = textLines[i];
|
|
455
|
-
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
456
|
-
const lineYOffset = yOffset + (i * lineHeightPx);
|
|
457
|
-
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
458
|
-
...textOptions,
|
|
459
|
-
width: isJustify ? element.width : undefined,
|
|
460
|
-
stroke: false,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
/**
|
|
465
|
-
* Render text stroke using standard PDF stroke
|
|
466
|
-
*/
|
|
467
|
-
function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions) {
|
|
468
|
-
const isJustify = element.align === 'justify';
|
|
469
|
-
doc.save();
|
|
470
|
-
doc.lineWidth(element.strokeWidth);
|
|
471
|
-
doc.lineCap('round').lineJoin('round');
|
|
472
|
-
doc.strokeColor(parseColor(element.stroke).hex, element.opacity);
|
|
473
|
-
let cumulativeYOffset = 0;
|
|
474
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
475
|
-
const line = textLines[i];
|
|
476
|
-
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
477
|
-
const lineYOffset = yOffset + cumulativeYOffset;
|
|
478
|
-
const strippedLineText = stripHtml(line.text).result;
|
|
479
|
-
const heightOfLine = line.text === ''
|
|
480
|
-
? lineHeightPx
|
|
481
|
-
: doc.heightOfString(strippedLineText, textOptions);
|
|
482
|
-
cumulativeYOffset += heightOfLine;
|
|
483
|
-
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
484
|
-
...textOptions,
|
|
485
|
-
width: isJustify ? element.width : undefined,
|
|
486
|
-
height: heightOfLine,
|
|
487
|
-
stroke: true,
|
|
488
|
-
fill: false
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
doc.restore();
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Render text fill with rich text support (HTML segments)
|
|
495
|
-
*/
|
|
496
|
-
async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
|
|
497
|
-
if (!element.fill) {
|
|
498
|
-
return;
|
|
499
|
-
}
|
|
500
|
-
const baseParsedColor = parseColor(element.fill);
|
|
501
|
-
const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
502
|
-
doc.fillColor(baseParsedColor.hex, baseOpacity);
|
|
503
|
-
const isJustify = element.align === 'justify';
|
|
504
|
-
let cumulativeYOffset = 0;
|
|
505
|
-
for (let i = 0; i < textLines.length; i++) {
|
|
506
|
-
const line = textLines[i];
|
|
507
|
-
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
508
|
-
const lineYOffset = yOffset + cumulativeYOffset;
|
|
509
|
-
const strippedLineText = stripHtml(line.text).result;
|
|
510
|
-
const heightOfLine = line.text === ''
|
|
511
|
-
? lineHeightPx
|
|
512
|
-
: doc.heightOfString(strippedLineText, textOptions);
|
|
513
|
-
cumulativeYOffset += heightOfLine;
|
|
514
|
-
// Position cursor at line start
|
|
515
|
-
doc.text('', lineXOffset, lineYOffset, { height: 0, width: 0 });
|
|
516
|
-
// Parse line into styled segments
|
|
517
|
-
const segments = parseHTMLToSegments(line.text, element);
|
|
518
|
-
// Render each segment with its own styling
|
|
519
|
-
for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
|
|
520
|
-
const segment = segments[segmentIndex];
|
|
521
|
-
const isLastSegment = segmentIndex === segments.length - 1;
|
|
522
|
-
// Load appropriate font for this segment
|
|
523
|
-
await loadFontForSegment(doc, segment, element, fonts);
|
|
524
|
-
doc.fontSize(element.fontSize);
|
|
525
|
-
// Apply segment color
|
|
526
|
-
const segmentColor = segment.color
|
|
527
|
-
? parseColor(segment.color).hex
|
|
528
|
-
: parseColor(element.fill).hex;
|
|
529
|
-
const segmentParsedColor = segment.color
|
|
530
|
-
? parseColor(segment.color)
|
|
531
|
-
: parseColor(element.fill);
|
|
532
|
-
const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
|
|
533
|
-
doc.fillColor(segmentColor, segmentOpacity);
|
|
534
|
-
// Render segment text
|
|
535
|
-
doc.text(segment.text, {
|
|
536
|
-
...textOptions,
|
|
537
|
-
width: isJustify ? element.width : undefined,
|
|
538
|
-
height: heightOfLine,
|
|
539
|
-
continued: !isLastSegment,
|
|
540
|
-
underline: segment.underline || textOptions.underline || false,
|
|
541
|
-
lineBreak: !!segment.underline, // Workaround for pdfkit bug
|
|
542
|
-
stroke: false,
|
|
543
|
-
fill: true
|
|
544
|
-
});
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
/**
|
|
549
|
-
* Main text rendering function
|
|
550
|
-
*/
|
|
551
|
-
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
552
|
-
doc.fontSize(element.fontSize);
|
|
553
|
-
const hasStroke = element.strokeWidth > 0;
|
|
554
|
-
const isPDFX1a = attrs.pdfx1a;
|
|
555
|
-
// Calculate text metrics and line positioning
|
|
556
|
-
const metrics = calculateTextMetrics(doc, element);
|
|
557
|
-
const verticalAlignmentOffset = calculateVerticalAlignment(doc, element, metrics.textOptions);
|
|
558
|
-
// Fit text to element height if needed
|
|
559
|
-
fitTextToHeight(doc, element, metrics.textOptions);
|
|
560
|
-
// Calculate final vertical offset
|
|
561
|
-
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
562
|
-
// Render background if enabled
|
|
563
|
-
renderTextBackground(doc, element, verticalAlignmentOffset, metrics.textOptions);
|
|
564
|
-
// Render text based on stroke and PDF/X-1a requirements
|
|
565
|
-
if (hasStroke && isPDFX1a) {
|
|
566
|
-
// PDF/X-1a mode: simulate stroke with offset fills
|
|
567
|
-
renderPDFX1aStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
568
|
-
}
|
|
569
|
-
else {
|
|
570
|
-
// Standard rendering: stroke first, then fill
|
|
571
|
-
if (hasStroke) {
|
|
572
|
-
renderStandardStroke(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
573
|
-
}
|
|
574
|
-
await renderTextFill(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
575
|
-
}
|
|
576
|
-
}
|
package/lib/utils.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import parseColor from 'parse-color';
|
|
2
|
-
export declare const DPI = 75;
|
|
3
|
-
export declare const PIXEL_RATIO = 2;
|
|
4
|
-
export declare function fetchWithTimeout(url: string, timeout?: number, retries?: number): Promise<any>;
|
|
5
|
-
export declare function pxToPt(px: number): number;
|
|
6
|
-
export interface ImageCache {
|
|
7
|
-
images: Map<string, any>;
|
|
8
|
-
buffers: Map<string, any>;
|
|
9
|
-
processedImages: Map<string, string>;
|
|
10
|
-
imageFiles: Map<string, string>;
|
|
11
|
-
tempDir: string | null;
|
|
12
|
-
}
|
|
13
|
-
export declare function loadImage(src: string, cache?: ImageCache | null): Promise<any>;
|
|
14
|
-
export declare function srcToBase64(src: string, cache?: ImageCache | null): Promise<string>;
|
|
15
|
-
export declare function srcToBuffer(src: string, cache?: ImageCache | null): Promise<Buffer>;
|
|
16
|
-
export { parseColor };
|