@polotno/pdf-export 0.1.22 → 0.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ }
@@ -0,0 +1,2 @@
1
+ import { Filter } from "konva/lib/Node";
2
+ export declare const elementFilterToKonva: Record<string, (intensity: number) => Filter>;
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.js';
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,5 @@
1
+ const globalScope = globalThis;
2
+ if (typeof globalScope.global === 'undefined') {
3
+ globalScope.global = globalScope;
4
+ }
5
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { PlatformAdapter } from './adapter.js';
2
+ interface FinalizeOptions {
3
+ fileName?: string;
4
+ attrs?: any;
5
+ }
6
+ export declare function createBrowserAdapter(): PlatformAdapter<Blob, FinalizeOptions>;
7
+ 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,7 @@
1
+ import type { PlatformAdapter } from './adapter.js';
2
+ interface FinalizeOptions {
3
+ fileName?: string;
4
+ attrs?: any;
5
+ }
6
+ export declare function createNodeAdapter(): PlatformAdapter<Uint8Array, FinalizeOptions>;
7
+ export {};
@@ -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 "string-strip-html";
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 ? `${currentLine}${i > 0 ? ' ' : ''}${word}` : word;
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 + lineHeight / 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 * (element.fontSize * element.lineHeight * 0.5);
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 + (i * lineHeightPx);
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 + (i * lineHeightPx);
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
- doc.fontSize(element.fontSize);
554
- const hasStroke = element.strokeWidth > 0;
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, element);
558
- const verticalAlignmentOffset = calculateVerticalAlignment(doc, element, metrics.textOptions);
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, element, metrics.textOptions);
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, element, verticalAlignmentOffset, metrics.textOptions);
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, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
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, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
603
+ renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions);
574
604
  }
575
- await renderTextFill(doc, element, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
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.22",
3
+ "version": "0.1.24",
4
4
  "description": "Convert Polotno JSON into vector PDF",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",