@polotno/pdf-export 0.1.34 → 0.1.35
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/image.d.ts +6 -0
- package/lib/image.js +117 -33
- package/lib/index.js +1 -1
- package/lib/text/fonts.d.ts +15 -0
- package/lib/text/fonts.js +67 -0
- package/lib/text/index.d.ts +8 -0
- package/lib/text/index.js +42 -0
- package/lib/text/layout.d.ts +22 -0
- package/lib/text/layout.js +522 -0
- package/lib/text/parser.d.ts +46 -0
- package/lib/text/parser.js +415 -0
- package/lib/text/render.d.ts +8 -0
- package/lib/text/render.js +236 -0
- package/lib/text/types.d.ts +91 -0
- package/lib/text/types.js +1 -0
- package/package.json +1 -1
package/lib/image.d.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface ImageElement {
|
|
|
21
21
|
blurEnabled: boolean;
|
|
22
22
|
blurRadius: number;
|
|
23
23
|
filters: Record<string, ShapeFilter>;
|
|
24
|
+
shadowEnabled?: boolean;
|
|
25
|
+
shadowBlur?: number;
|
|
26
|
+
shadowOffsetX?: number;
|
|
27
|
+
shadowOffsetY?: number;
|
|
28
|
+
shadowColor?: string;
|
|
29
|
+
shadowOpacity?: number;
|
|
24
30
|
}
|
|
25
31
|
type ShapeFilter = {
|
|
26
32
|
intensity: number;
|
package/lib/image.js
CHANGED
|
@@ -136,22 +136,74 @@ function applyBorder(doc, element) {
|
|
|
136
136
|
.stroke();
|
|
137
137
|
}
|
|
138
138
|
}
|
|
139
|
+
function saveToTempFile(buffer, key, cache) {
|
|
140
|
+
if (cache) {
|
|
141
|
+
if (!cache.imageFiles) {
|
|
142
|
+
cache.imageFiles = new Map();
|
|
143
|
+
}
|
|
144
|
+
if (!cache.tempDir) {
|
|
145
|
+
cache.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfkit-images-'));
|
|
146
|
+
}
|
|
147
|
+
// Create a unique filename based on cache key hash
|
|
148
|
+
const hash = crypto.createHash('md5').update(key).digest('hex');
|
|
149
|
+
const filePath = path.join(cache.tempDir, `${hash}.png`);
|
|
150
|
+
// Write buffer to file
|
|
151
|
+
fs.writeFileSync(filePath, buffer);
|
|
152
|
+
cache.imageFiles.set(key, filePath);
|
|
153
|
+
return filePath;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
return buffer;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
async function getShadowImage(src, element, cache) {
|
|
160
|
+
const image = await loadImage(src, cache);
|
|
161
|
+
const { shadowBlur, shadowColor, shadowOpacity } = element;
|
|
162
|
+
// Shadow blur in Konva is standard. In Canvas it is standard.
|
|
163
|
+
// We need to scale it by PIXEL_RATIO as our canvas is scaled.
|
|
164
|
+
const ratio = PIXEL_RATIO;
|
|
165
|
+
const blur = (shadowBlur || 0) * ratio;
|
|
166
|
+
// Padding.
|
|
167
|
+
const padding = blur * 4 + 20; // Sufficient padding
|
|
168
|
+
const width = image.width + padding * 2;
|
|
169
|
+
const height = image.height + padding * 2;
|
|
170
|
+
const canvas = Canvas.createCanvas(width, height);
|
|
171
|
+
const ctx = canvas.getContext('2d');
|
|
172
|
+
// Parse color
|
|
173
|
+
const parsed = parseColor(shadowColor || 'black');
|
|
174
|
+
const opacity = shadowOpacity !== undefined ? shadowOpacity : 1;
|
|
175
|
+
const r = parsed.rgb[0];
|
|
176
|
+
const g = parsed.rgb[1];
|
|
177
|
+
const b = parsed.rgb[2];
|
|
178
|
+
const a = opacity;
|
|
179
|
+
const colorString = `rgba(${r},${g},${b},${a})`;
|
|
180
|
+
ctx.shadowColor = colorString;
|
|
181
|
+
ctx.shadowBlur = blur;
|
|
182
|
+
// We want the shadow to appear at (padding, padding) relative to canvas.
|
|
183
|
+
// We draw the image at (padding - OFFSET, padding - OFFSET)
|
|
184
|
+
// And set shadowOffset to (OFFSET, OFFSET).
|
|
185
|
+
// OFFSET should be large enough to move image out of view.
|
|
186
|
+
const OFFSET = 10000;
|
|
187
|
+
ctx.shadowOffsetX = OFFSET;
|
|
188
|
+
ctx.shadowOffsetY = OFFSET;
|
|
189
|
+
// Draw image
|
|
190
|
+
ctx.drawImage(image, padding - OFFSET, padding - OFFSET, image.width, image.height);
|
|
191
|
+
return {
|
|
192
|
+
src: canvas.toDataURL('image/png'),
|
|
193
|
+
padding: padding / ratio, // return padding in original units (points)
|
|
194
|
+
width: width / ratio,
|
|
195
|
+
height: height / ratio,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
139
198
|
export async function renderImage(doc, element, cache = null) {
|
|
140
199
|
// Check if we have a cached processed version
|
|
141
200
|
const cacheKey = getProcessedImageKey(element);
|
|
142
|
-
// Check if we have a cached file path for this image
|
|
143
|
-
if (cache && cache.imageFiles && cache.imageFiles.has(cacheKey)) {
|
|
144
|
-
const filePath = cache.imageFiles.get(cacheKey);
|
|
145
|
-
console.log('✓ Using cached image file:', path.basename(filePath));
|
|
146
|
-
doc.image(filePath, 0, 0, {
|
|
147
|
-
width: element.width,
|
|
148
|
-
height: element.height,
|
|
149
|
-
});
|
|
150
|
-
applyBorder(doc, element);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
201
|
let src = null;
|
|
154
|
-
|
|
202
|
+
const hasCachedFile = cache && cache.imageFiles && cache.imageFiles.has(cacheKey);
|
|
203
|
+
if (hasCachedFile) {
|
|
204
|
+
src = cache.imageFiles.get(cacheKey);
|
|
205
|
+
}
|
|
206
|
+
else if (cache && cache.processedImages.has(cacheKey)) {
|
|
155
207
|
src = cache.processedImages.get(cacheKey);
|
|
156
208
|
}
|
|
157
209
|
else {
|
|
@@ -165,31 +217,63 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
165
217
|
cache.processedImages.set(cacheKey, src);
|
|
166
218
|
}
|
|
167
219
|
}
|
|
168
|
-
if (src) {
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
220
|
+
if (element.shadowEnabled && src) {
|
|
221
|
+
const shadowKey = cacheKey +
|
|
222
|
+
'_shadow_' +
|
|
223
|
+
JSON.stringify({
|
|
224
|
+
blur: element.shadowBlur,
|
|
225
|
+
color: element.shadowColor,
|
|
226
|
+
opacity: element.shadowOpacity,
|
|
227
|
+
});
|
|
228
|
+
let shadowPadding = 0;
|
|
229
|
+
let shadowW = 0;
|
|
230
|
+
let shadowH = 0;
|
|
231
|
+
if (cache && cache.imageFiles && cache.imageFiles.has(shadowKey)) {
|
|
232
|
+
const filePath = cache.imageFiles.get(shadowKey);
|
|
233
|
+
// Recalculate padding/dimensions as we don't cache them
|
|
234
|
+
const ratio = PIXEL_RATIO;
|
|
235
|
+
const blur = (element.shadowBlur || 0) * ratio;
|
|
236
|
+
shadowPadding = (blur * 4 + 20) / ratio;
|
|
237
|
+
shadowW = element.width + shadowPadding * 2;
|
|
238
|
+
shadowH = element.height + shadowPadding * 2;
|
|
239
|
+
console.log('✓ Using cached shadow file:', path.basename(filePath));
|
|
240
|
+
doc.image(filePath, (element.shadowOffsetX || 0) - shadowPadding, (element.shadowOffsetY || 0) - shadowPadding, {
|
|
241
|
+
width: shadowW,
|
|
242
|
+
height: shadowH,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
const shadowResult = await getShadowImage(src, element, cache);
|
|
247
|
+
const shadowSrc = shadowResult.src;
|
|
248
|
+
shadowPadding = shadowResult.padding;
|
|
249
|
+
shadowW = shadowResult.width;
|
|
250
|
+
shadowH = shadowResult.height;
|
|
251
|
+
if (shadowSrc) {
|
|
252
|
+
const buffer = await srcToBuffer(shadowSrc, cache);
|
|
253
|
+
const filePath = saveToTempFile(buffer, shadowKey, cache);
|
|
254
|
+
doc.image(filePath, (element.shadowOffsetX || 0) - shadowPadding, (element.shadowOffsetY || 0) - shadowPadding, {
|
|
255
|
+
width: shadowW,
|
|
256
|
+
height: shadowH,
|
|
257
|
+
});
|
|
178
258
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (src) {
|
|
262
|
+
if (hasCachedFile) {
|
|
263
|
+
console.log('✓ Using cached image file:', path.basename(src));
|
|
264
|
+
doc.image(src, 0, 0, {
|
|
265
|
+
width: element.width,
|
|
266
|
+
height: element.height,
|
|
267
|
+
});
|
|
185
268
|
}
|
|
186
269
|
else {
|
|
187
|
-
|
|
270
|
+
const buffer = await srcToBuffer(src, cache);
|
|
271
|
+
const filePath = saveToTempFile(buffer, cacheKey, cache);
|
|
272
|
+
doc.image(filePath, 0, 0, {
|
|
273
|
+
width: element.width,
|
|
274
|
+
height: element.height,
|
|
275
|
+
});
|
|
188
276
|
}
|
|
189
|
-
doc.image(filePath, 0, 0, {
|
|
190
|
-
width: element.width,
|
|
191
|
-
height: element.height,
|
|
192
|
-
});
|
|
193
277
|
applyBorder(doc, element);
|
|
194
278
|
}
|
|
195
279
|
}
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from 'fs';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { srcToBuffer, parseColor } from './utils.js';
|
|
5
5
|
import { renderImage } from './image.js';
|
|
6
|
-
import { loadFontIfNeeded, renderText } from './text.js';
|
|
6
|
+
import { loadFontIfNeeded, renderText } from './text/index.js';
|
|
7
7
|
import { renderFigure } from './figure.js';
|
|
8
8
|
import { renderGroup } from './group.js';
|
|
9
9
|
import { lineToPDF } from './line.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { TextElement, TextSegment } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Get font weight string based on bold/italic state
|
|
4
|
+
*/
|
|
5
|
+
export declare function getFontWeight(bold: boolean, italic: boolean, baseFontWeight?: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Get font key for caching
|
|
8
|
+
*/
|
|
9
|
+
export declare function getFontKey(fontFamily: string, bold: boolean, italic: boolean, baseFontWeight?: string): string;
|
|
10
|
+
export declare function getGoogleFontPath(fontFamily: string, fontWeight?: string, italic?: boolean): Promise<string>;
|
|
11
|
+
/**
|
|
12
|
+
* Load font for a text element or segment
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadFontForSegment(doc: any, segment: TextSegment | null, element: TextElement, fonts: Record<string, boolean>): Promise<string>;
|
|
15
|
+
export declare function loadFontIfNeeded(doc: any, element: TextElement, fonts: Record<string, boolean>): Promise<string>;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { srcToBuffer } from '../utils.js';
|
|
2
|
+
import getUrls from 'get-urls';
|
|
3
|
+
import fetch from 'node-fetch';
|
|
4
|
+
/**
|
|
5
|
+
* Get font weight string based on bold/italic state
|
|
6
|
+
*/
|
|
7
|
+
export function getFontWeight(bold, italic, baseFontWeight) {
|
|
8
|
+
if (bold) {
|
|
9
|
+
return 'bold';
|
|
10
|
+
}
|
|
11
|
+
return baseFontWeight || 'normal';
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Get font key for caching
|
|
15
|
+
*/
|
|
16
|
+
export function getFontKey(fontFamily, bold, italic, baseFontWeight) {
|
|
17
|
+
const weight = getFontWeight(bold, italic, baseFontWeight);
|
|
18
|
+
const style = italic ? 'italic' : 'normal';
|
|
19
|
+
return `${fontFamily}-${weight}-${style}`;
|
|
20
|
+
}
|
|
21
|
+
export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
|
|
22
|
+
const weight = fontWeight === 'bold' ? '700' : '400';
|
|
23
|
+
const italicParam = italic ? 'italic' : '';
|
|
24
|
+
const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
|
|
25
|
+
const req = await fetch(url);
|
|
26
|
+
if (!req.ok) {
|
|
27
|
+
if (weight !== '400' || italic) {
|
|
28
|
+
// Fallback: try normal weight without italic
|
|
29
|
+
return getGoogleFontPath(fontFamily, 'normal', false);
|
|
30
|
+
}
|
|
31
|
+
throw new Error(`Failed to fetch Google font: ${fontFamily}`);
|
|
32
|
+
}
|
|
33
|
+
const text = await req.text();
|
|
34
|
+
const urls = getUrls(text);
|
|
35
|
+
return urls.values().next().value;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Load font for a text element or segment
|
|
39
|
+
*/
|
|
40
|
+
export async function loadFontForSegment(doc, segment, element, fonts) {
|
|
41
|
+
const fontFamily = element.fontFamily;
|
|
42
|
+
// Determine bold/italic from segment or element
|
|
43
|
+
const bold = segment
|
|
44
|
+
? segment.bold || element.fontWeight == 'bold' || false
|
|
45
|
+
: element.fontWeight == 'bold';
|
|
46
|
+
const italic = segment
|
|
47
|
+
? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
|
|
48
|
+
: element.fontStyle?.indexOf('italic') >= 0 || false;
|
|
49
|
+
// Check if universal font is already defined
|
|
50
|
+
if (fonts[fontFamily]) {
|
|
51
|
+
doc.font(fontFamily);
|
|
52
|
+
return fontFamily;
|
|
53
|
+
}
|
|
54
|
+
const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
|
|
55
|
+
if (!fonts[fontKey]) {
|
|
56
|
+
const weight = getFontWeight(bold, italic, element.fontWeight);
|
|
57
|
+
const src = await getGoogleFontPath(fontFamily, weight, italic);
|
|
58
|
+
doc.registerFont(fontKey, await srcToBuffer(src));
|
|
59
|
+
fonts[fontKey] = true;
|
|
60
|
+
}
|
|
61
|
+
doc.font(fontKey);
|
|
62
|
+
return fontKey;
|
|
63
|
+
}
|
|
64
|
+
// Alias for backward compatibility
|
|
65
|
+
export async function loadFontIfNeeded(doc, element, fonts) {
|
|
66
|
+
return loadFontForSegment(doc, null, element, fonts);
|
|
67
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { TextElement, RenderAttrs } from './types.js';
|
|
2
|
+
export * from './types.js';
|
|
3
|
+
export { loadFontIfNeeded } from './fonts.js';
|
|
4
|
+
export { normalizeRichText, parseHTMLToSegments } from './parser.js';
|
|
5
|
+
/**
|
|
6
|
+
* Main text rendering function
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderText(doc: PDFKit.PDFDocument, element: TextElement, fonts: Record<string, boolean>, attrs?: RenderAttrs): Promise<void>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { normalizeRichText } from './parser.js';
|
|
2
|
+
import { calculateTextMetrics, calculateVerticalAlignment, fitTextToHeight, } from './layout.js';
|
|
3
|
+
import { renderTextBackground, renderPDFX1aStroke, renderStandardStroke, renderTextFill, } from './render.js';
|
|
4
|
+
export * from './types.js';
|
|
5
|
+
export { loadFontIfNeeded } from './fonts.js';
|
|
6
|
+
export { normalizeRichText, parseHTMLToSegments } from './parser.js';
|
|
7
|
+
/**
|
|
8
|
+
* Main text rendering function
|
|
9
|
+
*/
|
|
10
|
+
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
11
|
+
let elementToRender = element;
|
|
12
|
+
if (typeof element.text === 'string') {
|
|
13
|
+
const normalizedText = normalizeRichText(element.text);
|
|
14
|
+
if (normalizedText !== element.text) {
|
|
15
|
+
elementToRender = { ...element, text: normalizedText };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
doc.fontSize(elementToRender.fontSize);
|
|
19
|
+
const hasStroke = elementToRender.strokeWidth > 0;
|
|
20
|
+
const isPDFX1a = attrs.pdfx1a;
|
|
21
|
+
// Calculate text metrics and line positioning
|
|
22
|
+
const metrics = calculateTextMetrics(doc, elementToRender);
|
|
23
|
+
const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
|
|
24
|
+
// Fit text to element height if needed
|
|
25
|
+
fitTextToHeight(doc, elementToRender, metrics.textOptions);
|
|
26
|
+
// Calculate final vertical offset
|
|
27
|
+
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
28
|
+
// Render background if enabled
|
|
29
|
+
renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
|
|
30
|
+
// Render text based on stroke and PDF/X-1a requirements
|
|
31
|
+
if (hasStroke && isPDFX1a) {
|
|
32
|
+
// PDF/X-1a mode: simulate stroke with offset fills
|
|
33
|
+
await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Standard rendering: stroke first, then fill
|
|
37
|
+
if (hasStroke) {
|
|
38
|
+
await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
39
|
+
}
|
|
40
|
+
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { TextElement, TextLine, TextMetrics, RenderSegment, TabExpansionSegment } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Expand tabs in text with word spacing adjustment for accurate visual alignment.
|
|
4
|
+
*/
|
|
5
|
+
export declare function expandTabsWithWordSpacing(text: string, doc: PDFKit.PDFDocument, textOptions: PDFKit.Mixins.TextOptions, tabSizeInSpaces?: number, currentWidth?: number): {
|
|
6
|
+
segments: TabExpansionSegment[];
|
|
7
|
+
finalWidth: number;
|
|
8
|
+
};
|
|
9
|
+
export declare function buildRenderSegmentsForLine(doc: PDFKit.PDFDocument, element: TextElement, lineText: string, textOptions: PDFKit.Mixins.TextOptions, fonts: Record<string, boolean>): Promise<RenderSegment[]>;
|
|
10
|
+
export declare function splitTextIntoLines(doc: PDFKit.PDFDocument, element: TextElement, props: PDFKit.Mixins.TextOptions): TextLine[];
|
|
11
|
+
export declare function calculateTextMetrics(doc: PDFKit.PDFDocument, element: TextElement): TextMetrics;
|
|
12
|
+
export declare function calculateVerticalAlignment(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): number;
|
|
13
|
+
export declare function fitTextToHeight(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): void;
|
|
14
|
+
/**
|
|
15
|
+
* Calculate X offset for list markers (not used for text content positioning)
|
|
16
|
+
*/
|
|
17
|
+
export declare function calculateLineXOffset(element: TextElement, line: TextLine): number;
|
|
18
|
+
export declare function calculateTextContentXOffset(element: TextElement, line: TextLine): number;
|
|
19
|
+
/**
|
|
20
|
+
* Calculate effective width for text rendering, considering justify and underline constraints
|
|
21
|
+
*/
|
|
22
|
+
export declare function calculateEffectiveWidth(element: TextElement, line: TextLine, widthOption: number | undefined, hasUnderline: boolean): number | undefined;
|