@polotno/pdf-export 0.1.21 → 0.1.23
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/browser-entry.d.ts +7 -0
- package/lib/browser-entry.js +11 -0
- package/lib/core/index.d.ts +26 -0
- package/lib/core/index.js +87 -0
- package/lib/filters.d.ts +2 -0
- package/lib/filters.js +163 -0
- package/lib/image.d.ts +11 -0
- package/lib/image.js +49 -4
- package/lib/platform/adapter.d.ts +37 -0
- package/lib/platform/adapter.js +13 -0
- package/lib/platform/browser-polyfill.d.ts +1 -0
- package/lib/platform/browser-polyfill.js +5 -0
- package/lib/platform/browser.d.ts +7 -0
- package/lib/platform/browser.js +145 -0
- package/lib/platform/node.d.ts +7 -0
- package/lib/platform/node.js +142 -0
- package/lib/text.d.ts +6 -0
- package/lib/text.js +63 -31
- package/package.json +3 -2
- package/patches/pdfkit+0.17.2.patch +19 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PolotnoJSON, RenderAttrs } from './core/index.js';
|
|
2
|
+
export type { PolotnoJSON, RenderAttrs } from './core/index.js';
|
|
3
|
+
export interface BrowserExportOptions {
|
|
4
|
+
fileName?: string;
|
|
5
|
+
attrs?: RenderAttrs;
|
|
6
|
+
}
|
|
7
|
+
export declare function jsonToPDF(json: PolotnoJSON, options?: BrowserExportOptions): Promise<Blob>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { setPlatformAdapter, hasPlatformAdapter } from './platform/adapter.js';
|
|
2
|
+
import { createBrowserAdapter } from './platform/browser.js';
|
|
3
|
+
import { jsonToPDF as coreJsonToPDF } from './core/index.js';
|
|
4
|
+
if (!hasPlatformAdapter()) {
|
|
5
|
+
setPlatformAdapter(createBrowserAdapter());
|
|
6
|
+
}
|
|
7
|
+
export async function jsonToPDF(json, options = {}) {
|
|
8
|
+
const { fileName, attrs } = options;
|
|
9
|
+
const result = await coreJsonToPDF(json, fileName, attrs ?? {});
|
|
10
|
+
return result;
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { SpotColorConfig } from '../spot-colors.js';
|
|
2
|
+
export interface PolotnoJSON {
|
|
3
|
+
width: number;
|
|
4
|
+
height: number;
|
|
5
|
+
fonts: Array<{
|
|
6
|
+
fontFamily: string;
|
|
7
|
+
url: string;
|
|
8
|
+
}>;
|
|
9
|
+
pages: Array<{
|
|
10
|
+
background?: string;
|
|
11
|
+
children: any[];
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
export interface RenderAttrs {
|
|
15
|
+
pdfx1a?: boolean;
|
|
16
|
+
validate?: boolean;
|
|
17
|
+
metadata?: {
|
|
18
|
+
title?: string;
|
|
19
|
+
author?: string;
|
|
20
|
+
application?: string;
|
|
21
|
+
producer?: string;
|
|
22
|
+
};
|
|
23
|
+
spotColors?: SpotColorConfig;
|
|
24
|
+
textVerticalResizeEnabled?: boolean;
|
|
25
|
+
}
|
|
26
|
+
export declare function jsonToPDF(json: PolotnoJSON, pdfFileName?: string, attrs?: RenderAttrs): Promise<Blob | Uint8Array | undefined>;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { srcToBuffer, parseColor } from '../utils.js';
|
|
2
|
+
import { renderImage } from '../image.js';
|
|
3
|
+
import { loadFontIfNeeded, renderText } from '../text.js';
|
|
4
|
+
import { renderFigure } from '../figure.js';
|
|
5
|
+
import { renderGroup } from '../group.js';
|
|
6
|
+
import { lineToPDF } from '../line.js';
|
|
7
|
+
import { renderSVG } from '../svg-render.js';
|
|
8
|
+
import { enableSpotColorSupport } from '../spot-colors.js';
|
|
9
|
+
import { getPlatformAdapter } from '../platform/adapter.js';
|
|
10
|
+
async function renderElement({ doc, element, fonts, attrs, cache, }) {
|
|
11
|
+
if ((element.visible !== undefined && !element.visible) ||
|
|
12
|
+
(element.showInExport !== undefined && !element.showInExport)) {
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
doc.save();
|
|
16
|
+
if (element.type !== 'group') {
|
|
17
|
+
doc.translate(element.x, element.y);
|
|
18
|
+
doc.rotate(element.rotation);
|
|
19
|
+
}
|
|
20
|
+
if (element.opacity !== undefined) {
|
|
21
|
+
doc.opacity(element.opacity);
|
|
22
|
+
}
|
|
23
|
+
if (element.type === 'group') {
|
|
24
|
+
await renderGroup(doc, element, renderElement, fonts, attrs, cache);
|
|
25
|
+
}
|
|
26
|
+
else if (element.type === 'text') {
|
|
27
|
+
await loadFontIfNeeded(doc, element, fonts);
|
|
28
|
+
await renderText(doc, element, fonts, attrs);
|
|
29
|
+
}
|
|
30
|
+
else if (element.type === 'line') {
|
|
31
|
+
lineToPDF(doc, element);
|
|
32
|
+
}
|
|
33
|
+
else if (element.type === 'image') {
|
|
34
|
+
await renderImage(doc, element, cache);
|
|
35
|
+
}
|
|
36
|
+
else if (element.type === 'svg') {
|
|
37
|
+
await renderSVG(doc, element, cache);
|
|
38
|
+
}
|
|
39
|
+
else if (element.type === 'figure') {
|
|
40
|
+
renderFigure(doc, element);
|
|
41
|
+
}
|
|
42
|
+
doc.restore();
|
|
43
|
+
}
|
|
44
|
+
export async function jsonToPDF(json, pdfFileName, attrs = {}) {
|
|
45
|
+
const fonts = {};
|
|
46
|
+
const cache = {
|
|
47
|
+
images: new Map(),
|
|
48
|
+
buffers: new Map(),
|
|
49
|
+
processedImages: new Map(),
|
|
50
|
+
};
|
|
51
|
+
const adapter = getPlatformAdapter();
|
|
52
|
+
const { doc, finalize } = adapter.createPDFContext({
|
|
53
|
+
size: [json.width, json.height],
|
|
54
|
+
autoFirstPage: false,
|
|
55
|
+
});
|
|
56
|
+
if (attrs.spotColors) {
|
|
57
|
+
enableSpotColorSupport(doc, attrs.spotColors);
|
|
58
|
+
}
|
|
59
|
+
for (const font of json.fonts) {
|
|
60
|
+
await adapter.registerFont(doc, font.fontFamily, await srcToBuffer(font.url, cache));
|
|
61
|
+
fonts[font.fontFamily] = true;
|
|
62
|
+
}
|
|
63
|
+
for (const page of json.pages) {
|
|
64
|
+
doc.addPage();
|
|
65
|
+
if (page.background) {
|
|
66
|
+
const isURL = page.background.indexOf('http') >= 0 ||
|
|
67
|
+
page.background.indexOf('.png') >= 0 ||
|
|
68
|
+
page.background.indexOf('.jpg') >= 0;
|
|
69
|
+
if (isURL) {
|
|
70
|
+
const mime = cache.buffers.get(`mime:${page.background}`);
|
|
71
|
+
await adapter.embedImage(doc, await srcToBuffer(page.background, cache), 0, 0, {
|
|
72
|
+
cover: [json.width, json.height],
|
|
73
|
+
align: 'center',
|
|
74
|
+
}, mime);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
doc.rect(0, 0, json.width, json.height);
|
|
78
|
+
doc.fill(parseColor(page.background).hex);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const element of page.children) {
|
|
82
|
+
await renderElement({ doc, element, fonts, attrs, cache });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
doc.end();
|
|
86
|
+
return finalize({ fileName: pdfFileName, attrs });
|
|
87
|
+
}
|
package/lib/filters.d.ts
ADDED
package/lib/filters.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
export const elementFilterToKonva = {
|
|
2
|
+
warm: (intensity) => (imageData) => {
|
|
3
|
+
const data = imageData.data; // Access pixel data
|
|
4
|
+
// Intensity should be in 0-1 range from the model
|
|
5
|
+
intensity = Math.max(0, Math.min(1, intensity));
|
|
6
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
7
|
+
// Increase red and green values for a warm effect
|
|
8
|
+
data[i] = Math.min(data[i] + 30 * intensity, 255); // Red channel
|
|
9
|
+
data[i + 1] = Math.min(data[i + 1] + 15 * intensity, 255); // Green channel
|
|
10
|
+
// Leave blue unchanged for a warm tone
|
|
11
|
+
}
|
|
12
|
+
return imageData;
|
|
13
|
+
},
|
|
14
|
+
cold: (intensity) => (imageData) => {
|
|
15
|
+
const data = imageData.data; // Access pixel data
|
|
16
|
+
// Intensity should be in 0-1 range from the model
|
|
17
|
+
intensity = Math.max(0, Math.min(1, intensity));
|
|
18
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
19
|
+
// Adjust red, green, and blue channels
|
|
20
|
+
data[i] = Math.min(data[i] - 15 * intensity, 255); // Reduce red
|
|
21
|
+
data[i + 1] = Math.min(data[i + 1] - 10 * intensity, 255); // Slightly reduce green
|
|
22
|
+
data[i + 2] = Math.min(data[i + 2] + 15 * intensity, 255); // Boost blue
|
|
23
|
+
}
|
|
24
|
+
return imageData;
|
|
25
|
+
},
|
|
26
|
+
natural: (intensity) => (imageData) => {
|
|
27
|
+
const data = imageData.data; // Access pixel data
|
|
28
|
+
// Intensity should be in 0-1 range from the model
|
|
29
|
+
intensity = Math.max(0, Math.min(1, intensity));
|
|
30
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
31
|
+
// Adjust each channel (R, G, B)
|
|
32
|
+
data[i] = Math.min(data[i] * (1 + 0.1 * intensity), 255); // Enhance red
|
|
33
|
+
data[i + 1] = Math.min(data[i + 1] * (1 + 0.1 * intensity), 255); // Enhance green
|
|
34
|
+
data[i + 2] = Math.min(data[i + 2] * (1 + 0.1 * intensity), 255); // Enhance blue
|
|
35
|
+
// Increase contrast based on intensity
|
|
36
|
+
const average = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
37
|
+
data[i] = Math.min((data[i] - average) * (1 + 0.2 * intensity) + average, 255);
|
|
38
|
+
data[i + 1] = Math.min((data[i + 1] - average) * (1 + 0.2 * intensity) + average, 255);
|
|
39
|
+
data[i + 2] = Math.min((data[i + 2] - average) * (1 + 0.2 * intensity) + average, 255);
|
|
40
|
+
}
|
|
41
|
+
return imageData;
|
|
42
|
+
},
|
|
43
|
+
temperature: (intensity) => (imageData) => {
|
|
44
|
+
const data = imageData.data;
|
|
45
|
+
// Intensity should be in -1 to 1 range from the model
|
|
46
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
47
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
48
|
+
const r = data[i];
|
|
49
|
+
const g = data[i + 1];
|
|
50
|
+
const b = data[i + 2];
|
|
51
|
+
// Adjust red and blue channels based on temperature
|
|
52
|
+
// Warmer = more red, less blue
|
|
53
|
+
// Colder = more blue, less red
|
|
54
|
+
data[i] = Math.min(Math.max(r + 15 * intensity, 0), 255); // Red channel
|
|
55
|
+
data[i + 2] = Math.min(Math.max(b - 15 * intensity, 0), 255); // Blue channel
|
|
56
|
+
// Optionally: slight tweak to green for balance (optional)
|
|
57
|
+
// data[i + 1] = Math.min(Math.max(g + 10 * intensity, 0), 255);
|
|
58
|
+
}
|
|
59
|
+
return imageData;
|
|
60
|
+
},
|
|
61
|
+
contrast: (intensity) => (imageData) => {
|
|
62
|
+
const data = imageData.data;
|
|
63
|
+
// Intensity should be in -1 to 1 range from the model
|
|
64
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
65
|
+
// Convert intensity to a contrast factor
|
|
66
|
+
// Scale from -1,1 to -100,100 for the formula
|
|
67
|
+
const scaledIntensity = intensity * 100;
|
|
68
|
+
const factor = (259 * (scaledIntensity + 255)) / (255 * (259 - scaledIntensity));
|
|
69
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
70
|
+
// Apply contrast formula to R, G, B channels
|
|
71
|
+
data[i] = Math.min(Math.max(factor * (data[i] - 128) + 128, 0), 255); // Red
|
|
72
|
+
data[i + 1] = Math.min(Math.max(factor * (data[i + 1] - 128) + 128, 0), 255); // Green
|
|
73
|
+
data[i + 2] = Math.min(Math.max(factor * (data[i + 2] - 128) + 128, 0), 255); // Blue
|
|
74
|
+
}
|
|
75
|
+
return imageData;
|
|
76
|
+
},
|
|
77
|
+
shadows: (intensity) => (imageData) => {
|
|
78
|
+
const data = imageData.data;
|
|
79
|
+
// Intensity should be in -1 to 1 range from the model
|
|
80
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
81
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
82
|
+
// Compute brightness as average of R, G, B
|
|
83
|
+
const brightness = (data[i] + data[i + 1] + data[i + 2]) / 3;
|
|
84
|
+
// Only affect pixels in shadow range (brightness < 128)
|
|
85
|
+
if (brightness < 128) {
|
|
86
|
+
const factor = 1 + intensity * (1 - brightness / 128) * 2; // More adjustment for darker pixels
|
|
87
|
+
data[i] = Math.min(Math.max(data[i] * factor, 0), 255); // Red
|
|
88
|
+
data[i + 1] = Math.min(Math.max(data[i + 1] * factor, 0), 255); // Green
|
|
89
|
+
data[i + 2] = Math.min(Math.max(data[i + 2] * factor, 0), 255); // Blue
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return imageData;
|
|
93
|
+
},
|
|
94
|
+
white: (intensity) => (imageData) => {
|
|
95
|
+
const data = imageData.data;
|
|
96
|
+
// Intensity should be in -1 to 1 range from the model
|
|
97
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
98
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
99
|
+
const r = data[i];
|
|
100
|
+
const g = data[i + 1];
|
|
101
|
+
const b = data[i + 2];
|
|
102
|
+
const brightness = (r + g + b) / 3;
|
|
103
|
+
if (brightness > 128) {
|
|
104
|
+
const factor = 1 + intensity * ((brightness - 128) / 127); // stronger effect on brighter pixels
|
|
105
|
+
data[i] = Math.min(Math.max(r * factor, 0), 255);
|
|
106
|
+
data[i + 1] = Math.min(Math.max(g * factor, 0), 255);
|
|
107
|
+
data[i + 2] = Math.min(Math.max(b * factor, 0), 255);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return imageData;
|
|
111
|
+
},
|
|
112
|
+
black: (intensity) => (imageData) => {
|
|
113
|
+
const data = imageData.data;
|
|
114
|
+
// Intensity should be in -1 to 1 range from the model
|
|
115
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
116
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
117
|
+
const r = data[i];
|
|
118
|
+
const g = data[i + 1];
|
|
119
|
+
const b = data[i + 2];
|
|
120
|
+
const brightness = (r + g + b) / 3;
|
|
121
|
+
if (brightness < 128) {
|
|
122
|
+
const factor = 1 + intensity * ((128 - brightness) / 128); // stronger effect on darker pixels
|
|
123
|
+
data[i] = Math.min(Math.max(r * factor, 0), 255);
|
|
124
|
+
data[i + 1] = Math.min(Math.max(g * factor, 0), 255);
|
|
125
|
+
data[i + 2] = Math.min(Math.max(b * factor, 0), 255);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return imageData;
|
|
129
|
+
},
|
|
130
|
+
vibrance: (intensity) => (imageData) => {
|
|
131
|
+
const data = imageData.data;
|
|
132
|
+
// Intensity should be in -1 to 1 range from the model
|
|
133
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
134
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
135
|
+
const r = data[i];
|
|
136
|
+
const g = data[i + 1];
|
|
137
|
+
const b = data[i + 2];
|
|
138
|
+
const max = Math.max(r, g, b);
|
|
139
|
+
const avg = (r + g + b) / 3;
|
|
140
|
+
const saturation = max === 0 ? 0 : (max - avg) / max;
|
|
141
|
+
const factor = intensity < 0 ? (1 - saturation) * intensity * 1.5 : intensity * 0.5;
|
|
142
|
+
data[i] = Math.min(Math.max(r - (max - r) * factor, 0), 255);
|
|
143
|
+
data[i + 1] = Math.min(Math.max(g - (max - g) * factor, 0), 255);
|
|
144
|
+
data[i + 2] = Math.min(Math.max(b - (max - b) * factor, 0), 255);
|
|
145
|
+
}
|
|
146
|
+
return imageData;
|
|
147
|
+
},
|
|
148
|
+
saturation: (intensity) => (imageData) => {
|
|
149
|
+
const data = imageData.data;
|
|
150
|
+
// Intensity should be in -1 to 1 range from the model
|
|
151
|
+
intensity = Math.max(-1, Math.min(1, intensity));
|
|
152
|
+
for (let i = 0; i < data.length; i += 4) {
|
|
153
|
+
const r = data[i];
|
|
154
|
+
const g = data[i + 1];
|
|
155
|
+
const b = data[i + 2];
|
|
156
|
+
const gray = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
157
|
+
data[i] = Math.min(Math.max(gray + (r - gray) * (1 + intensity), 0), 255);
|
|
158
|
+
data[i + 1] = Math.min(Math.max(gray + (g - gray) * (1 + intensity), 0), 255);
|
|
159
|
+
data[i + 2] = Math.min(Math.max(gray + (b - gray) * (1 + intensity), 0), 255);
|
|
160
|
+
}
|
|
161
|
+
return imageData;
|
|
162
|
+
},
|
|
163
|
+
};
|
package/lib/image.d.ts
CHANGED
|
@@ -14,8 +14,19 @@ export interface ImageElement {
|
|
|
14
14
|
flipY?: boolean;
|
|
15
15
|
borderSize?: number;
|
|
16
16
|
borderColor?: string;
|
|
17
|
+
brightnessEnabled: boolean;
|
|
18
|
+
brightness: number;
|
|
19
|
+
grayscaleEnabled: boolean;
|
|
20
|
+
sepiaEnabled: boolean;
|
|
21
|
+
blurEnabled: boolean;
|
|
22
|
+
blurRadius: number;
|
|
23
|
+
filters: Record<string, ShapeFilter>;
|
|
17
24
|
}
|
|
25
|
+
type ShapeFilter = {
|
|
26
|
+
intensity: number;
|
|
27
|
+
};
|
|
18
28
|
export declare function getProcessedImageKey(element: ImageElement): string;
|
|
19
29
|
export declare function cropImage(src: string, element: ImageElement, cache?: ImageCache | null): Promise<string>;
|
|
20
30
|
export declare function clipImage(src: string, element: ImageElement, cache?: ImageCache | null): Promise<string>;
|
|
21
31
|
export declare function renderImage(doc: PDFKit.PDFDocument, element: ImageElement, cache?: ImageCache | null): Promise<void>;
|
|
32
|
+
export {};
|
package/lib/image.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import { loadImage, PIXEL_RATIO, srcToBuffer, parseColor } 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';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
|
+
import Konva from 'konva';
|
|
8
|
+
import { elementFilterToKonva } from './filters';
|
|
7
9
|
async function applyFlip(image, element) {
|
|
8
10
|
const { flipX, flipY } = element;
|
|
9
11
|
if (!flipX && !flipY) {
|
|
@@ -31,6 +33,13 @@ export function getProcessedImageKey(element) {
|
|
|
31
33
|
cropWidth: element.cropWidth,
|
|
32
34
|
cropHeight: element.cropHeight,
|
|
33
35
|
clipSrc: element.clipSrc,
|
|
36
|
+
brightnessEnabled: element.brightnessEnabled,
|
|
37
|
+
brightness: element.brightness,
|
|
38
|
+
grayscaleEnabled: element.grayscaleEnabled,
|
|
39
|
+
sepiaEnabled: element.sepiaEnabled,
|
|
40
|
+
blurEnabled: element.blurEnabled,
|
|
41
|
+
blurRadius: element.blurRadius,
|
|
42
|
+
filters: element.filters,
|
|
34
43
|
});
|
|
35
44
|
}
|
|
36
45
|
export async function cropImage(src, element, cache = null) {
|
|
@@ -79,6 +88,44 @@ export async function clipImage(src, element, cache = null) {
|
|
|
79
88
|
ctx.globalCompositeOperation = 'source-over';
|
|
80
89
|
return canvas.toDataURL('image/png');
|
|
81
90
|
}
|
|
91
|
+
async function applyFilter(src, element, cache = null) {
|
|
92
|
+
const image = await loadImage(src, cache);
|
|
93
|
+
const canvas = Canvas.createCanvas(element.width * PIXEL_RATIO, element.height * PIXEL_RATIO);
|
|
94
|
+
const ctx = canvas.getContext('2d');
|
|
95
|
+
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
96
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
97
|
+
const data = imageData.data;
|
|
98
|
+
if (element.brightnessEnabled) {
|
|
99
|
+
Konva.Filters.Brighten.bind({
|
|
100
|
+
brightness: () => element.brightness,
|
|
101
|
+
})({ data });
|
|
102
|
+
}
|
|
103
|
+
// grayscale
|
|
104
|
+
if (element.grayscaleEnabled) {
|
|
105
|
+
Konva.Filters.Grayscale.bind({})({ data });
|
|
106
|
+
}
|
|
107
|
+
// sepia
|
|
108
|
+
if (element.sepiaEnabled) {
|
|
109
|
+
Konva.Filters.Sepia.bind({})({ data });
|
|
110
|
+
}
|
|
111
|
+
// blur
|
|
112
|
+
if (element.blurEnabled) {
|
|
113
|
+
Konva.Filters.Blur.bind({
|
|
114
|
+
blurRadius: () => element.blurRadius,
|
|
115
|
+
})({ data });
|
|
116
|
+
}
|
|
117
|
+
// filters
|
|
118
|
+
if (element.filters) {
|
|
119
|
+
Object.entries(element.filters).forEach(([type, effect]) => {
|
|
120
|
+
const filter = elementFilterToKonva[type];
|
|
121
|
+
if (filter) {
|
|
122
|
+
filter(effect.intensity).bind({})({ data });
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
ctx.putImageData(imageData, 0, 0);
|
|
127
|
+
return canvas.toDataURL('image/png');
|
|
128
|
+
}
|
|
82
129
|
function applyBorder(doc, element) {
|
|
83
130
|
if (element.borderSize > 0) {
|
|
84
131
|
const borderColor = parseColor(element.borderColor).keyword || 'black';
|
|
@@ -99,8 +146,6 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
99
146
|
doc.image(filePath, 0, 0, {
|
|
100
147
|
width: element.width,
|
|
101
148
|
height: element.height,
|
|
102
|
-
// No opacity property in pdfkit
|
|
103
|
-
// opacity: element.opacity,
|
|
104
149
|
});
|
|
105
150
|
applyBorder(doc, element);
|
|
106
151
|
return;
|
|
@@ -114,6 +159,7 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
114
159
|
if (element.clipSrc) {
|
|
115
160
|
src = await clipImage(src, element, cache);
|
|
116
161
|
}
|
|
162
|
+
src = await applyFilter(src, element, cache);
|
|
117
163
|
// Cache the processed result
|
|
118
164
|
if (cache && src) {
|
|
119
165
|
cache.processedImages.set(cacheKey, src);
|
|
@@ -143,7 +189,6 @@ export async function renderImage(doc, element, cache = null) {
|
|
|
143
189
|
doc.image(filePath, 0, 0, {
|
|
144
190
|
width: element.width,
|
|
145
191
|
height: element.height,
|
|
146
|
-
// opacity: element.opacity,
|
|
147
192
|
});
|
|
148
193
|
applyBorder(doc, element);
|
|
149
194
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface FetchResponse {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
status: number;
|
|
4
|
+
statusText: string;
|
|
5
|
+
headers: Headers;
|
|
6
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
7
|
+
text(): Promise<string>;
|
|
8
|
+
json(): Promise<any>;
|
|
9
|
+
}
|
|
10
|
+
export interface CanvasLike {
|
|
11
|
+
width: number;
|
|
12
|
+
height: number;
|
|
13
|
+
getContext(type: '2d'): any;
|
|
14
|
+
toDataURL(type?: string, quality?: any): string;
|
|
15
|
+
}
|
|
16
|
+
export interface ImageLike {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
[key: string]: any;
|
|
20
|
+
}
|
|
21
|
+
export interface PDFContext<TOutput = unknown, TFinalizeOptions = unknown> {
|
|
22
|
+
doc: any;
|
|
23
|
+
finalize(options?: TFinalizeOptions): Promise<TOutput>;
|
|
24
|
+
}
|
|
25
|
+
export interface PlatformAdapter<TOutput = unknown, TFinalizeOptions = unknown> {
|
|
26
|
+
fetch(input: string, init?: RequestInit): Promise<FetchResponse>;
|
|
27
|
+
loadImage(src: string): Promise<ImageLike>;
|
|
28
|
+
createCanvas(width: number, height: number): CanvasLike;
|
|
29
|
+
createPDFContext(options: any): PDFContext<TOutput, TFinalizeOptions>;
|
|
30
|
+
registerFont(doc: any, name: string, data: Uint8Array): void | Promise<void>;
|
|
31
|
+
encodeBase64(data: Uint8Array): string;
|
|
32
|
+
decodeBase64(value: string): Uint8Array;
|
|
33
|
+
embedImage(doc: any, data: Uint8Array | string, x: number, y: number, options: any, mimeType?: string): void | Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export declare function setPlatformAdapter(adapter: PlatformAdapter): void;
|
|
36
|
+
export declare function hasPlatformAdapter(): boolean;
|
|
37
|
+
export declare function getPlatformAdapter(): PlatformAdapter;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
let currentAdapter = null;
|
|
2
|
+
export function setPlatformAdapter(adapter) {
|
|
3
|
+
currentAdapter = adapter;
|
|
4
|
+
}
|
|
5
|
+
export function hasPlatformAdapter() {
|
|
6
|
+
return currentAdapter !== null;
|
|
7
|
+
}
|
|
8
|
+
export function getPlatformAdapter() {
|
|
9
|
+
if (!currentAdapter) {
|
|
10
|
+
throw new Error('Platform adapter is not configured.');
|
|
11
|
+
}
|
|
12
|
+
return currentAdapter;
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import PDFDocument from 'pdfkit/js/pdfkit.standalone.js';
|
|
2
|
+
import SVGtoPDF from 'svg-to-pdfkit';
|
|
3
|
+
import { Buffer } from 'buffer';
|
|
4
|
+
function attachAddSVG(doc) {
|
|
5
|
+
const existing = doc.addSVG;
|
|
6
|
+
if (!existing) {
|
|
7
|
+
doc.addSVG = function (svg, x, y, options) {
|
|
8
|
+
SVGtoPDF(this, svg, x, y, options);
|
|
9
|
+
return this;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
function arrayBufferToBase64(buffer) {
|
|
14
|
+
let binary = '';
|
|
15
|
+
const chunkSize = 8192;
|
|
16
|
+
for (let i = 0; i < buffer.length; i += chunkSize) {
|
|
17
|
+
const chunk = buffer.subarray(i, i + chunkSize);
|
|
18
|
+
binary += String.fromCharCode(...chunk);
|
|
19
|
+
}
|
|
20
|
+
return btoa(binary);
|
|
21
|
+
}
|
|
22
|
+
function base64ToUint8Array(value) {
|
|
23
|
+
const binary = atob(value);
|
|
24
|
+
const bytes = new Uint8Array(binary.length);
|
|
25
|
+
for (let i = 0; i < binary.length; i++) {
|
|
26
|
+
bytes[i] = binary.charCodeAt(i);
|
|
27
|
+
}
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
function loadImageElement(src) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const img = new Image();
|
|
33
|
+
if (!src.startsWith('data:')) {
|
|
34
|
+
img.crossOrigin = 'anonymous';
|
|
35
|
+
}
|
|
36
|
+
img.onload = () => resolve(img);
|
|
37
|
+
img.onerror = (error) => reject(error);
|
|
38
|
+
img.src = src;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
async function loadImageFromSource(src) {
|
|
42
|
+
if (src.startsWith('data:')) {
|
|
43
|
+
return loadImageElement(src);
|
|
44
|
+
}
|
|
45
|
+
const fetchImpl = globalThis.fetch?.bind(globalThis);
|
|
46
|
+
if (!fetchImpl) {
|
|
47
|
+
throw new Error('Global fetch is not available in this environment.');
|
|
48
|
+
}
|
|
49
|
+
const response = await fetchImpl(src, { mode: 'cors' });
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
const blob = await response.blob();
|
|
54
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
55
|
+
try {
|
|
56
|
+
return await loadImageElement(objectUrl);
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
URL.revokeObjectURL(objectUrl);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function createBrowserAdapter() {
|
|
63
|
+
return {
|
|
64
|
+
async fetch(input, init) {
|
|
65
|
+
const fetchImpl = globalThis.fetch?.bind(globalThis);
|
|
66
|
+
if (!fetchImpl) {
|
|
67
|
+
throw new Error('Global fetch is not available in this environment.');
|
|
68
|
+
}
|
|
69
|
+
return (await fetchImpl(input, init));
|
|
70
|
+
},
|
|
71
|
+
async loadImage(src) {
|
|
72
|
+
return loadImageFromSource(src);
|
|
73
|
+
},
|
|
74
|
+
createCanvas(width, height) {
|
|
75
|
+
const canvas = document.createElement('canvas');
|
|
76
|
+
canvas.width = width;
|
|
77
|
+
canvas.height = height;
|
|
78
|
+
return canvas;
|
|
79
|
+
},
|
|
80
|
+
createPDFContext(options) {
|
|
81
|
+
const doc = new PDFDocument(options);
|
|
82
|
+
attachAddSVG(doc);
|
|
83
|
+
const blobParts = [];
|
|
84
|
+
const appendTypedArray = (view) => {
|
|
85
|
+
blobParts.push(view.slice());
|
|
86
|
+
};
|
|
87
|
+
doc.on('data', (chunk) => {
|
|
88
|
+
if (chunk instanceof Uint8Array) {
|
|
89
|
+
appendTypedArray(chunk);
|
|
90
|
+
}
|
|
91
|
+
else if (Array.isArray(chunk)) {
|
|
92
|
+
appendTypedArray(new Uint8Array(chunk));
|
|
93
|
+
}
|
|
94
|
+
else if (typeof chunk === 'string') {
|
|
95
|
+
const encoder = new TextEncoder();
|
|
96
|
+
appendTypedArray(encoder.encode(chunk));
|
|
97
|
+
}
|
|
98
|
+
else if (chunk?.buffer instanceof ArrayBuffer) {
|
|
99
|
+
appendTypedArray(new Uint8Array(chunk.buffer));
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
103
|
+
doc.on('end', () => {
|
|
104
|
+
try {
|
|
105
|
+
resolve(new Blob(blobParts, { type: 'application/pdf' }));
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
reject(error);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
doc.on('error', reject);
|
|
112
|
+
});
|
|
113
|
+
const finalize = async (options) => {
|
|
114
|
+
if (options?.attrs?.pdfx1a) {
|
|
115
|
+
console.warn('PDF/X-1a conversion is not supported in the browser runtime.');
|
|
116
|
+
}
|
|
117
|
+
return resultPromise;
|
|
118
|
+
};
|
|
119
|
+
return {
|
|
120
|
+
doc,
|
|
121
|
+
finalize,
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
registerFont(doc, name, data) {
|
|
125
|
+
const buffer = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
126
|
+
doc.registerFont(name, buffer);
|
|
127
|
+
},
|
|
128
|
+
encodeBase64(data) {
|
|
129
|
+
return arrayBufferToBase64(data);
|
|
130
|
+
},
|
|
131
|
+
decodeBase64(value) {
|
|
132
|
+
return base64ToUint8Array(value);
|
|
133
|
+
},
|
|
134
|
+
embedImage(doc, data, x, y, options, mimeType) {
|
|
135
|
+
if (typeof data === 'string') {
|
|
136
|
+
doc.image(data, x, y, options);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const mime = mimeType || 'image/png';
|
|
140
|
+
const base64 = arrayBufferToBase64(data);
|
|
141
|
+
const dataUrl = `data:${mime};base64,${base64}`;
|
|
142
|
+
doc.image(dataUrl, x, y, options);
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { Buffer } from 'buffer';
|
|
3
|
+
import { PassThrough } from 'stream';
|
|
4
|
+
import PDFDocument from 'pdfkit';
|
|
5
|
+
import SVGtoPDF from 'svg-to-pdfkit';
|
|
6
|
+
import Canvas from 'canvas';
|
|
7
|
+
import sharp from 'sharp';
|
|
8
|
+
import nodeFetch from 'node-fetch';
|
|
9
|
+
import { fileTypeFromBuffer } from 'file-type';
|
|
10
|
+
import { convertToPDFX1a, validatePDFX1a } from '../ghostscript.js';
|
|
11
|
+
function ensureBuffer(data) {
|
|
12
|
+
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
|
13
|
+
}
|
|
14
|
+
function getFetch() {
|
|
15
|
+
if (typeof globalThis.fetch === 'function') {
|
|
16
|
+
return globalThis.fetch.bind(globalThis);
|
|
17
|
+
}
|
|
18
|
+
return nodeFetch;
|
|
19
|
+
}
|
|
20
|
+
async function convertToPNG(buffer, mime) {
|
|
21
|
+
if (mime === 'image/png' || mime === 'image/jpeg') {
|
|
22
|
+
return buffer;
|
|
23
|
+
}
|
|
24
|
+
// Fall back to PNG conversion for other formats
|
|
25
|
+
return sharp(buffer).toFormat('png').toBuffer();
|
|
26
|
+
}
|
|
27
|
+
function attachAddSVG(doc) {
|
|
28
|
+
const existing = doc.addSVG;
|
|
29
|
+
if (!existing) {
|
|
30
|
+
doc.addSVG = function (svg, x, y, options) {
|
|
31
|
+
SVGtoPDF(this, svg, x, y, options);
|
|
32
|
+
return this;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function writeBufferToFile(fileName, buffer) {
|
|
37
|
+
await fs.promises.writeFile(fileName, buffer);
|
|
38
|
+
}
|
|
39
|
+
export function createNodeAdapter() {
|
|
40
|
+
const fetchImpl = getFetch();
|
|
41
|
+
return {
|
|
42
|
+
async fetch(input, init) {
|
|
43
|
+
return (await fetchImpl(input, init));
|
|
44
|
+
},
|
|
45
|
+
async loadImage(src) {
|
|
46
|
+
let buffer;
|
|
47
|
+
let mime;
|
|
48
|
+
if (src.startsWith('data:')) {
|
|
49
|
+
const matches = src.match(/^data:(.+);base64,(.*)$/);
|
|
50
|
+
if (!matches) {
|
|
51
|
+
throw new Error('Invalid data URL');
|
|
52
|
+
}
|
|
53
|
+
mime = matches[1];
|
|
54
|
+
buffer = Buffer.from(matches[2], 'base64');
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
const response = await fetchImpl(src);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
60
|
+
}
|
|
61
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
62
|
+
buffer = Buffer.from(arrayBuffer);
|
|
63
|
+
const typeInfo = await fileTypeFromBuffer(buffer);
|
|
64
|
+
if (typeInfo) {
|
|
65
|
+
mime = typeInfo.mime;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const normalizedBuffer = await convertToPNG(buffer, mime);
|
|
69
|
+
return Canvas.loadImage(normalizedBuffer);
|
|
70
|
+
},
|
|
71
|
+
createCanvas(width, height) {
|
|
72
|
+
return Canvas.createCanvas(width, height);
|
|
73
|
+
},
|
|
74
|
+
createPDFContext(options) {
|
|
75
|
+
const doc = new PDFDocument(options);
|
|
76
|
+
attachAddSVG(doc);
|
|
77
|
+
const stream = doc.pipe(new PassThrough());
|
|
78
|
+
const chunks = [];
|
|
79
|
+
stream.on('data', (chunk) => {
|
|
80
|
+
chunks.push(Buffer.from(chunk));
|
|
81
|
+
});
|
|
82
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
83
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
84
|
+
stream.on('error', reject);
|
|
85
|
+
doc.on('error', reject);
|
|
86
|
+
});
|
|
87
|
+
const finalize = async (options) => {
|
|
88
|
+
const buffer = await resultPromise;
|
|
89
|
+
const outputBuffer = Buffer.from(buffer);
|
|
90
|
+
if (options?.fileName) {
|
|
91
|
+
const targetFile = options.fileName;
|
|
92
|
+
await writeBufferToFile(targetFile, outputBuffer);
|
|
93
|
+
if (options.attrs?.pdfx1a) {
|
|
94
|
+
const tempFile = targetFile.replace('.pdf', '-temp.pdf');
|
|
95
|
+
await fs.promises.rename(targetFile, tempFile);
|
|
96
|
+
try {
|
|
97
|
+
await convertToPDFX1a(tempFile, targetFile, {
|
|
98
|
+
metadata: options.attrs.metadata || {},
|
|
99
|
+
});
|
|
100
|
+
await fs.promises.unlink(tempFile);
|
|
101
|
+
if (options.attrs.validate) {
|
|
102
|
+
const isValid = await validatePDFX1a(targetFile);
|
|
103
|
+
if (!isValid) {
|
|
104
|
+
console.warn('Warning: Generated PDF may not be fully PDF/X-1a compliant');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
if (fs.existsSync(tempFile)) {
|
|
110
|
+
await fs.promises.rename(tempFile, targetFile);
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`PDF/X-1a conversion failed: ${error.message}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return new Uint8Array(outputBuffer);
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
doc,
|
|
120
|
+
finalize,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
registerFont(doc, name, data) {
|
|
124
|
+
const buffer = ensureBuffer(data);
|
|
125
|
+
doc.registerFont(name, buffer);
|
|
126
|
+
},
|
|
127
|
+
encodeBase64(data) {
|
|
128
|
+
return Buffer.from(data).toString('base64');
|
|
129
|
+
},
|
|
130
|
+
decodeBase64(value) {
|
|
131
|
+
return new Uint8Array(Buffer.from(value, 'base64'));
|
|
132
|
+
},
|
|
133
|
+
embedImage(doc, data, x, y, options, _mimeType) {
|
|
134
|
+
if (typeof data === 'string') {
|
|
135
|
+
doc.image(data, x, y, options);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const buffer = ensureBuffer(data);
|
|
139
|
+
doc.image(buffer, x, y, options);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/lib/text.d.ts
CHANGED
|
@@ -31,9 +31,15 @@ export interface TextSegment {
|
|
|
31
31
|
underline?: boolean;
|
|
32
32
|
color?: string;
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalize rich text HTML by converting block-level line breaks into newline characters
|
|
36
|
+
* while preserving inline formatting tags.
|
|
37
|
+
*/
|
|
38
|
+
declare function normalizeRichText(text: string): string;
|
|
34
39
|
export declare function getGoogleFontPath(fontFamily: string, fontWeight?: string, italic?: boolean): Promise<string>;
|
|
35
40
|
export declare function loadFontIfNeeded(doc: any, element: TextElement, fonts: Record<string, boolean>): Promise<string>;
|
|
36
41
|
/**
|
|
37
42
|
* Main text rendering function
|
|
38
43
|
*/
|
|
39
44
|
export declare function renderText(doc: PDFKit.PDFDocument, element: TextElement, fonts: Record<string, boolean>, attrs?: RenderAttrs): Promise<void>;
|
|
45
|
+
export { normalizeRichText as __normalizeRichTextForTests };
|
package/lib/text.js
CHANGED
|
@@ -1,14 +1,34 @@
|
|
|
1
1
|
import { parseColor, srcToBuffer } from './utils.js';
|
|
2
2
|
import getUrls from 'get-urls';
|
|
3
3
|
import fetch from 'node-fetch';
|
|
4
|
-
import { stripHtml } from
|
|
4
|
+
import { stripHtml } from 'string-strip-html';
|
|
5
5
|
/**
|
|
6
6
|
* Check if text contains HTML tags
|
|
7
7
|
*/
|
|
8
8
|
function containsHTML(text) {
|
|
9
|
-
const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span)[^>]*>/i;
|
|
9
|
+
const htmlTagRegex = /<\/?(?:strong|b|em|i|u|span|p|br)[^>]*>/i;
|
|
10
10
|
return htmlTagRegex.test(text);
|
|
11
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Normalize rich text HTML by converting block-level line breaks into newline characters
|
|
14
|
+
* while preserving inline formatting tags.
|
|
15
|
+
*/
|
|
16
|
+
function normalizeRichText(text) {
|
|
17
|
+
if (!text) {
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
let normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
21
|
+
// Convert explicit HTML break tags into newline characters
|
|
22
|
+
normalized = normalized.replace(/<br\s*\/?>/gi, '\n');
|
|
23
|
+
// Treat paragraph boundaries as newlines and drop opening tags
|
|
24
|
+
normalized = normalized.replace(/<\/p\s*>/gi, '\n');
|
|
25
|
+
normalized = normalized.replace(/<p[^>]*>/gi, '');
|
|
26
|
+
// Collapse excessive consecutive newlines produced by HTML cleanup
|
|
27
|
+
normalized = normalized.replace(/\n{3,}/g, '\n\n');
|
|
28
|
+
// Trim stray leading/trailing newlines introduced by paragraph conversion
|
|
29
|
+
normalized = normalized.replace(/^\n+/, '').replace(/\n+$/, '');
|
|
30
|
+
return normalized;
|
|
31
|
+
}
|
|
12
32
|
/**
|
|
13
33
|
* Parse HTML text into styled segments
|
|
14
34
|
*/
|
|
@@ -42,7 +62,7 @@ function parseHTMLToSegments(html, baseElement) {
|
|
|
42
62
|
bold,
|
|
43
63
|
italic,
|
|
44
64
|
underline,
|
|
45
|
-
color
|
|
65
|
+
color,
|
|
46
66
|
});
|
|
47
67
|
}
|
|
48
68
|
else {
|
|
@@ -52,7 +72,7 @@ function parseHTMLToSegments(html, baseElement) {
|
|
|
52
72
|
const attributes = match[3];
|
|
53
73
|
if (isClosing) {
|
|
54
74
|
// Remove from stack
|
|
55
|
-
const index = tagStack.findIndex(t => t.tag === tagName);
|
|
75
|
+
const index = tagStack.findIndex((t) => t.tag === tagName);
|
|
56
76
|
if (index !== -1) {
|
|
57
77
|
tagStack.splice(index, 1);
|
|
58
78
|
}
|
|
@@ -157,7 +177,7 @@ function tokenizeHTML(html) {
|
|
|
157
177
|
// Text content
|
|
158
178
|
tokens.push({
|
|
159
179
|
type: 'text',
|
|
160
|
-
content: match[4]
|
|
180
|
+
content: match[4],
|
|
161
181
|
});
|
|
162
182
|
}
|
|
163
183
|
else {
|
|
@@ -168,7 +188,7 @@ function tokenizeHTML(html) {
|
|
|
168
188
|
type: 'tag',
|
|
169
189
|
content: match[0],
|
|
170
190
|
tagName: tagName,
|
|
171
|
-
isClosing: isClosing
|
|
191
|
+
isClosing: isClosing,
|
|
172
192
|
});
|
|
173
193
|
}
|
|
174
194
|
}
|
|
@@ -196,7 +216,7 @@ function tokensToHTML(tokens, openTags) {
|
|
|
196
216
|
html += token.content;
|
|
197
217
|
if (token.isClosing) {
|
|
198
218
|
// Remove from stack
|
|
199
|
-
const idx = tagStack.findIndex(t => t.name === token.tagName);
|
|
219
|
+
const idx = tagStack.findIndex((t) => t.name === token.tagName);
|
|
200
220
|
if (idx !== -1) {
|
|
201
221
|
tagStack.splice(idx, 1);
|
|
202
222
|
}
|
|
@@ -205,7 +225,7 @@ function tokensToHTML(tokens, openTags) {
|
|
|
205
225
|
// Add to stack
|
|
206
226
|
tagStack.push({
|
|
207
227
|
name: token.tagName,
|
|
208
|
-
fullTag: token.content
|
|
228
|
+
fullTag: token.content,
|
|
209
229
|
});
|
|
210
230
|
}
|
|
211
231
|
}
|
|
@@ -228,8 +248,8 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
228
248
|
const tokens = tokenizeHTML(paragraph);
|
|
229
249
|
// Extract plain text for width calculation
|
|
230
250
|
const plainText = tokens
|
|
231
|
-
.filter(t => t.type === 'text')
|
|
232
|
-
.map(t => t.content)
|
|
251
|
+
.filter((t) => t.type === 'text')
|
|
252
|
+
.map((t) => t.content)
|
|
233
253
|
.join('');
|
|
234
254
|
const paragraphWidth = doc.widthOfString(plainText, props);
|
|
235
255
|
// Justify alignment using native pdfkit instruments
|
|
@@ -252,7 +272,9 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
252
272
|
const textWords = token.content.split(' ');
|
|
253
273
|
for (let i = 0; i < textWords.length; i++) {
|
|
254
274
|
const word = textWords[i];
|
|
255
|
-
const testLine = currentLine
|
|
275
|
+
const testLine = currentLine
|
|
276
|
+
? `${currentLine}${i > 0 ? ' ' : ''}${word}`
|
|
277
|
+
: word;
|
|
256
278
|
const testWidth = doc.widthOfString(testLine, props);
|
|
257
279
|
if (testWidth <= element.width) {
|
|
258
280
|
currentLine = testLine;
|
|
@@ -262,13 +284,13 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
262
284
|
let content = (i > 0 ? ' ' : '') + word;
|
|
263
285
|
currentTokens.push({
|
|
264
286
|
type: 'text',
|
|
265
|
-
content: content
|
|
287
|
+
content: content,
|
|
266
288
|
});
|
|
267
289
|
}
|
|
268
290
|
else {
|
|
269
291
|
currentTokens.push({
|
|
270
292
|
type: 'text',
|
|
271
|
-
content: word
|
|
293
|
+
content: word,
|
|
272
294
|
});
|
|
273
295
|
}
|
|
274
296
|
}
|
|
@@ -284,7 +306,7 @@ function splitTextIntoLines(doc, element, props) {
|
|
|
284
306
|
currentWidth = doc.widthOfString(word, props);
|
|
285
307
|
currentTokens.push({
|
|
286
308
|
type: 'text',
|
|
287
|
-
content: word
|
|
309
|
+
content: word,
|
|
288
310
|
});
|
|
289
311
|
}
|
|
290
312
|
}
|
|
@@ -334,14 +356,15 @@ function calculateTextMetrics(doc, element) {
|
|
|
334
356
|
: 0,
|
|
335
357
|
lineBreak: false,
|
|
336
358
|
stroke: false,
|
|
337
|
-
fill: false
|
|
359
|
+
fill: false,
|
|
338
360
|
};
|
|
339
361
|
const currentLineHeight = doc.heightOfString('A', textOptions);
|
|
340
362
|
const lineHeight = element.lineHeight * element.fontSize;
|
|
341
363
|
const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
|
|
342
364
|
const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
|
|
343
365
|
// Calculate baseline offset based on font metrics (similar to Konva rendering)
|
|
344
|
-
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
|
|
366
|
+
const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
|
|
367
|
+
lineHeight / 2;
|
|
345
368
|
// Adjust line gap to match desired line height
|
|
346
369
|
const lineHeightDiff = currentLineHeight - lineHeight;
|
|
347
370
|
textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
|
|
@@ -350,7 +373,7 @@ function calculateTextMetrics(doc, element) {
|
|
|
350
373
|
textOptions,
|
|
351
374
|
lineHeightPx: lineHeight,
|
|
352
375
|
baselineOffset,
|
|
353
|
-
textLines
|
|
376
|
+
textLines,
|
|
354
377
|
};
|
|
355
378
|
}
|
|
356
379
|
/**
|
|
@@ -392,7 +415,8 @@ function renderTextBackground(doc, element, verticalAlignmentOffset, textOptions
|
|
|
392
415
|
}
|
|
393
416
|
const strippedContent = stripHtml(element.text).result;
|
|
394
417
|
const padding = element.backgroundPadding * (element.fontSize * element.lineHeight);
|
|
395
|
-
const cornerRadius = element.backgroundCornerRadius *
|
|
418
|
+
const cornerRadius = element.backgroundCornerRadius *
|
|
419
|
+
(element.fontSize * element.lineHeight * 0.5);
|
|
396
420
|
const textWidth = doc.widthOfString(strippedContent, {
|
|
397
421
|
...textOptions,
|
|
398
422
|
width: element.width,
|
|
@@ -439,7 +463,7 @@ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, text
|
|
|
439
463
|
for (let i = 0; i < textLines.length; i++) {
|
|
440
464
|
const line = textLines[i];
|
|
441
465
|
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
442
|
-
const lineYOffset = yOffset +
|
|
466
|
+
const lineYOffset = yOffset + i * lineHeightPx;
|
|
443
467
|
for (const offset of offsets) {
|
|
444
468
|
doc.text(line.text, lineXOffset + offset.x, lineYOffset + offset.y, {
|
|
445
469
|
...textOptions,
|
|
@@ -454,7 +478,7 @@ function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, text
|
|
|
454
478
|
for (let i = 0; i < textLines.length; i++) {
|
|
455
479
|
const line = textLines[i];
|
|
456
480
|
const lineXOffset = calculateLineXOffset(element, line.width);
|
|
457
|
-
const lineYOffset = yOffset +
|
|
481
|
+
const lineYOffset = yOffset + i * lineHeightPx;
|
|
458
482
|
doc.text(line.text, lineXOffset, lineYOffset, {
|
|
459
483
|
...textOptions,
|
|
460
484
|
width: isJustify ? element.width : undefined,
|
|
@@ -486,7 +510,7 @@ function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
486
510
|
width: isJustify ? element.width : undefined,
|
|
487
511
|
height: heightOfLine,
|
|
488
512
|
stroke: true,
|
|
489
|
-
fill: false
|
|
513
|
+
fill: false,
|
|
490
514
|
});
|
|
491
515
|
}
|
|
492
516
|
doc.restore();
|
|
@@ -541,7 +565,7 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
541
565
|
underline: segment.underline || textOptions.underline || false,
|
|
542
566
|
lineBreak: !!segment.underline, // Workaround for pdfkit bug
|
|
543
567
|
stroke: false,
|
|
544
|
-
fill: true
|
|
568
|
+
fill: true,
|
|
545
569
|
});
|
|
546
570
|
}
|
|
547
571
|
}
|
|
@@ -550,28 +574,36 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
|
|
|
550
574
|
* Main text rendering function
|
|
551
575
|
*/
|
|
552
576
|
export async function renderText(doc, element, fonts, attrs = {}) {
|
|
553
|
-
|
|
554
|
-
|
|
577
|
+
const normalizedText = typeof element.text === 'string'
|
|
578
|
+
? normalizeRichText(element.text)
|
|
579
|
+
: element.text;
|
|
580
|
+
const elementToRender = typeof element.text === 'string' && normalizedText !== element.text
|
|
581
|
+
? { ...element, text: normalizedText }
|
|
582
|
+
: element;
|
|
583
|
+
doc.fontSize(elementToRender.fontSize);
|
|
584
|
+
const hasStroke = elementToRender.strokeWidth > 0;
|
|
555
585
|
const isPDFX1a = attrs.pdfx1a;
|
|
556
586
|
// Calculate text metrics and line positioning
|
|
557
|
-
const metrics = calculateTextMetrics(doc,
|
|
558
|
-
const verticalAlignmentOffset = calculateVerticalAlignment(doc,
|
|
587
|
+
const metrics = calculateTextMetrics(doc, elementToRender);
|
|
588
|
+
const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
|
|
559
589
|
// Fit text to element height if needed
|
|
560
|
-
fitTextToHeight(doc,
|
|
590
|
+
fitTextToHeight(doc, elementToRender, metrics.textOptions);
|
|
561
591
|
// Calculate final vertical offset
|
|
562
592
|
const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
|
|
563
593
|
// Render background if enabled
|
|
564
|
-
renderTextBackground(doc,
|
|
594
|
+
renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
|
|
565
595
|
// Render text based on stroke and PDF/X-1a requirements
|
|
566
596
|
if (hasStroke && isPDFX1a) {
|
|
567
597
|
// PDF/X-1a mode: simulate stroke with offset fills
|
|
568
|
-
renderPDFX1aStroke(doc,
|
|
598
|
+
renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
569
599
|
}
|
|
570
600
|
else {
|
|
571
601
|
// Standard rendering: stroke first, then fill
|
|
572
602
|
if (hasStroke) {
|
|
573
|
-
renderStandardStroke(doc,
|
|
603
|
+
renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
|
|
574
604
|
}
|
|
575
|
-
await renderTextFill(doc,
|
|
605
|
+
await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
|
|
576
606
|
}
|
|
577
607
|
}
|
|
608
|
+
// Internal exports for testing
|
|
609
|
+
export { normalizeRichText as __normalizeRichTextForTests };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polotno/pdf-export",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.23",
|
|
4
4
|
"description": "Convert Polotno JSON into vector PDF",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -22,7 +22,8 @@
|
|
|
22
22
|
"author": "Anton Lavrenov",
|
|
23
23
|
"files": [
|
|
24
24
|
"lib",
|
|
25
|
-
"README.md"
|
|
25
|
+
"README.md",
|
|
26
|
+
"patches"
|
|
26
27
|
],
|
|
27
28
|
"dependencies": {
|
|
28
29
|
"canvas": "^3.2.0",
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
diff --git a/node_modules/pdfkit/js/pdfkit.js b/node_modules/pdfkit/js/pdfkit.js
|
|
2
|
+
index bba8028..66fd060 100644
|
|
3
|
+
--- a/node_modules/pdfkit/js/pdfkit.js
|
|
4
|
+
+++ b/node_modules/pdfkit/js/pdfkit.js
|
|
5
|
+
@@ -3423,7 +3423,13 @@ var TextMixin = {
|
|
6
|
+
}
|
|
7
|
+
const lineWidth = this._fontSize < 10 ? 0.5 : Math.floor(this._fontSize / 10);
|
|
8
|
+
this.lineWidth(lineWidth);
|
|
9
|
+
- let lineY = y + this.currentLineHeight() - lineWidth;
|
|
10
|
+
+ let lineY;
|
|
11
|
+
+ if (options.baseline === 'alphabetic') {
|
|
12
|
+
+ lineY = y + this.currentLineHeight() - lineWidth;
|
|
13
|
+
+ lineY -= (this._font.ascender / 1000) * this._fontSize
|
|
14
|
+
+ } else {
|
|
15
|
+
+ lineY = y + this.currentLineHeight() - lineWidth;
|
|
16
|
+
+ }
|
|
17
|
+
this.moveTo(x, lineY);
|
|
18
|
+
this.lineTo(x + renderedWidth, lineY);
|
|
19
|
+
this.stroke();
|