@polotno/pdf-export 0.1.34 → 0.1.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,6 +94,41 @@ await jsonToPDF(json, './output.pdf', {
94
94
  - Spot colors work best with PDF/X-1a export enabled
95
95
  - You can verify spot colors in Adobe Acrobat by checking Output Preview > Separations
96
96
 
97
+ ## DPI Handling
98
+
99
+ The library automatically handles DPI conversion to ensure correct physical dimensions in the output PDF. By default, it uses the `dpi` value from your JSON file (or 72 DPI if not specified).
100
+
101
+ ```js
102
+ // JSON with dpi specified
103
+ const json = {
104
+ width: 1920,
105
+ height: 1080,
106
+ dpi: 300, // 300 DPI input
107
+ // ... rest of JSON
108
+ };
109
+
110
+ // Use JSON dpi automatically
111
+ await jsonToPDF(json, './output.pdf');
112
+
113
+ // Override DPI via attrs (takes precedence over JSON dpi)
114
+ await jsonToPDF(json, './output.pdf', {
115
+ dpi: 150, // Override to 150 DPI
116
+ });
117
+ ```
118
+
119
+ **How it works:**
120
+
121
+ - Input JSON coordinates are in **pixels** at the specified DPI
122
+ - PDF uses **points** (1 point = 1/72 inch)
123
+ - The library converts: `points = pixels × (72 / dpi)`
124
+ - This ensures the PDF has correct physical dimensions for printing
125
+ - All element positions, sizes, and coordinates are automatically scaled
126
+
127
+ **Example:**
128
+
129
+ - A 1920×1080 pixel canvas at 300 DPI = 6.4" × 3.6" in the PDF
130
+ - The same canvas at 72 DPI = 26.67" × 15" in the PDF
131
+
97
132
  ## Requirements
98
133
 
99
134
  - **GhostScript** must be installed for PDF/X-1a conversion
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
- if (cache && cache.processedImages.has(cacheKey)) {
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 buffer = await srcToBuffer(src, cache);
170
- // Save buffer to a temp file and cache the path
171
- let filePath;
172
- if (cache) {
173
- if (!cache.imageFiles) {
174
- cache.imageFiles = new Map();
175
- }
176
- if (!cache.tempDir) {
177
- cache.tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pdfkit-images-'));
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
- // Create a unique filename based on cache key hash
180
- const hash = crypto.createHash('md5').update(cacheKey).digest('hex');
181
- filePath = path.join(cache.tempDir, `${hash}.png`);
182
- // Write buffer to file
183
- fs.writeFileSync(filePath, buffer);
184
- cache.imageFiles.set(cacheKey, filePath);
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
- filePath = buffer;
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.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface PolotnoJSON {
10
10
  background?: string;
11
11
  children: any[];
12
12
  }>;
13
+ dpi?: number;
13
14
  }
14
15
  export interface RenderAttrs {
15
16
  pdfx1a?: boolean;
@@ -22,5 +23,6 @@ export interface RenderAttrs {
22
23
  };
23
24
  spotColors?: SpotColorConfig;
24
25
  textVerticalResizeEnabled?: boolean;
26
+ dpi?: number;
25
27
  }
26
28
  export declare function jsonToPDF(json: PolotnoJSON, pdfFileName: string, attrs?: RenderAttrs): Promise<void>;
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';
@@ -51,6 +51,13 @@ async function renderElement({ doc, element, fonts, attrs, cache, }) {
51
51
  }
52
52
  export async function jsonToPDF(json, pdfFileName, attrs = {}) {
53
53
  const fonts = {};
54
+ // Compute DPI and scale factor
55
+ // Priority: attrs.dpi (override) > json.dpi > 72 (default)
56
+ const inputDpi = attrs.dpi ?? json.dpi ?? 72;
57
+ // Validate DPI: must be finite and positive
58
+ const validDpi = Number.isFinite(inputDpi) && inputDpi > 0 ? inputDpi : 72;
59
+ // Convert pixels to PDF points: 1 point = 1/72 inch, so points per pixel = 72 / dpi
60
+ const ptPerPx = 72 / validDpi;
54
61
  // Create cache for images and processed results
55
62
  const cache = {
56
63
  images: new Map(), // Cache for loaded Canvas images
@@ -60,7 +67,7 @@ export async function jsonToPDF(json, pdfFileName, attrs = {}) {
60
67
  tempDir: null, // Temporary directory for image files
61
68
  };
62
69
  var doc = new PDFDocument({
63
- size: [json.width, json.height],
70
+ size: [json.width * ptPerPx, json.height * ptPerPx],
64
71
  autoFirstPage: false,
65
72
  });
66
73
  // Enable spot color support if configured
@@ -73,6 +80,10 @@ export async function jsonToPDF(json, pdfFileName, attrs = {}) {
73
80
  }
74
81
  for (const page of json.pages) {
75
82
  doc.addPage();
83
+ // Apply scale transform so all pixel-based coordinates work correctly
84
+ // The page size is already in points, but we render in pixel coordinates
85
+ doc.save();
86
+ doc.scale(ptPerPx);
76
87
  if (page.background) {
77
88
  const isURL = page.background.indexOf('http') >= 0 ||
78
89
  page.background.indexOf('.png') >= 0 ||
@@ -91,6 +102,7 @@ export async function jsonToPDF(json, pdfFileName, attrs = {}) {
91
102
  for (const element of page.children) {
92
103
  await renderElement({ doc, element, fonts, attrs, cache });
93
104
  }
105
+ doc.restore();
94
106
  }
95
107
  doc.end();
96
108
  await new Promise((r) => doc.pipe(fs.createWriteStream(pdfFileName)).on('finish', r));
@@ -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;