@polotno/pdf-export 0.1.30 → 0.1.32

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/svg.js CHANGED
@@ -87,8 +87,8 @@ export async function urlToBase64(url, cache = null) {
87
87
  }
88
88
  const req = await fetchWithTimeout(url);
89
89
  let result;
90
- if (req.buffer) {
91
- const buffer = await req.buffer();
90
+ if (req.arrayBuffer) {
91
+ const buffer = Buffer.from(await req.arrayBuffer());
92
92
  result = `data:image/svg+xml;base64,${buffer.toString('base64')}`;
93
93
  }
94
94
  else {
package/lib/text.js CHANGED
@@ -3,12 +3,129 @@ import getUrls from 'get-urls';
3
3
  import fetch from 'node-fetch';
4
4
  import { stripHtml } from 'string-strip-html';
5
5
  import { decode as decodeEntities } from 'html-entities';
6
+ /**
7
+ * Expand tabs to spaces based on tab stops (every 8 characters by default, matching HTML behavior)
8
+ * This ensures that tabs align to tab stops, so deleting characters before tabs doesn't affect
9
+ * the position of text after tabs.
10
+ *
11
+ * TODO: KNOWN LIMITATION - This doesn't match Chrome/browser behavior correctly!
12
+ *
13
+ * CURRENT LOGIC (character-based):
14
+ * - Counts characters: "01\t" → "01 " (6 spaces to reach position 8)
15
+ * - Problem: In proportional fonts, "01" visually takes ~15px but we treat it as 2 chars
16
+ * - Result: Tabs misalign because visual width ≠ character count
17
+ *
18
+ * ACTUAL CHROME BEHAVIOR (visual/pixel-based):
19
+ * - Measures visual width: "01" = 15px, single space = 5px
20
+ * - Tab stop at: 8 spaces × 5px = 40px
21
+ * - "01\t" should advance from 15px → 40px (add 25px, or ~5 spaces)
22
+ * - "\t" should advance from 0px → 40px (add 40px, or 8 spaces)
23
+ * - Both end at same VISUAL position (40px), not same character position
24
+ *
25
+ * HOW TO FIX (future work):
26
+ * 1. Create `expandTabsWithVisualWidth(text, doc, textOptions)` that:
27
+ * - Measures actual text width character-by-character using doc.widthOfString()
28
+ * - Calculates tab stops as multiples of (spaceWidth × 8)
29
+ * - For each tab, determines visual advance needed to reach next tab stop
30
+ * 2. In rendering (renderTextFill, renderStandardStroke, renderPDFX1aStroke):
31
+ * - Split segments at tab characters
32
+ * - Replace each tab with N spaces
33
+ * - Use PDFKit's wordSpacing option to stretch/shrink those spaces to exact width
34
+ * - Example: Need 25px advance → use 5 spaces + wordSpacing adjustment
35
+ * 3. In line breaking (splitTextIntoLines):
36
+ * - Use visual width measurement for all width calculations
37
+ * - Ensure wrapped lines maintain accurate widths
38
+ *
39
+ * CHALLENGES:
40
+ * - Must measure with correct font for each styled segment (bold/italic affects width)
41
+ * - wordSpacing interacts with justify alignment - need careful handling
42
+ * - Line breaking must use same width calculations as rendering
43
+ * - Performance: width measurement is expensive, may need caching
44
+ *
45
+ * For now, we use character-based expansion which approximately matches monospace fonts
46
+ * but misaligns in proportional fonts like Roboto/Arial. This is a known issue.
47
+ *
48
+ * @param text - Text containing tabs to expand
49
+ * @param tabSize - Size of tab stops (default 8, matching HTML)
50
+ * @param startPosition - Starting character position for tab stop calculation (default 0)
51
+ * @returns Text with tabs expanded to spaces (character-based approximation)
52
+ */
53
+ function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
54
+ if (!text) {
55
+ return text;
56
+ }
57
+ let result = '';
58
+ let position = startPosition; // Current character position
59
+ for (let i = 0; i < text.length; i++) {
60
+ const char = text[i];
61
+ if (char === '\t') {
62
+ // Calculate how many spaces needed to reach next tab stop
63
+ const spacesNeeded = tabSize - (position % tabSize);
64
+ result += ' '.repeat(spacesNeeded);
65
+ position += spacesNeeded;
66
+ }
67
+ else if (char === '\n') {
68
+ // Reset position on newline (tab stops reset at line start)
69
+ result += char;
70
+ position = 0;
71
+ }
72
+ else {
73
+ result += char;
74
+ position++;
75
+ }
76
+ }
77
+ return result;
78
+ }
79
+ /**
80
+ * Expand tabs to spaces based on actual text width measurements (for PDF rendering)
81
+ * This ensures tabs align to visual tab stops based on actual font metrics, not character count.
82
+ * @param text - Text containing tabs to expand
83
+ * @param doc - PDFKit document for measuring text width
84
+ * @param textOptions - PDFKit text options (font, size, etc.)
85
+ * @param tabSizeInSpaces - Number of spaces per tab stop (default 8)
86
+ * @param currentWidth - Current text width in points (default 0)
87
+ * @returns Object with expanded text and final width
88
+ */
89
+ function expandTabsToTabStopsByWidth(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0) {
90
+ if (!text) {
91
+ return { text, width: currentWidth };
92
+ }
93
+ // Measure the width of one space character
94
+ const spaceWidth = doc.widthOfString(' ', textOptions);
95
+ const tabStopWidth = spaceWidth * tabSizeInSpaces;
96
+ let result = '';
97
+ let width = currentWidth;
98
+ for (let i = 0; i < text.length; i++) {
99
+ const char = text[i];
100
+ if (char === '\t') {
101
+ // Calculate how many spaces needed to reach next tab stop based on actual width
102
+ const currentTabPosition = width % tabStopWidth;
103
+ const spacesNeeded = Math.ceil((tabStopWidth - currentTabPosition) / spaceWidth);
104
+ const spaces = ' '.repeat(spacesNeeded);
105
+ result += spaces;
106
+ width += doc.widthOfString(spaces, textOptions);
107
+ }
108
+ else if (char === '\n') {
109
+ // Reset width on newline (tab stops reset at line start)
110
+ result += char;
111
+ width = 0;
112
+ }
113
+ else {
114
+ result += char;
115
+ // Measure the actual width of this character
116
+ const charWidth = doc.widthOfString(char, textOptions);
117
+ width += charWidth;
118
+ }
119
+ }
120
+ return { text: result, width };
121
+ }
6
122
  function decodeHtmlEntities(text) {
7
123
  if (!text) {
8
124
  return text;
9
125
  }
10
126
  const decoded = decodeEntities(text);
11
- return decoded.replace(/\t/g, ' ');
127
+ // Don't replace tabs here - we'll handle them with expandTabsToTabStops
128
+ return decoded;
12
129
  }
13
130
  /**
14
131
  * Check if text contains HTML tags
@@ -26,8 +143,6 @@ function normalizeRichText(text) {
26
143
  return text;
27
144
  }
28
145
  let normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
29
- // Normalize tab characters into 8 spaces
30
- normalized = normalized.replace(/\t/g, ' '.repeat(8));
31
146
  // Convert explicit HTML break tags into newline characters
32
147
  normalized = normalized.replace(/<br\s*\/?>/gi, '\n');
33
148
  // Treat paragraph boundaries as newlines and drop opening tags
@@ -37,6 +152,11 @@ function normalizeRichText(text) {
37
152
  normalized = normalized.replace(/\n{3,}/g, '\n\n');
38
153
  // Trim stray leading/trailing newlines introduced by paragraph conversion
39
154
  normalized = normalized.replace(/^\n+/, '').replace(/\n+$/, '');
155
+ // Expand tabs to tab stops AFTER processing HTML structure
156
+ // This preserves HTML-like tab behavior where tabs align to fixed positions
157
+ // so deleting characters before tabs doesn't affect the position of text after tabs
158
+ // Tabs are expanded in the text content only, not in HTML tags
159
+ normalized = expandTabsToTabStops(normalized, 8);
40
160
  // Decode common HTML non-breaking space entities into their unicode counterpart
41
161
  normalized = normalized.replace(/&(nbsp|#160|#xA0);/gi, '\u00A0');
42
162
  // Strip zero-width characters that can create missing-glyph boxes in PDF output
@@ -551,10 +671,11 @@ function splitTextIntoLines(doc, element, props) {
551
671
  // Tokenize the paragraph
552
672
  const tokens = tokenizeHTML(paragraph.html);
553
673
  // Extract plain text for width calculation
554
- const plainText = tokens
674
+ // Expand tabs to tab stops for accurate width measurement
675
+ const plainText = expandTabsToTabStops(tokens
555
676
  .filter((t) => t.type === 'text')
556
677
  .map((t) => t.decodedContent ?? decodeHtmlEntities(t.content))
557
- .join('');
678
+ .join(''), 8);
558
679
  const baseMeta = paragraph.listMeta
559
680
  ? createListLineMeta(doc, element, props, paragraph.listMeta)
560
681
  : undefined;
@@ -587,8 +708,10 @@ function splitTextIntoLines(doc, element, props) {
587
708
  continue;
588
709
  }
589
710
  // Text token - split by words
711
+ // Don't expand tabs here - we need to preserve tabs for proper alignment
590
712
  const rawWords = token.content.split(' ');
591
- const decodedWords = (token.decodedContent ?? decodeHtmlEntities(token.content)).split(' ');
713
+ const decodedText = token.decodedContent ?? decodeHtmlEntities(token.content);
714
+ const decodedWords = decodedText.split(' ');
592
715
  for (let i = 0; i < rawWords.length; i++) {
593
716
  const rawWord = rawWords[i];
594
717
  const decodedWord = decodedWords[i] ?? decodeHtmlEntities(rawWord);
@@ -597,7 +720,10 @@ function splitTextIntoLines(doc, element, props) {
597
720
  const testLineDecoded = hasCurrentLine
598
721
  ? `${currentLineDecoded}${separator}${decodedWord}`
599
722
  : decodedWord;
600
- const testWidth = doc.widthOfString(testLineDecoded, props);
723
+ // Expand tabs in test line for accurate width measurement
724
+ // Tabs are expanded based on the full line position, maintaining tab stop alignment
725
+ const testLineExpanded = expandTabsToTabStops(testLineDecoded, 8);
726
+ const testWidth = doc.widthOfString(testLineExpanded, props);
601
727
  if (testWidth <= availableWidth) {
602
728
  currentLineDecoded = testLineDecoded;
603
729
  currentWidth = testWidth;
@@ -626,7 +752,9 @@ function splitTextIntoLines(doc, element, props) {
626
752
  showMarkerForLine = false;
627
753
  }
628
754
  currentLineDecoded = decodedWord;
629
- currentWidth = doc.widthOfString(decodedWord, props);
755
+ // Expand tabs for accurate width measurement
756
+ const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
757
+ currentWidth = doc.widthOfString(decodedWordExpanded, props);
630
758
  currentTokens.push({
631
759
  type: 'text',
632
760
  content: rawWord,
@@ -878,8 +1006,37 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
878
1006
  width: 0,
879
1007
  });
880
1008
  const segments = parseHTMLToSegments(line.text, element);
881
- for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
882
- const segment = segments[segmentIndex];
1009
+ // Expand tabs in segments while tracking actual width across segments
1010
+ // This maintains tab stop alignment based on actual font metrics, not character count
1011
+ let currentLineWidth = 0;
1012
+ const segmentsWithExpandedTabs = [];
1013
+ for (const segment of segments) {
1014
+ // Check if segment has tabs
1015
+ const hasTabs = segment.text.includes('\t');
1016
+ if (hasTabs) {
1017
+ // Load font for this segment to get accurate measurements
1018
+ await loadFontForSegment(doc, segment, element, fonts);
1019
+ doc.fontSize(element.fontSize);
1020
+ // Create text options for this segment
1021
+ const segmentTextOptions = {
1022
+ ...textOptions,
1023
+ };
1024
+ // Expand tabs based on actual width
1025
+ const expanded = expandTabsToTabStopsByWidth(segment.text, doc, segmentTextOptions, 8, currentLineWidth);
1026
+ currentLineWidth = expanded.width;
1027
+ segmentsWithExpandedTabs.push({ ...segment, text: expanded.text });
1028
+ }
1029
+ else {
1030
+ // No tabs, just measure the width and update position
1031
+ await loadFontForSegment(doc, segment, element, fonts);
1032
+ doc.fontSize(element.fontSize);
1033
+ const segmentWidth = doc.widthOfString(segment.text, textOptions);
1034
+ currentLineWidth += segmentWidth;
1035
+ segmentsWithExpandedTabs.push(segment);
1036
+ }
1037
+ }
1038
+ for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
1039
+ const segment = segmentsWithExpandedTabs[segmentIndex];
883
1040
  const fontKey = await loadFontForSegment(doc, segment, element, fonts);
884
1041
  doc.font(fontKey);
885
1042
  doc.fontSize(element.fontSize);
@@ -888,7 +1045,7 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
888
1045
  width: widthOption,
889
1046
  stroke: false,
890
1047
  fill: true,
891
- continued: segmentIndex !== segments.length - 1,
1048
+ continued: segmentIndex !== segmentsWithExpandedTabs.length - 1,
892
1049
  underline: segment.underline || textOptions.underline || false,
893
1050
  lineBreak: !!segment.underline,
894
1051
  });
@@ -926,7 +1083,10 @@ async function renderStandardStroke(doc, element, textLines, yOffset, lineHeight
926
1083
  await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeParsedColor.hex, element.opacity, 'stroke', textOptions);
927
1084
  doc.strokeColor(strokeParsedColor.hex, element.opacity);
928
1085
  }
929
- doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
1086
+ // Position cursor at line start
1087
+ // Use moveTo to set position directly without corrupting text rendering state
1088
+ doc.x = contentStartX;
1089
+ doc.y = lineYOffset;
930
1090
  const segments = parseHTMLToSegments(line.text, element);
931
1091
  for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
932
1092
  const segment = segments[segmentIndex];
@@ -934,15 +1094,23 @@ async function renderStandardStroke(doc, element, textLines, yOffset, lineHeight
934
1094
  doc.font(fontKey);
935
1095
  doc.fontSize(element.fontSize);
936
1096
  doc.strokeColor(strokeParsedColor.hex, element.opacity);
1097
+ const hasUnderline = segment.underline || textOptions.underline || false;
1098
+ // When underline is enabled, we need lineBreak: true to avoid NaN errors in PDFKit
1099
+ // But we must preserve the width constraint to prevent unwanted text wrapping
1100
+ const effectiveWidth = widthOption !== undefined
1101
+ ? widthOption
1102
+ : hasUnderline
1103
+ ? element.width
1104
+ : widthOption;
937
1105
  doc.text(segment.text, {
938
1106
  ...textOptions,
939
- width: widthOption,
1107
+ width: effectiveWidth,
940
1108
  height: heightOfLine,
941
1109
  continued: segmentIndex !== segments.length - 1,
942
1110
  stroke: true,
943
1111
  fill: false,
944
- underline: segment.underline || textOptions.underline || false,
945
- lineBreak: !!segment.underline,
1112
+ underline: hasUnderline,
1113
+ lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
946
1114
  });
947
1115
  }
948
1116
  }
@@ -978,13 +1146,47 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
978
1146
  doc.fillColor(baseParsedColor.hex, baseOpacity);
979
1147
  }
980
1148
  // Position cursor at line start
981
- doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
1149
+ // Use direct assignment to set position without corrupting text rendering state
1150
+ doc.x = contentStartX;
1151
+ doc.y = lineYOffset;
982
1152
  // Parse line into styled segments
983
1153
  const segments = parseHTMLToSegments(line.text, element);
1154
+ // Expand tabs in segments while tracking actual width across segments
1155
+ // This maintains tab stop alignment based on actual font metrics, not character count
1156
+ // Note: Tabs should already be expanded by normalizeRichText, but we handle them here
1157
+ // in case line.text still contains tabs (e.g., from HTML parsing that preserves tabs)
1158
+ let currentLineWidth = 0;
1159
+ const segmentsWithExpandedTabs = [];
1160
+ for (const segment of segments) {
1161
+ // Check if segment has tabs
1162
+ const hasTabs = segment.text.includes('\t');
1163
+ if (hasTabs) {
1164
+ // Load font for this segment to get accurate measurements
1165
+ await loadFontForSegment(doc, segment, element, fonts);
1166
+ doc.fontSize(element.fontSize);
1167
+ // Create text options for this segment
1168
+ const segmentTextOptions = {
1169
+ ...textOptions,
1170
+ };
1171
+ // Expand tabs based on actual width
1172
+ const expanded = expandTabsToTabStopsByWidth(segment.text, doc, segmentTextOptions, 8, currentLineWidth);
1173
+ currentLineWidth = expanded.width;
1174
+ segmentsWithExpandedTabs.push({ ...segment, text: expanded.text });
1175
+ }
1176
+ else {
1177
+ // No tabs, just measure the width and update position
1178
+ // Load font to measure correctly
1179
+ await loadFontForSegment(doc, segment, element, fonts);
1180
+ doc.fontSize(element.fontSize);
1181
+ const segmentWidth = doc.widthOfString(segment.text, textOptions);
1182
+ currentLineWidth += segmentWidth;
1183
+ segmentsWithExpandedTabs.push(segment);
1184
+ }
1185
+ }
984
1186
  // Render each segment with its own styling
985
- for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
986
- const segment = segments[segmentIndex];
987
- const isLastSegment = segmentIndex === segments.length - 1;
1187
+ for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
1188
+ const segment = segmentsWithExpandedTabs[segmentIndex];
1189
+ const isLastSegment = segmentIndex === segmentsWithExpandedTabs.length - 1;
988
1190
  // Load appropriate font for this segment
989
1191
  await loadFontForSegment(doc, segment, element, fonts);
990
1192
  doc.fontSize(element.fontSize);
@@ -998,13 +1200,23 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
998
1200
  const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
999
1201
  doc.fillColor(segmentColor, segmentOpacity);
1000
1202
  // Render segment text
1203
+ const hasUnderline = segment.underline || textOptions.underline || false;
1204
+ // When underline is enabled, we need lineBreak: true to avoid NaN errors in PDFKit
1205
+ // But we must preserve the width constraint to prevent unwanted text wrapping
1206
+ // For non-justified text, widthOption is undefined, which would remove the width constraint
1207
+ // So we use widthOption when available (for justify), otherwise use element.width for underlines
1208
+ const effectiveWidth = widthOption !== undefined
1209
+ ? widthOption
1210
+ : hasUnderline
1211
+ ? element.width
1212
+ : widthOption;
1001
1213
  doc.text(segment.text, {
1002
1214
  ...textOptions,
1003
- width: widthOption,
1215
+ width: effectiveWidth,
1004
1216
  height: heightOfLine,
1005
1217
  continued: !isLastSegment,
1006
- underline: segment.underline || textOptions.underline || false,
1007
- lineBreak: !!segment.underline, // Workaround for pdfkit bug
1218
+ underline: hasUnderline,
1219
+ lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
1008
1220
  stroke: false,
1009
1221
  fill: true,
1010
1222
  });
package/lib/utils.js CHANGED
@@ -62,7 +62,7 @@ export async function loadImage(src, cache = null) {
62
62
  else {
63
63
  try {
64
64
  const response = await fetchWithTimeout(src);
65
- buffer = await response.buffer();
65
+ buffer = Buffer.from(await response.arrayBuffer());
66
66
  const { fileTypeFromBuffer } = await import('file-type');
67
67
  const typeData = await fileTypeFromBuffer(buffer);
68
68
  if (typeData) {
@@ -99,7 +99,7 @@ export async function srcToBase64(src, cache = null) {
99
99
  }
100
100
  else {
101
101
  const res = await fetchWithTimeout(src);
102
- const data = await res.buffer();
102
+ const data = Buffer.from(await res.arrayBuffer());
103
103
  base64 = data.toString('base64');
104
104
  }
105
105
  // Store in cache
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polotno/pdf-export",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "Convert Polotno JSON into vector PDF",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -28,15 +28,15 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "canvas": "^3.2.0",
31
- "file-type": "^21.0.0",
31
+ "file-type": "^21.1.0",
32
32
  "get-urls": "^12.1.0",
33
- "konva": "^10.0.8",
33
+ "konva": "^10.0.9",
34
34
  "node-fetch": "^3.3.2",
35
35
  "parse-color": "^1.0.0",
36
36
  "pdf2pic": "^3.2.0",
37
37
  "pdfkit": "^0.17.2",
38
- "polotno": "^2.29.5",
39
- "sharp": "^0.34.4",
38
+ "polotno": "^2.32.4",
39
+ "sharp": "^0.34.5",
40
40
  "string-strip-html": "^13.5.0",
41
41
  "svg-to-pdfkit": "^0.1.8",
42
42
  "xmldom": "^0.6.0",
@@ -45,18 +45,18 @@
45
45
  "devDependencies": {
46
46
  "@types/react": "18.3.12",
47
47
  "@types/react-dom": "18.3.2",
48
- "@types/node": "^24.10.0",
48
+ "@types/node": "^24.10.1",
49
49
  "@types/parse-color": "^1.0.3",
50
50
  "@types/pdfkit": "^0.17.3",
51
51
  "jest-image-snapshot": "^6.5.1",
52
52
  "pdf-img-convert": "^2.0.0",
53
- "pdf-to-png-converter": "^3.10.0",
53
+ "pdf-to-png-converter": "^3.11.0",
54
54
  "pixelmatch": "^7.1.0",
55
55
  "pngjs": "^7.0.0",
56
56
  "polotno-node": "^2.12.30",
57
57
  "typescript": "~5.9.3",
58
- "@vitejs/plugin-react": "^5.1.0",
59
- "vite": "^7.2.0",
60
- "vitest": "^4.0.7"
58
+ "@vitejs/plugin-react": "^5.1.1",
59
+ "vite": "^7.2.2",
60
+ "vitest": "^4.0.10"
61
61
  }
62
62
  }