@polotno/pdf-export 0.1.32 → 0.1.34

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.
Files changed (2) hide show
  1. package/lib/text.js +310 -295
  2. package/package.json +1 -1
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,103 @@ 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
+ // Disable justification if line contains tabs - tabs position content precisely,
939
+ // and justification would interfere by spreading spaces
940
+ const hasTabs = line.text.includes('\t');
941
+ const widthOption = isJustify && !hasTabs
942
+ ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
943
+ : undefined;
944
+ // Handle list markers if needed
945
+ if (line.listMeta?.showMarker) {
946
+ await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, markerColor, markerOpacity, markerMode, textOptions);
947
+ // Restore color after drawing marker (marker may have changed the color state)
948
+ if (markerMode === 'fill') {
949
+ doc.fillColor(markerColor, markerOpacity);
950
+ }
951
+ else {
952
+ doc.strokeColor(markerColor, markerOpacity);
953
+ }
954
+ }
955
+ return {
956
+ markerXOffset,
957
+ lineYOffset,
958
+ contentStartX,
959
+ widthOption,
960
+ heightOfLine,
961
+ };
962
+ }
963
+ /**
964
+ * Render segments for a line with flexible options for different rendering modes
965
+ */
966
+ async function renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, options) {
967
+ const { mode, color, opacity = element.opacity, heightOfLine, offsetX = 0, offsetY = 0, applySegmentColor, } = options;
968
+ const totalTextSegments = renderSegments.filter((seg) => seg.type === 'text').length;
969
+ let processedTextSegments = 0;
970
+ // Position cursor at line start (with optional offset)
971
+ doc.x = context.contentStartX + offsetX;
972
+ doc.y = context.lineYOffset + offsetY;
973
+ for (const renderSegment of renderSegments) {
974
+ if (renderSegment.type === 'tab') {
975
+ const advanceWidth = renderSegment.advanceWidth ?? 0;
976
+ doc.x = context.contentStartX + advanceWidth + offsetX;
977
+ continue;
978
+ }
979
+ const { segment, text = '', wordSpacing = 0, fontKey } = renderSegment;
980
+ processedTextSegments += 1;
981
+ const isLastSegment = processedTextSegments === totalTextSegments;
982
+ doc.font(fontKey);
983
+ doc.fontSize(element.fontSize);
984
+ // Apply color (either segment-specific or global)
985
+ if (applySegmentColor) {
986
+ applySegmentColor(segment);
987
+ }
988
+ else if (color) {
989
+ if (mode === 'fill') {
990
+ doc.fillColor(color, opacity);
991
+ }
992
+ else {
993
+ doc.strokeColor(color, opacity);
994
+ }
995
+ }
996
+ const hasUnderline = segment.underline || textOptions.underline || false;
997
+ const effectiveWidth = calculateEffectiveWidth(element, line, context.widthOption, hasUnderline);
998
+ doc.text(text, {
999
+ ...textOptions,
1000
+ width: effectiveWidth,
1001
+ height: heightOfLine,
1002
+ continued: !isLastSegment,
1003
+ underline: hasUnderline,
1004
+ lineBreak: hasUnderline,
1005
+ stroke: mode === 'stroke',
1006
+ fill: mode === 'fill',
1007
+ wordSpacing,
1008
+ });
838
1009
  }
839
1010
  }
840
1011
  /**
@@ -975,8 +1146,7 @@ async function drawListMarker(doc, element, line, lineXOffset, lineYOffset, font
975
1146
  async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
976
1147
  const strokeColor = parseColor(element.stroke).hex;
977
1148
  const strokeWidth = element.strokeWidth;
978
- const isJustify = element.align === 'justify';
979
- // Generate stroke offsets in a circle pattern
1149
+ // Generate stroke offsets in a circle pattern (8 directions)
980
1150
  const offsets = [];
981
1151
  for (let angle = 0; angle < 360; angle += 45) {
982
1152
  const radian = (angle * Math.PI) / 180;
@@ -990,66 +1160,21 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
990
1160
  doc.fillColor(strokeColor, element.opacity);
991
1161
  for (let i = 0; i < textLines.length; i++) {
992
1162
  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
- }
1163
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, i * lineHeightPx, textOptions, fonts, strokeColor, element.opacity, 'fill');
1164
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1165
+ // Render with each offset to create stroke effect
1003
1166
  for (const offset of offsets) {
1004
- doc.text('', contentStartX + offset.x, lineYOffset + offset.y, {
1167
+ doc.text('', context.contentStartX + offset.x, context.lineYOffset + offset.y, {
1005
1168
  height: 0,
1006
1169
  width: 0,
1007
1170
  });
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
- }
1171
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1172
+ mode: 'fill',
1173
+ color: strokeColor,
1174
+ opacity: element.opacity,
1175
+ offsetX: offset.x,
1176
+ offsetY: offset.y,
1177
+ });
1053
1178
  }
1054
1179
  }
1055
1180
  doc.restore();
@@ -1059,7 +1184,6 @@ async function renderPDFX1aStroke(doc, element, textLines, yOffset, lineHeightPx
1059
1184
  * Render text stroke using standard PDF stroke
1060
1185
  */
1061
1186
  async function renderStandardStroke(doc, element, textLines, yOffset, lineHeightPx, textOptions, fonts) {
1062
- const isJustify = element.align === 'justify';
1063
1187
  const strokeParsedColor = parseColor(element.stroke);
1064
1188
  doc.save();
1065
1189
  doc.lineWidth(element.strokeWidth);
@@ -1068,51 +1192,15 @@ async function renderStandardStroke(doc, element, textLines, yOffset, lineHeight
1068
1192
  let cumulativeYOffset = 0;
1069
1193
  for (let i = 0; i < textLines.length; i++) {
1070
1194
  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
- // 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;
1090
- const segments = parseHTMLToSegments(line.text, element);
1091
- for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex++) {
1092
- const segment = segments[segmentIndex];
1093
- const fontKey = await loadFontForSegment(doc, segment, element, fonts);
1094
- doc.font(fontKey);
1095
- doc.fontSize(element.fontSize);
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;
1105
- doc.text(segment.text, {
1106
- ...textOptions,
1107
- width: effectiveWidth,
1108
- height: heightOfLine,
1109
- continued: segmentIndex !== segments.length - 1,
1110
- stroke: true,
1111
- fill: false,
1112
- underline: hasUnderline,
1113
- lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
1114
- });
1115
- }
1195
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, strokeParsedColor.hex, element.opacity, 'stroke');
1196
+ cumulativeYOffset += context.heightOfLine;
1197
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1198
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1199
+ mode: 'stroke',
1200
+ color: strokeParsedColor.hex,
1201
+ opacity: element.opacity,
1202
+ heightOfLine: context.heightOfLine,
1203
+ });
1116
1204
  }
1117
1205
  doc.restore();
1118
1206
  }
@@ -1126,71 +1214,14 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1126
1214
  const baseParsedColor = parseColor(element.fill);
1127
1215
  const baseOpacity = Math.min(baseParsedColor.rgba[3] ?? 1, element.opacity, 1);
1128
1216
  doc.fillColor(baseParsedColor.hex, baseOpacity);
1129
- const isJustify = element.align === 'justify';
1130
1217
  let cumulativeYOffset = 0;
1131
1218
  for (let i = 0; i < textLines.length; i++) {
1132
1219
  const line = textLines[i];
1133
- const markerXOffset = calculateLineXOffset(element, line);
1134
- const lineYOffset = yOffset + cumulativeYOffset;
1135
- const strippedLineText = stripHtml(line.text).result;
1136
- const heightOfLine = line.text === ''
1137
- ? lineHeightPx
1138
- : doc.heightOfString(strippedLineText, textOptions);
1139
- cumulativeYOffset += heightOfLine;
1140
- const contentStartX = calculateTextContentXOffset(element, line);
1141
- const widthOption = isJustify
1142
- ? Math.max(element.width - (line.listMeta ? line.listMeta.textStartPx : 0), 1)
1143
- : undefined;
1144
- if (line.listMeta?.showMarker) {
1145
- await drawListMarker(doc, element, line, markerXOffset, lineYOffset, fonts, baseParsedColor.hex, baseOpacity, 'fill', textOptions);
1146
- doc.fillColor(baseParsedColor.hex, baseOpacity);
1147
- }
1148
- // Position cursor at line start
1149
- // Use direct assignment to set position without corrupting text rendering state
1150
- doc.x = contentStartX;
1151
- doc.y = lineYOffset;
1152
- // Parse line into styled segments
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
- }
1186
- // Render each segment with its own styling
1187
- for (let segmentIndex = 0; segmentIndex < segmentsWithExpandedTabs.length; segmentIndex++) {
1188
- const segment = segmentsWithExpandedTabs[segmentIndex];
1189
- const isLastSegment = segmentIndex === segmentsWithExpandedTabs.length - 1;
1190
- // Load appropriate font for this segment
1191
- await loadFontForSegment(doc, segment, element, fonts);
1192
- doc.fontSize(element.fontSize);
1193
- // Apply segment color
1220
+ const context = await prepareLineForRendering(doc, element, line, i, yOffset, lineHeightPx, cumulativeYOffset, textOptions, fonts, baseParsedColor.hex, baseOpacity, 'fill');
1221
+ cumulativeYOffset += context.heightOfLine;
1222
+ const renderSegments = await buildRenderSegmentsForLine(doc, element, line.text, textOptions, fonts);
1223
+ // Apply segment-specific colors for rich text
1224
+ const applySegmentColor = (segment) => {
1194
1225
  const segmentColor = segment.color
1195
1226
  ? parseColor(segment.color).hex
1196
1227
  : parseColor(element.fill).hex;
@@ -1199,28 +1230,12 @@ async function renderTextFill(doc, element, textLines, yOffset, lineHeightPx, te
1199
1230
  : parseColor(element.fill);
1200
1231
  const segmentOpacity = Math.min(segmentParsedColor.rgba[3] ?? 1, element.opacity, 1);
1201
1232
  doc.fillColor(segmentColor, segmentOpacity);
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;
1213
- doc.text(segment.text, {
1214
- ...textOptions,
1215
- width: effectiveWidth,
1216
- height: heightOfLine,
1217
- continued: !isLastSegment,
1218
- underline: hasUnderline,
1219
- lineBreak: hasUnderline, // Workaround for pdfkit bug - enable lineBreak when underline is used
1220
- stroke: false,
1221
- fill: true,
1222
- });
1223
- }
1233
+ };
1234
+ await renderSegmentsForLine(doc, element, line, renderSegments, context, textOptions, {
1235
+ mode: 'fill',
1236
+ heightOfLine: context.heightOfLine,
1237
+ applySegmentColor,
1238
+ });
1224
1239
  }
1225
1240
  }
1226
1241
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polotno/pdf-export",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Convert Polotno JSON into vector PDF",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",