@polotno/pdf-export 0.1.31 → 0.1.33

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
@@ -4,49 +4,15 @@ import fetch from 'node-fetch';
4
4
  import { stripHtml } from 'string-strip-html';
5
5
  import { decode as decodeEntities } from 'html-entities';
6
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.
7
+ * Expand tabs to spaces based on character positions (every 8 characters by default).
8
+ * This is a lightweight character-based approximation used for line-breaking calculations.
10
9
  *
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.
10
+ * NOTE: This is NOT used for actual rendering! The rendering functions use visual width
11
+ * measurements and manual cursor positioning for accurate tab alignment in proportional fonts.
12
+ * This function is only used in splitTextIntoLines() to approximate text widths for wrapping.
47
13
  *
48
14
  * @param text - Text containing tabs to expand
49
- * @param tabSize - Size of tab stops (default 8, matching HTML)
15
+ * @param tabSize - Size of tab stops in characters (default 8)
50
16
  * @param startPosition - Starting character position for tab stop calculation (default 0)
51
17
  * @returns Text with tabs expanded to spaces (character-based approximation)
52
18
  */
@@ -76,48 +42,136 @@ function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
76
42
  }
77
43
  return result;
78
44
  }
45
+ function isTabDebuggingEnabled() {
46
+ return typeof process !== 'undefined' && process.env?.DEBUG_TABS === '1';
47
+ }
48
+ function getEffectiveTabSize(tabSizeInSpaces) {
49
+ if (typeof process === 'undefined') {
50
+ return tabSizeInSpaces;
51
+ }
52
+ const envValue = process.env?.POLOTNO_TAB_SIZE;
53
+ if (!envValue) {
54
+ return tabSizeInSpaces;
55
+ }
56
+ const parsed = parseInt(envValue, 10);
57
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : tabSizeInSpaces;
58
+ }
59
+ function formatTabDebugLabel(context) {
60
+ if (!context) {
61
+ return 'unknown';
62
+ }
63
+ if (context.elementId) {
64
+ return context.elementId;
65
+ }
66
+ if (context.elementName) {
67
+ return context.elementName;
68
+ }
69
+ return 'unknown';
70
+ }
71
+ function formatSegmentPreview(text) {
72
+ if (!text) {
73
+ return '';
74
+ }
75
+ return text.replace(/\s+/g, ' ').trim().slice(0, 80);
76
+ }
77
+ function getTabDebugContext(element, segment) {
78
+ if (!isTabDebuggingEnabled()) {
79
+ return undefined;
80
+ }
81
+ const elementWithMeta = element;
82
+ return {
83
+ elementId: elementWithMeta.id,
84
+ elementName: elementWithMeta.name,
85
+ segmentPreview: segment.text,
86
+ };
87
+ }
79
88
  /**
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.
89
+ * Expand tabs in text with word spacing adjustment for accurate visual alignment.
90
+ * This splits text at tab boundaries and calculates the wordSpacing needed to make
91
+ * the replacement spaces render at exactly the right width to reach tab stops.
92
+ *
82
93
  * @param text - Text containing tabs to expand
83
94
  * @param doc - PDFKit document for measuring text width
84
95
  * @param textOptions - PDFKit text options (font, size, etc.)
85
96
  * @param tabSizeInSpaces - Number of spaces per tab stop (default 8)
86
97
  * @param currentWidth - Current text width in points (default 0)
87
- * @returns Object with expanded text and final width
98
+ * @returns Array of segments with expanded text and wordSpacing adjustments
88
99
  */
89
- function expandTabsToTabStopsByWidth(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0) {
90
- if (!text) {
91
- return { text, width: currentWidth };
100
+ function expandTabsWithWordSpacing(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0, debugContext) {
101
+ if (!text || !text.includes('\t')) {
102
+ // No tabs, return as-is
103
+ const width = currentWidth + doc.widthOfString(text, textOptions);
104
+ return {
105
+ segments: [{ type: 'text', text, wordSpacing: 0 }],
106
+ finalWidth: width,
107
+ };
92
108
  }
93
- // Measure the width of one space character
109
+ // Measure the width of one space character (for rendering tab spaces)
94
110
  const spaceWidth = doc.widthOfString(' ', textOptions);
95
- const tabStopWidth = spaceWidth * tabSizeInSpaces;
96
- let result = '';
111
+ const effectiveTabSize = getEffectiveTabSize(tabSizeInSpaces);
112
+ const tabStopWidth = Math.max(spaceWidth * effectiveTabSize, spaceWidth || 1);
113
+ const shouldLog = isTabDebuggingEnabled() && !!debugContext;
114
+ const segments = [];
97
115
  let width = currentWidth;
116
+ let currentSegment = '';
98
117
  for (let i = 0; i < text.length; i++) {
99
118
  const char = text[i];
100
119
  if (char === '\t') {
101
- // Calculate how many spaces needed to reach next tab stop based on actual width
120
+ // Flush current segment if any
121
+ if (currentSegment) {
122
+ const segmentWidth = doc.widthOfString(currentSegment, textOptions);
123
+ segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
124
+ width += segmentWidth;
125
+ currentSegment = '';
126
+ }
127
+ // Calculate the exact distance to next tab stop
102
128
  const currentTabPosition = width % tabStopWidth;
103
- const spacesNeeded = Math.ceil((tabStopWidth - currentTabPosition) / spaceWidth);
129
+ const targetWidth = tabStopWidth - currentTabPosition;
130
+ const widthBeforeTab = width;
131
+ // Use a reasonable number of spaces (at least 1, usually 2-8)
132
+ const spacesNeeded = Math.max(1, Math.round(targetWidth / spaceWidth));
104
133
  const spaces = ' '.repeat(spacesNeeded);
105
- result += spaces;
106
- width += doc.widthOfString(spaces, textOptions);
134
+ // Calculate the natural width of those spaces
135
+ const naturalWidth = doc.widthOfString(spaces, textOptions);
136
+ // Calculate wordSpacing adjustment to make spaces fill exact target width
137
+ // wordSpacing is added between words, and spaces count as word separators
138
+ // For N spaces, we have N-1 word boundaries, but PDFKit applies wordSpacing
139
+ // to each space character in the context of word separation
140
+ const wordSpacingAdjustment = spacesNeeded > 1
141
+ ? (targetWidth - naturalWidth) / (spacesNeeded - 1)
142
+ : 0;
143
+ if (shouldLog) {
144
+ console.log(`[polotno-tabs] element=${formatTabDebugLabel(debugContext)} text="${formatSegmentPreview(debugContext?.segmentPreview)}" startWidth=${widthBeforeTab.toFixed(2)} targetStop=${(widthBeforeTab + targetWidth).toFixed(2)} spaces=${spacesNeeded} naturalWidth=${naturalWidth.toFixed(2)} wordSpacing=${wordSpacingAdjustment.toFixed(4)}`);
145
+ }
146
+ // Emit a tab instruction so the renderer can manually reposition the cursor.
147
+ segments.push({
148
+ type: 'tab',
149
+ advanceWidth: widthBeforeTab + targetWidth,
150
+ });
151
+ width += targetWidth;
107
152
  }
108
153
  else if (char === '\n') {
109
- // Reset width on newline (tab stops reset at line start)
110
- result += char;
111
- width = 0;
154
+ // Flush current segment and add newline
155
+ if (currentSegment) {
156
+ const segmentWidth = doc.widthOfString(currentSegment, textOptions);
157
+ segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
158
+ width += segmentWidth;
159
+ currentSegment = '';
160
+ }
161
+ segments.push({ type: 'text', text: '\n', wordSpacing: 0 });
162
+ width = 0; // Reset width on newline
112
163
  }
113
164
  else {
114
- result += char;
115
- // Measure the actual width of this character
116
- const charWidth = doc.widthOfString(char, textOptions);
117
- width += charWidth;
165
+ currentSegment += char;
118
166
  }
119
167
  }
120
- return { text: result, width };
168
+ // Flush remaining segment
169
+ if (currentSegment) {
170
+ const segmentWidth = doc.widthOfString(currentSegment, textOptions);
171
+ segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
172
+ width += segmentWidth;
173
+ }
174
+ return { segments, finalWidth: width };
121
175
  }
122
176
  function decodeHtmlEntities(text) {
123
177
  if (!text) {
@@ -152,11 +206,10 @@ function normalizeRichText(text) {
152
206
  normalized = normalized.replace(/\n{3,}/g, '\n\n');
153
207
  // Trim stray leading/trailing newlines introduced by paragraph conversion
154
208
  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);
209
+ // NOTE: We do NOT expand tabs here anymore!
210
+ // Tab expansion is now handled in the rendering functions (renderTextFill, renderPDFX1aStroke, renderStandardStroke)
211
+ // using visual width measurements and wordSpacing adjustments for accurate alignment in proportional fonts.
212
+ // The old character-based expandTabsToTabStops() was incorrect for proportional fonts.
160
213
  // Decode common HTML non-breaking space entities into their unicode counterpart
161
214
  normalized = normalized.replace(/&(nbsp|#160|#xA0);/gi, '\u00A0');
162
215
  // Strip zero-width characters that can create missing-glyph boxes in PDF output
@@ -260,30 +313,18 @@ export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', itali
260
313
  const urls = getUrls(text);
261
314
  return urls.values().next().value;
262
315
  }
263
- export async function loadFontIfNeeded(doc, element, fonts) {
264
- // check if universal font is already defined
265
- if (fonts[element.fontFamily]) {
266
- doc.font(element.fontFamily);
267
- return element.fontFamily;
268
- }
269
- const isItalic = element.fontStyle?.indexOf('italic') >= 0;
270
- const isBold = element.fontWeight == 'bold';
271
- const fontKey = getFontKey(element.fontFamily, isBold, isItalic, element.fontWeight);
272
- if (!fonts[fontKey]) {
273
- const src = await getGoogleFontPath(element.fontFamily, element.fontWeight, isItalic);
274
- doc.registerFont(fontKey, await srcToBuffer(src));
275
- fonts[fontKey] = true;
276
- }
277
- doc.font(fontKey);
278
- return fontKey;
279
- }
280
316
  /**
281
- * Load font for a rich text segment
317
+ * Load font for a text element or segment
282
318
  */
283
319
  async function loadFontForSegment(doc, segment, element, fonts) {
284
320
  const fontFamily = element.fontFamily;
285
- const bold = segment.bold || element.fontWeight == 'bold' || false;
286
- const italic = segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false;
321
+ // Determine bold/italic from segment or element
322
+ const bold = segment
323
+ ? segment.bold || element.fontWeight == 'bold' || false
324
+ : element.fontWeight == 'bold';
325
+ const italic = segment
326
+ ? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
327
+ : element.fontStyle?.indexOf('italic') >= 0 || false;
287
328
  // Check if universal font is already defined
288
329
  if (fonts[fontFamily]) {
289
330
  doc.font(fontFamily);
@@ -299,6 +340,55 @@ async function loadFontForSegment(doc, segment, element, fonts) {
299
340
  doc.font(fontKey);
300
341
  return fontKey;
301
342
  }
343
+ // Alias for backward compatibility
344
+ export async function loadFontIfNeeded(doc, element, fonts) {
345
+ return loadFontForSegment(doc, null, element, fonts);
346
+ }
347
+ async function buildRenderSegmentsForLine(doc, element, lineText, textOptions, fonts) {
348
+ const parsedSegments = parseHTMLToSegments(lineText, element);
349
+ let currentLineWidth = 0;
350
+ const renderSegments = [];
351
+ for (const segment of parsedSegments) {
352
+ const fontKey = await loadFontForSegment(doc, segment, element, fonts);
353
+ doc.font(fontKey);
354
+ doc.fontSize(element.fontSize);
355
+ if (segment.text.includes('\t')) {
356
+ const expanded = expandTabsWithWordSpacing(segment.text, doc, textOptions, 8, currentLineWidth, getTabDebugContext(element, segment));
357
+ currentLineWidth = expanded.finalWidth;
358
+ for (const tabSegment of expanded.segments) {
359
+ if (tabSegment.type === 'tab') {
360
+ renderSegments.push({
361
+ segment,
362
+ fontKey,
363
+ type: 'tab',
364
+ advanceWidth: tabSegment.advanceWidth,
365
+ });
366
+ }
367
+ else {
368
+ renderSegments.push({
369
+ segment,
370
+ fontKey,
371
+ type: 'text',
372
+ text: tabSegment.text,
373
+ wordSpacing: tabSegment.wordSpacing,
374
+ });
375
+ }
376
+ }
377
+ }
378
+ else {
379
+ const segmentWidth = doc.widthOfString(segment.text, textOptions);
380
+ currentLineWidth += segmentWidth;
381
+ renderSegments.push({
382
+ segment,
383
+ fontKey,
384
+ type: 'text',
385
+ text: segment.text,
386
+ wordSpacing: 0,
387
+ });
388
+ }
389
+ }
390
+ return renderSegments;
391
+ }
302
392
  /**
303
393
  * Parse HTML into tokens (text and tags)
304
394
  */
@@ -789,15 +879,14 @@ function splitTextIntoLines(doc, element, props) {
789
879
  return lines;
790
880
  }
791
881
  /**
792
- * Calculate horizontal offset for a line of text based on alignment
793
- * @param element - Text element with alignment settings
794
- * @param lineWidth - Width of the current line
795
- * @returns X offset for positioning the line
882
+ * Calculate X offset for list markers (not used for text content positioning)
796
883
  */
797
884
  function calculateLineXOffset(element, line) {
885
+ // Markers are always at the left edge, regardless of text alignment
798
886
  if (line.listMeta) {
799
887
  return 0;
800
888
  }
889
+ // For non-list lines, markers follow text alignment
801
890
  const align = element.align;
802
891
  const targetWidth = line.width;
803
892
  if (align === 'right') {
@@ -806,26 +895,13 @@ function calculateLineXOffset(element, line) {
806
895
  else if (align === 'center') {
807
896
  return (element.width - targetWidth) / 2;
808
897
  }
809
- else if (align === 'justify') {
810
- return 0;
811
- }
898
+ // left or justify: markers at position 0
812
899
  return 0;
813
900
  }
814
901
  function calculateTextContentXOffset(element, line) {
815
902
  const align = element.align;
816
903
  const textWidth = line.width;
817
- if (!line.listMeta) {
818
- if (align === 'right') {
819
- return element.width - textWidth;
820
- }
821
- else if (align === 'center') {
822
- return (element.width - textWidth) / 2;
823
- }
824
- else {
825
- return 0;
826
- }
827
- }
828
- const baseStart = line.listMeta.textStartPx;
904
+ const baseStart = line.listMeta?.textStartPx ?? 0;
829
905
  const availableWidth = Math.max(element.width - baseStart, 0);
830
906
  if (align === 'right') {
831
907
  return baseStart + Math.max(availableWidth - textWidth, 0);
@@ -833,8 +909,100 @@ function calculateTextContentXOffset(element, line) {
833
909
  else if (align === 'center') {
834
910
  return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
835
911
  }
836
- else {
837
- return baseStart;
912
+ return baseStart;
913
+ }
914
+ /**
915
+ * Calculate effective width for text rendering, considering justify and underline constraints
916
+ */
917
+ function calculateEffectiveWidth(element, line, widthOption, hasUnderline) {
918
+ if (widthOption !== undefined) {
919
+ return widthOption;
920
+ }
921
+ if (hasUnderline) {
922
+ return element.width;
923
+ }
924
+ return undefined;
925
+ }
926
+ /**
927
+ * Prepare rendering context for a line (calculates positions, widths, handles markers)
928
+ */
929
+ async function prepareLineForRendering(doc, element, line, lineIndex, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, markerColor, markerOpacity, markerMode) {
930
+ const markerXOffset = calculateLineXOffset(element, line);
931
+ const lineYOffset = yOffset + cumulativeYOffset;
932
+ const strippedLineText = stripHtml(line.text).result;
933
+ const heightOfLine = line.text === ''
934
+ ? lineHeightPx
935
+ : doc.heightOfString(strippedLineText, textOptions);
936
+ const contentStartX = calculateTextContentXOffset(element, line);
937
+ const isJustify = element.align === 'justify';
938
+ const widthOption = isJustify
939
+ ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
940
+ : undefined;
941
+ // Handle list markers if needed
942
+ if (line.listMeta?.showMarker) {
943
+ await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, markerColor, markerOpacity, markerMode, textOptions);
944
+ // Restore color after drawing marker (marker may have changed the color state)
945
+ if (markerMode === 'fill') {
946
+ doc.fillColor(markerColor, markerOpacity);
947
+ }
948
+ else {
949
+ doc.strokeColor(markerColor, markerOpacity);
950
+ }
951
+ }
952
+ return {
953
+ markerXOffset,
954
+ lineYOffset,
955
+ contentStartX,
956
+ widthOption,
957
+ heightOfLine,
958
+ };
959
+ }
960
+ /**
961
+ * Render segments for a line with flexible options for different rendering modes
962
+ */
963
+ async function renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, options) {
964
+ const { mode, color, opacity = element.opacity, heightOfLine, offsetX = 0, offsetY = 0, applySegmentColor, } = options;
965
+ const totalTextSegments = renderSegments.filter((seg) => seg.type === 'text').length;
966
+ let processedTextSegments = 0;
967
+ // Position cursor at line start (with optional offset)
968
+ doc.x = context.contentStartX + offsetX;
969
+ doc.y = context.lineYOffset + offsetY;
970
+ for (const renderSegment of renderSegments) {
971
+ if (renderSegment.type === 'tab') {
972
+ const advanceWidth = renderSegment.advanceWidth ?? 0;
973
+ doc.x = context.contentStartX + advanceWidth + offsetX;
974
+ continue;
975
+ }
976
+ const { segment, text = '', wordSpacing = 0, fontKey } = renderSegment;
977
+ processedTextSegments += 1;
978
+ const isLastSegment = processedTextSegments === totalTextSegments;
979
+ doc.font(fontKey);
980
+ doc.fontSize(element.fontSize);
981
+ // Apply color (either segment-specific or global)
982
+ if (applySegmentColor) {
983
+ applySegmentColor(segment);
984
+ }
985
+ else if (color) {
986
+ if (mode === 'fill') {
987
+ doc.fillColor(color, opacity);
988
+ }
989
+ else {
990
+ doc.strokeColor(color, opacity);
991
+ }
992
+ }
993
+ const hasUnderline = segment.underline || textOptions.underline || false;
994
+ const effectiveWidth = calculateEffectiveWidth(element, line, context.widthOption, hasUnderline);
995
+ doc.text(text, {
996
+ ...textOptions,
997
+ width: effectiveWidth,
998
+ height: heightOfLine,
999
+ continued: !isLastSegment,
1000
+ underline: hasUnderline,
1001
+ lineBreak: hasUnderline,
1002
+ stroke: mode === 'stroke',
1003
+ fill: mode === 'fill',
1004
+ wordSpacing,
1005
+ });
838
1006
  }
839
1007
  }
840
1008
  /**
@@ -975,8 +1143,7 @@ async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, font
975
1143
  async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
976
1144
  const strokeColor = parseColor(element.stroke).hex;
977
1145
  const strokeWidth = element.strokeWidth;
978
- const isJustify = element.align === 'justify';
979
- // Generate stroke offsets in a circle pattern
1146
+ // Generate stroke offsets in a circle pattern (8 directions)
980
1147
  const offsets = [];
981
1148
  for (let angle = 0; angle < 360; angle += 45) {
982
1149
  const radian = (angle * Math.PI) / 180;
@@ -990,66 +1157,21 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
990
1157
  doc.fillColor(strokeColor, element.opacity);
991
1158
  for (let i = 0; i < textLines.length; i++) {
992
1159
  const line = textLines[i];
993
- const markerXOffset = calculateLineXOffset(element, line);
994
- const lineYOffset = yOffset + i * lineHeightPx;
995
- const contentStartX = calculateTextContentXOffset(element, line);
996
- const widthOption = isJustify
997
- ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
998
- : undefined;
999
- if (line.listMeta?.showMarker) {
1000
- await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeColor, element.opacity, 'fill', textOptions);
1001
- doc.fillColor(strokeColor, element.opacity);
1002
- }
1160
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, i * lineHeightPx, textOptions, fonts, strokeColor, element.opacity, 'fill');
1161
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1162
+ // Render with each offset to create stroke effect
1003
1163
  for (const offset of offsets) {
1004
- doc.text('', contentStartX + offset.x, lineYOffset + offset.y, {
1164
+ doc.text('', context.contentStartX + offset.x, context.lineYOffset + offset.y, {
1005
1165
  height: 0,
1006
1166
  width: 0,
1007
1167
  });
1008
- const segments = parseHTMLToSegments(line.text, element);
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];
1040
- const fontKey = await loadFontForSegment(doc, segment, element, fonts);
1041
- doc.font(fontKey);
1042
- doc.fontSize(element.fontSize);
1043
- doc.text(segment.text, {
1044
- ...textOptions,
1045
- width: widthOption,
1046
- stroke: false,
1047
- fill: true,
1048
- continued: segmentIndex !== segmentsWithExpandedTabs.length - 1,
1049
- underline: segment.underline || textOptions.underline || false,
1050
- lineBreak: !!segment.underline,
1051
- });
1052
- }
1168
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1169
+ mode: 'fill',
1170
+ color: strokeColor,
1171
+ opacity: element.opacity,
1172
+ offsetX: offset.x,
1173
+ offsetY: offset.y,
1174
+ });
1053
1175
  }
1054
1176
  }
1055
1177
  doc.restore();
@@ -1059,7 +1181,6 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
1059
1181
  * Render text stroke using standard PDF stroke
1060
1182
  */
1061
1183
  async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
1062
- const isJustify = element.align === 'justify';
1063
1184
  const strokeParsedColor = parseColor(element.stroke);
1064
1185
  doc.save();
1065
1186
  doc.lineWidth(element.strokeWidth);
@@ -1068,40 +1189,15 @@ async function renderStandardStroke(doc, element, textLines, yOffset, lineHeight
1068
1189
  let cumulativeYOffset = 0;
1069
1190
  for (let i = 0; i < textLines.length; i++) {
1070
1191
  const line = textLines[i];
1071
- const markerXOffset = calculateLineXOffset(element, line);
1072
- const lineYOffset = yOffset + cumulativeYOffset;
1073
- const strippedLineText = stripHtml(line.text).result;
1074
- const heightOfLine = line.text === ''
1075
- ? lineHeightPx
1076
- : doc.heightOfString(strippedLineText, textOptions);
1077
- cumulativeYOffset += heightOfLine;
1078
- const contentStartX = calculateTextContentXOffset(element, line);
1079
- const widthOption = isJustify
1080
- ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
1081
- : undefined;
1082
- if (line.listMeta?.showMarker) {
1083
- await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, strokeParsedColor.hex, element.opacity, 'stroke', textOptions);
1084
- doc.strokeColor(strokeParsedColor.hex, element.opacity);
1085
- }
1086
- doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
1087
- const segments = parseHTMLToSegments(line.text, element);
1088
- for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
1089
- const segment = segments[segmentIndex];
1090
- const fontKey = await loadFontForSegment(doc, segment, element, fonts);
1091
- doc.font(fontKey);
1092
- doc.fontSize(element.fontSize);
1093
- doc.strokeColor(strokeParsedColor.hex, element.opacity);
1094
- doc.text(segment.text, {
1095
- ...textOptions,
1096
- width: widthOption,
1097
- height: heightOfLine,
1098
- continued: segmentIndex !== segments.length - 1,
1099
- stroke: true,
1100
- fill: false,
1101
- underline: segment.underline || textOptions.underline || false,
1102
- lineBreak: !!segment.underline,
1103
- });
1104
- }
1192
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, strokeParsedColor.hex, element.opacity, 'stroke');
1193
+ cumulativeYOffset += context.heightOfLine;
1194
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1195
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1196
+ mode: 'stroke',
1197
+ color: strokeParsedColor.hex,
1198
+ opacity: element.opacity,
1199
+ heightOfLine: context.heightOfLine,
1200
+ });
1105
1201
  }
1106
1202
  doc.restore();
1107
1203
  }
@@ -1115,69 +1211,14 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1115
1211
  const baseParsedColor = parseColor(element.fill);
1116
1212
  const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
1117
1213
  doc.fillColor(baseParsedColor.hex, baseOpacity);
1118
- const isJustify = element.align === 'justify';
1119
1214
  let cumulativeYOffset = 0;
1120
1215
  for (let i = 0; i < textLines.length; i++) {
1121
1216
  const line = textLines[i];
1122
- const markerXOffset = calculateLineXOffset(element, line);
1123
- const lineYOffset = yOffset + cumulativeYOffset;
1124
- const strippedLineText = stripHtml(line.text).result;
1125
- const heightOfLine = line.text === ''
1126
- ? lineHeightPx
1127
- : doc.heightOfString(strippedLineText, textOptions);
1128
- cumulativeYOffset += heightOfLine;
1129
- const contentStartX = calculateTextContentXOffset(element, line);
1130
- const widthOption = isJustify
1131
- ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
1132
- : undefined;
1133
- if (line.listMeta?.showMarker) {
1134
- await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, baseParsedColor.hex, baseOpacity, 'fill', textOptions);
1135
- doc.fillColor(baseParsedColor.hex, baseOpacity);
1136
- }
1137
- // Position cursor at line start
1138
- doc.text('', contentStartX, lineYOffset, { height: 0, width: 0 });
1139
- // Parse line into styled segments
1140
- const segments = parseHTMLToSegments(line.text, element);
1141
- // Expand tabs in segments while tracking actual width across segments
1142
- // This maintains tab stop alignment based on actual font metrics, not character count
1143
- // Note: Tabs should already be expanded by normalizeRichText, but we handle them here
1144
- // in case line.text still contains tabs (e.g., from HTML parsing that preserves tabs)
1145
- let currentLineWidth = 0;
1146
- const segmentsWithExpandedTabs = [];
1147
- for (const segment of segments) {
1148
- // Check if segment has tabs
1149
- const hasTabs = segment.text.includes('\t');
1150
- if (hasTabs) {
1151
- // Load font for this segment to get accurate measurements
1152
- await loadFontForSegment(doc, segment, element, fonts);
1153
- doc.fontSize(element.fontSize);
1154
- // Create text options for this segment
1155
- const segmentTextOptions = {
1156
- ...textOptions,
1157
- };
1158
- // Expand tabs based on actual width
1159
- const expanded = expandTabsToTabStopsByWidth(segment.text, doc, segmentTextOptions, 8, currentLineWidth);
1160
- currentLineWidth = expanded.width;
1161
- segmentsWithExpandedTabs.push({ ...segment, text: expanded.text });
1162
- }
1163
- else {
1164
- // No tabs, just measure the width and update position
1165
- // Load font to measure correctly
1166
- await loadFontForSegment(doc, segment, element, fonts);
1167
- doc.fontSize(element.fontSize);
1168
- const segmentWidth = doc.widthOfString(segment.text, textOptions);
1169
- currentLineWidth += segmentWidth;
1170
- segmentsWithExpandedTabs.push(segment);
1171
- }
1172
- }
1173
- // Render each segment with its own styling
1174
- for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
1175
- const segment = segmentsWithExpandedTabs[segmentIndex];
1176
- const isLastSegment = segmentIndex === segmentsWithExpandedTabs.length - 1;
1177
- // Load appropriate font for this segment
1178
- await loadFontForSegment(doc, segment, element, fonts);
1179
- doc.fontSize(element.fontSize);
1180
- // Apply segment color
1217
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, baseParsedColor.hex, baseOpacity, 'fill');
1218
+ cumulativeYOffset += context.heightOfLine;
1219
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1220
+ // Apply segment-specific colors for rich text
1221
+ const applySegmentColor = (segment) => {
1181
1222
  const segmentColor = segment.color
1182
1223
  ? parseColor(segment.color).hex
1183
1224
  : parseColor(element.fill).hex;
@@ -1186,18 +1227,12 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1186
1227
  : parseColor(element.fill);
1187
1228
  const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
1188
1229
  doc.fillColor(segmentColor, segmentOpacity);
1189
- // Render segment text
1190
- doc.text(segment.text, {
1191
- ...textOptions,
1192
- width: widthOption,
1193
- height: heightOfLine,
1194
- continued: !isLastSegment,
1195
- underline: segment.underline || textOptions.underline || false,
1196
- lineBreak: !!segment.underline, // Workaround for pdfkit bug
1197
- stroke: false,
1198
- fill: true,
1199
- });
1200
- }
1230
+ };
1231
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1232
+ mode: 'fill',
1233
+ heightOfLine: context.heightOfLine,
1234
+ applySegmentColor,
1235
+ });
1201
1236
  }
1202
1237
  }
1203
1238
  /**
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.31",
3
+ "version": "0.1.33",
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
  }