@polotno/pdf-export 0.1.37 → 0.1.39

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 (52) hide show
  1. package/README.md +61 -8
  2. package/lib/index.d.ts +66 -8
  3. package/lib/index.js +25 -145
  4. package/package.json +17 -18
  5. package/lib/browser-entry.d.ts +0 -7
  6. package/lib/browser-entry.js +0 -11
  7. package/lib/compare-render.d.ts +0 -1
  8. package/lib/compare-render.js +0 -185
  9. package/lib/core/index.d.ts +0 -26
  10. package/lib/core/index.js +0 -87
  11. package/lib/figure.d.ts +0 -10
  12. package/lib/figure.js +0 -54
  13. package/lib/filters.d.ts +0 -2
  14. package/lib/filters.js +0 -163
  15. package/lib/ghostscript.d.ts +0 -21
  16. package/lib/ghostscript.js +0 -132
  17. package/lib/group.d.ts +0 -5
  18. package/lib/group.js +0 -5
  19. package/lib/image.d.ts +0 -38
  20. package/lib/image.js +0 -279
  21. package/lib/line.d.ts +0 -10
  22. package/lib/line.js +0 -66
  23. package/lib/platform/adapter.d.ts +0 -37
  24. package/lib/platform/adapter.js +0 -13
  25. package/lib/platform/browser-polyfill.d.ts +0 -1
  26. package/lib/platform/browser-polyfill.js +0 -5
  27. package/lib/platform/browser.d.ts +0 -7
  28. package/lib/platform/browser.js +0 -145
  29. package/lib/platform/node.d.ts +0 -7
  30. package/lib/platform/node.js +0 -142
  31. package/lib/spot-colors.d.ts +0 -38
  32. package/lib/spot-colors.js +0 -141
  33. package/lib/svg-render.d.ts +0 -9
  34. package/lib/svg-render.js +0 -63
  35. package/lib/svg.d.ts +0 -12
  36. package/lib/svg.js +0 -224
  37. package/lib/text/fonts.d.ts +0 -16
  38. package/lib/text/fonts.js +0 -82
  39. package/lib/text/index.d.ts +0 -8
  40. package/lib/text/index.js +0 -42
  41. package/lib/text/layout.d.ts +0 -22
  42. package/lib/text/layout.js +0 -522
  43. package/lib/text/parser.d.ts +0 -46
  44. package/lib/text/parser.js +0 -415
  45. package/lib/text/render.d.ts +0 -8
  46. package/lib/text/render.js +0 -237
  47. package/lib/text/types.d.ts +0 -91
  48. package/lib/text/types.js +0 -1
  49. package/lib/text.d.ts +0 -49
  50. package/lib/text.js +0 -1277
  51. package/lib/utils.d.ts +0 -16
  52. package/lib/utils.js +0 -124
package/lib/text/fonts.js DELETED
@@ -1,82 +0,0 @@
1
- import { srcToBuffer } from '../utils.js';
2
- import getUrls from 'get-urls';
3
- import fetch from 'node-fetch';
4
- const fontUrlRegistry = {};
5
- export function registerFontUrl(fontFamily, url) {
6
- fontUrlRegistry[fontFamily] = url;
7
- }
8
- /**
9
- * Get font weight string based on bold/italic state
10
- */
11
- export function getFontWeight(bold, italic, baseFontWeight) {
12
- if (bold) {
13
- return 'bold';
14
- }
15
- return baseFontWeight || 'normal';
16
- }
17
- /**
18
- * Get font key for caching
19
- */
20
- export function getFontKey(fontFamily, bold, italic, baseFontWeight) {
21
- const weight = getFontWeight(bold, italic, baseFontWeight);
22
- const style = italic ? 'italic' : 'normal';
23
- return `${fontFamily}-${weight}-${style}`;
24
- }
25
- export async function getGoogleFontPath(fontFamily, fontWeight = 'normal', italic = false) {
26
- const weight = fontWeight === 'bold' ? '700' : '400';
27
- const italicParam = italic ? 'italic' : '';
28
- const url = `https://fonts.googleapis.com/css?family=${fontFamily}:${italicParam}${weight}`;
29
- const req = await fetch(url);
30
- if (!req.ok) {
31
- if (weight !== '400' || italic) {
32
- // Fallback: try normal weight without italic
33
- return getGoogleFontPath(fontFamily, 'normal', false);
34
- }
35
- throw new Error(`Failed to fetch Google font: ${fontFamily}`);
36
- }
37
- const text = await req.text();
38
- const urls = getUrls(text);
39
- return urls.values().next().value;
40
- }
41
- /**
42
- * Load font for a text element or segment
43
- */
44
- export async function loadFontForSegment(doc, segment, element, fonts) {
45
- const fontFamily = element.fontFamily;
46
- // Determine bold/italic from segment or element
47
- const bold = segment
48
- ? segment.bold || element.fontWeight == 'bold' || false
49
- : element.fontWeight == 'bold';
50
- const italic = segment
51
- ? segment.italic || element.fontStyle?.indexOf('italic') >= 0 || false
52
- : element.fontStyle?.indexOf('italic') >= 0 || false;
53
- // Check if universal font is already defined
54
- if (fonts[fontFamily]) {
55
- doc.font(fontFamily);
56
- return fontFamily;
57
- }
58
- const fontKey = getFontKey(fontFamily, bold, italic, element.fontWeight);
59
- if (!fonts[fontKey]) {
60
- let src;
61
- if (fontUrlRegistry[fontFamily]) {
62
- src = fontUrlRegistry[fontFamily];
63
- }
64
- else {
65
- const weight = getFontWeight(bold, italic, element.fontWeight);
66
- src = await getGoogleFontPath(fontFamily, weight, italic);
67
- }
68
- try {
69
- doc.registerFont(fontKey, await srcToBuffer(src));
70
- }
71
- catch (error) {
72
- throw new Error(`Failed to load font "${fontFamily}" from ${src}: ${error.message}`);
73
- }
74
- fonts[fontKey] = true;
75
- }
76
- doc.font(fontKey);
77
- return fontKey;
78
- }
79
- // Alias for backward compatibility
80
- export async function loadFontIfNeeded(doc, element, fonts) {
81
- return loadFontForSegment(doc, null, element, fonts);
82
- }
@@ -1,8 +0,0 @@
1
- import { TextElement, RenderAttrs } from './types.js';
2
- export * from './types.js';
3
- export { loadFontIfNeeded } from './fonts.js';
4
- export { normalizeRichText, parseHTMLToSegments } from './parser.js';
5
- /**
6
- * Main text rendering function
7
- */
8
- export declare function renderText(doc: PDFKit.PDFDocument, element: TextElement, fonts: Record<string, boolean>, attrs?: RenderAttrs): Promise<void>;
package/lib/text/index.js DELETED
@@ -1,42 +0,0 @@
1
- import { normalizeRichText } from './parser.js';
2
- import { calculateTextMetrics, calculateVerticalAlignment, fitTextToHeight, } from './layout.js';
3
- import { renderTextBackground, renderPDFX1aStroke, renderStandardStroke, renderTextFill, } from './render.js';
4
- export * from './types.js';
5
- export { loadFontIfNeeded } from './fonts.js';
6
- export { normalizeRichText, parseHTMLToSegments } from './parser.js';
7
- /**
8
- * Main text rendering function
9
- */
10
- export async function renderText(doc, element, fonts, attrs = {}) {
11
- let elementToRender = element;
12
- if (typeof element.text === 'string') {
13
- const normalizedText = normalizeRichText(element.text);
14
- if (normalizedText !== element.text) {
15
- elementToRender = { ...element, text: normalizedText };
16
- }
17
- }
18
- doc.fontSize(elementToRender.fontSize);
19
- const hasStroke = elementToRender.strokeWidth > 0;
20
- const isPDFX1a = attrs.pdfx1a;
21
- // Calculate text metrics and line positioning
22
- const metrics = calculateTextMetrics(doc, elementToRender);
23
- const verticalAlignmentOffset = calculateVerticalAlignment(doc, elementToRender, metrics.textOptions);
24
- // Fit text to element height if needed
25
- fitTextToHeight(doc, elementToRender, metrics.textOptions);
26
- // Calculate final vertical offset
27
- const finalYOffset = verticalAlignmentOffset + metrics.baselineOffset;
28
- // Render background if enabled
29
- renderTextBackground(doc, elementToRender, verticalAlignmentOffset, metrics.textOptions);
30
- // Render text based on stroke and PDF/X-1a requirements
31
- if (hasStroke && isPDFX1a) {
32
- // PDF/X-1a mode: simulate stroke with offset fills
33
- await renderPDFX1aStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
34
- }
35
- else {
36
- // Standard rendering: stroke first, then fill
37
- if (hasStroke) {
38
- await renderStandardStroke(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
39
- }
40
- await renderTextFill(doc, elementToRender, metrics.textLines, finalYOffset, metrics.lineHeightPx, metrics.textOptions, fonts);
41
- }
42
- }
@@ -1,22 +0,0 @@
1
- import { TextElement, TextLine, TextMetrics, RenderSegment, TabExpansionSegment } from './types.js';
2
- /**
3
- * Expand tabs in text with word spacing adjustment for accurate visual alignment.
4
- */
5
- export declare function expandTabsWithWordSpacing(text: string, doc: PDFKit.PDFDocument, textOptions: PDFKit.Mixins.TextOptions, tabSizeInSpaces?: number, currentWidth?: number): {
6
- segments: TabExpansionSegment[];
7
- finalWidth: number;
8
- };
9
- export declare function buildRenderSegmentsForLine(doc: PDFKit.PDFDocument, element: TextElement, lineText: string, textOptions: PDFKit.Mixins.TextOptions, fonts: Record<string, boolean>): Promise<RenderSegment[]>;
10
- export declare function splitTextIntoLines(doc: PDFKit.PDFDocument, element: TextElement, props: PDFKit.Mixins.TextOptions): TextLine[];
11
- export declare function calculateTextMetrics(doc: PDFKit.PDFDocument, element: TextElement): TextMetrics;
12
- export declare function calculateVerticalAlignment(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): number;
13
- export declare function fitTextToHeight(doc: PDFKit.PDFDocument, element: TextElement, textOptions: PDFKit.Mixins.TextOptions): void;
14
- /**
15
- * Calculate X offset for list markers (not used for text content positioning)
16
- */
17
- export declare function calculateLineXOffset(element: TextElement, line: TextLine): number;
18
- export declare function calculateTextContentXOffset(element: TextElement, line: TextLine): number;
19
- /**
20
- * Calculate effective width for text rendering, considering justify and underline constraints
21
- */
22
- export declare function calculateEffectiveWidth(element: TextElement, line: TextLine, widthOption: number | undefined, hasUnderline: boolean): number | undefined;
@@ -1,522 +0,0 @@
1
- import { stripHtml } from 'string-strip-html';
2
- import { tokenizeHTML, parseHtmlToParagraphs, tokensToHTML, decodeHtmlEntities, parseHTMLToSegments, } from './parser.js';
3
- import { loadFontForSegment } from './fonts.js';
4
- /**
5
- * Expand tabs to spaces based on character positions (every 8 characters by default).
6
- * This is a lightweight character-based approximation used for line-breaking calculations.
7
- */
8
- function expandTabsToTabStops(text, tabSize = 8, startPosition = 0) {
9
- if (!text) {
10
- return text;
11
- }
12
- let result = '';
13
- let position = startPosition; // Current character position
14
- for (let i = 0; i < text.length; i++) {
15
- const char = text[i];
16
- if (char === '\t') {
17
- // Calculate how many spaces needed to reach next tab stop
18
- const spacesNeeded = tabSize - (position % tabSize);
19
- result += ' '.repeat(spacesNeeded);
20
- position += spacesNeeded;
21
- }
22
- else if (char === '\n') {
23
- // Reset position on newline (tab stops reset at line start)
24
- result += char;
25
- position = 0;
26
- }
27
- else {
28
- result += char;
29
- position++;
30
- }
31
- }
32
- return result;
33
- }
34
- /**
35
- * Expand tabs in text with word spacing adjustment for accurate visual alignment.
36
- */
37
- export function expandTabsWithWordSpacing(text, doc, textOptions, tabSizeInSpaces = 8, currentWidth = 0) {
38
- if (!text || !text.includes('\t')) {
39
- // No tabs, return as-is
40
- const width = currentWidth + doc.widthOfString(text, textOptions);
41
- return {
42
- segments: [{ type: 'text', text, wordSpacing: 0 }],
43
- finalWidth: width,
44
- };
45
- }
46
- // Measure the width of one space character (for rendering tab spaces)
47
- const spaceWidth = doc.widthOfString(' ', textOptions);
48
- const tabStopWidth = Math.max(spaceWidth * tabSizeInSpaces, spaceWidth || 1);
49
- const segments = [];
50
- let width = currentWidth;
51
- let currentSegment = '';
52
- for (let i = 0; i < text.length; i++) {
53
- const char = text[i];
54
- if (char === '\t') {
55
- // Flush current segment if any
56
- if (currentSegment) {
57
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
58
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
59
- width += segmentWidth;
60
- currentSegment = '';
61
- }
62
- // Calculate the exact distance to next tab stop
63
- const currentTabPosition = width % tabStopWidth;
64
- const targetWidth = tabStopWidth - currentTabPosition;
65
- const widthBeforeTab = width;
66
- // Emit a tab instruction so the renderer can manually reposition the cursor.
67
- segments.push({
68
- type: 'tab',
69
- advanceWidth: widthBeforeTab + targetWidth,
70
- });
71
- width += targetWidth;
72
- }
73
- else if (char === '\n') {
74
- // Flush current segment and add newline
75
- if (currentSegment) {
76
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
77
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
78
- width += segmentWidth;
79
- currentSegment = '';
80
- }
81
- segments.push({ type: 'text', text: '\n', wordSpacing: 0 });
82
- width = 0; // Reset width on newline
83
- }
84
- else {
85
- currentSegment += char;
86
- }
87
- }
88
- // Flush remaining segment
89
- if (currentSegment) {
90
- const segmentWidth = doc.widthOfString(currentSegment, textOptions);
91
- segments.push({ type: 'text', text: currentSegment, wordSpacing: 0 });
92
- width += segmentWidth;
93
- }
94
- return { segments, finalWidth: width };
95
- }
96
- export async function buildRenderSegmentsForLine(doc, element, lineText, textOptions, fonts) {
97
- const parsedSegments = parseHTMLToSegments(lineText, element);
98
- let currentLineWidth = 0;
99
- const renderSegments = [];
100
- for (const segment of parsedSegments) {
101
- const fontKey = await loadFontForSegment(doc, segment, element, fonts);
102
- doc.font(fontKey);
103
- doc.fontSize(element.fontSize);
104
- if (segment.text.includes('\t')) {
105
- const expanded = expandTabsWithWordSpacing(segment.text, doc, textOptions, 8, currentLineWidth);
106
- currentLineWidth = expanded.finalWidth;
107
- for (const tabSegment of expanded.segments) {
108
- if (tabSegment.type === 'tab') {
109
- renderSegments.push({
110
- segment,
111
- fontKey,
112
- type: 'tab',
113
- advanceWidth: tabSegment.advanceWidth,
114
- });
115
- }
116
- else {
117
- renderSegments.push({
118
- segment,
119
- fontKey,
120
- type: 'text',
121
- text: tabSegment.text,
122
- wordSpacing: tabSegment.wordSpacing,
123
- });
124
- }
125
- }
126
- }
127
- else {
128
- const segmentWidth = doc.widthOfString(segment.text, textOptions);
129
- currentLineWidth += segmentWidth;
130
- renderSegments.push({
131
- segment,
132
- fontKey,
133
- type: 'text',
134
- text: segment.text,
135
- wordSpacing: 0,
136
- });
137
- }
138
- }
139
- return renderSegments;
140
- }
141
- function cloneListMetaForLine(meta, showMarker) {
142
- if (!meta) {
143
- return undefined;
144
- }
145
- return {
146
- ...meta,
147
- showMarker,
148
- };
149
- }
150
- function createListLineMeta(doc, element, props, paragraphMeta) {
151
- const indentPx = paragraphMeta.indentLevel * element.fontSize * 0.5;
152
- const markerText = paragraphMeta.type === 'ul' ? '•' : `${paragraphMeta.index.toString()}.`;
153
- const previousFontSize = doc._fontSize !== undefined
154
- ? doc._fontSize
155
- : element.fontSize;
156
- const markerFontSize = paragraphMeta.type === 'ul' ? element.fontSize * 1.2 : element.fontSize;
157
- doc.fontSize(markerFontSize);
158
- const markerLabelWidth = doc.widthOfString(markerText, {
159
- ...props,
160
- width: undefined,
161
- });
162
- doc.fontSize(previousFontSize);
163
- const markerGapPx = element.fontSize * (paragraphMeta.type === 'ul' ? 1.5 : 0.8);
164
- const markerBoxMinPx = element.fontSize * (paragraphMeta.type === 'ul' ? 2.5 : 2.8);
165
- const markerBoxWidth = Math.max(markerLabelWidth + markerGapPx, markerBoxMinPx);
166
- const textStartPx = indentPx + markerBoxWidth;
167
- return {
168
- type: paragraphMeta.type,
169
- markerText,
170
- indentPx,
171
- markerBoxWidth,
172
- markerFontSize,
173
- markerAlignment: paragraphMeta.type === 'ul' ? 'center' : 'right',
174
- showMarker: paragraphMeta.displayMarker,
175
- textStartPx,
176
- };
177
- }
178
- function encodeHtmlEntities(text) {
179
- return text
180
- .replace(/&/g, '&amp;')
181
- .replace(/</g, '&lt;')
182
- .replace(/>/g, '&gt;')
183
- .replace(/"/g, '&quot;')
184
- .replace(/'/g, '&apos;');
185
- }
186
- export function splitTextIntoLines(doc, element, props) {
187
- const lines = [];
188
- const rawText = typeof element.text === 'string'
189
- ? element.text
190
- : String(element.text ?? '');
191
- const paragraphs = parseHtmlToParagraphs(rawText);
192
- const lineHeightPx = element.lineHeight * element.fontSize;
193
- // Calculate max allowed lines. Ensure at least 1 line is allowed.
194
- // Use a very large number if height check is disabled or infinite
195
- // If element.height is very large (like in 8.json which is a page), we should treat it as effectively infinite for text
196
- // But wait, the element in 8.json has specific height:
197
- // "height": 60 for title, 40 for subtitle, 40 for chapter title, 600 for content text.
198
- // The content text has height 600 and font size 14. Line height 1.5 -> 21px per line.
199
- // 600 / 21 = ~28 lines.
200
- // So maxLines is finite.
201
- const maxLines = element.height > 0
202
- ? Math.max(1, Math.round(element.height / lineHeightPx))
203
- : Infinity;
204
- if (paragraphs.length === 0) {
205
- paragraphs.push({ html: '' });
206
- }
207
- for (const paragraph of paragraphs) {
208
- // Tokenize the paragraph
209
- const tokens = tokenizeHTML(paragraph.html);
210
- // Extract plain text for width calculation
211
- // Expand tabs to tab stops for accurate width measurement
212
- const plainText = expandTabsToTabStops(tokens
213
- .filter((t) => t.type === 'text')
214
- .map((t) => t.decodedContent ?? decodeHtmlEntities(t.content))
215
- .join(''), 8);
216
- const baseMeta = paragraph.listMeta
217
- ? createListLineMeta(doc, element, props, paragraph.listMeta)
218
- : undefined;
219
- const availableWidthRaw = element.width - (baseMeta ? baseMeta.textStartPx : 0);
220
- const availableWidth = element.align === 'justify'
221
- ? Math.max(availableWidthRaw, 1)
222
- : Math.max(availableWidthRaw, element.width * 0.1, 1);
223
- const paragraphWidth = doc.widthOfString(plainText, props);
224
- let showMarkerForLine = baseMeta?.showMarker ?? false;
225
- // Justify alignment using native pdfkit instruments
226
- if (paragraphWidth <= availableWidth || element.align === 'justify') {
227
- // Paragraph fits on one line
228
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
229
- lines.push({
230
- text: paragraph.html,
231
- width: paragraphWidth,
232
- fullWidth: paragraphWidth + (listMeta ? listMeta.textStartPx : 0),
233
- listMeta,
234
- });
235
- }
236
- else {
237
- // Need to split paragraph into multiple lines
238
- let currentLineDecoded = '';
239
- let currentWidth = 0;
240
- let currentTokens = [];
241
- let openTags = [];
242
- for (const token of tokens) {
243
- if (token.type === 'tag') {
244
- currentTokens.push(token);
245
- continue;
246
- }
247
- // Text token - split by words
248
- // Don't expand tabs here - we need to preserve tabs for proper alignment
249
- const rawWords = token.content.split(' ');
250
- const decodedText = token.decodedContent ?? decodeHtmlEntities(token.content);
251
- const decodedWords = decodedText.split(' ');
252
- for (let i = 0; i < rawWords.length; i++) {
253
- const rawWord = rawWords[i];
254
- const decodedWord = decodedWords[i] ?? decodeHtmlEntities(rawWord);
255
- const separator = i > 0 ? ' ' : '';
256
- const hasCurrentLine = currentLineDecoded.length > 0;
257
- const testLineDecoded = hasCurrentLine
258
- ? `${currentLineDecoded}${separator}${decodedWord}`
259
- : decodedWord;
260
- // Expand tabs in test line for accurate width measurement
261
- // Tabs are expanded based on the full line position, maintaining tab stop alignment
262
- const testLineExpanded = expandTabsToTabStops(testLineDecoded, 8);
263
- const testWidth = doc.widthOfString(testLineExpanded, props);
264
- if (testWidth <= availableWidth) {
265
- currentLineDecoded = testLineDecoded;
266
- currentWidth = testWidth;
267
- // Add text token (with space if not first word in token)
268
- const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
269
- const decodedContent = separator.length > 0 ? `${separator}${decodedWord}` : decodedWord;
270
- currentTokens.push({
271
- type: 'text',
272
- content: rawContent,
273
- decodedContent,
274
- });
275
- }
276
- else {
277
- // Line is too long, save current line and start new one
278
- // Check if we can add a new line (wrapping).
279
- // If we wrap, we push the current line and start a new one.
280
- // So we will have lines.length + 1 lines + the new one we are starting.
281
- // We can wrap only if lines.length + 1 <= maxLines.
282
- if (lines.length < maxLines - 1) {
283
- // Case 1: Line has content, so we wrap to next line
284
- if (currentLineDecoded.length > 0) {
285
- const result = tokensToHTML(currentTokens, openTags);
286
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
287
- lines.push({
288
- text: result.html,
289
- width: currentWidth,
290
- fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
291
- listMeta,
292
- });
293
- openTags = result.openTags;
294
- currentTokens = [];
295
- showMarkerForLine = false;
296
- // Handle the current word on the new line
297
- currentLineDecoded = decodedWord;
298
- // Expand tabs for accurate width measurement
299
- const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
300
- currentWidth = doc.widthOfString(decodedWordExpanded, props);
301
- currentTokens.push({
302
- type: 'text',
303
- content: rawWord,
304
- decodedContent: decodedWord,
305
- });
306
- }
307
- else {
308
- // Case 2: Line is empty (start of line), but word is too big for line
309
- // Since we can wrap (have height), but the word itself is wider than line width,
310
- // we must split the word across lines.
311
- // The original logic for splitting word was inside "else if (currentLineDecoded.length === 0)" block
312
- // which is technically this else block if lines.length check was outside.
313
- const word = decodedWord;
314
- // logic for splitting word...
315
- let processedWord = '';
316
- let processedWidth = 0;
317
- for (const char of word) {
318
- const testChunk = processedWord + char;
319
- const testChunkExpanded = expandTabsToTabStops(testChunk, 8);
320
- const testChunkWidth = doc.widthOfString(testChunkExpanded, props);
321
- if (testChunkWidth > availableWidth) {
322
- if (processedWord.length > 0 &&
323
- lines.length < maxLines - 1) {
324
- const chunkContent = encodeHtmlEntities(processedWord);
325
- currentTokens.push({
326
- type: 'text',
327
- content: chunkContent,
328
- decodedContent: processedWord,
329
- });
330
- const result = tokensToHTML(currentTokens, openTags);
331
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
332
- lines.push({
333
- text: result.html,
334
- width: processedWidth,
335
- fullWidth: processedWidth +
336
- (listMeta ? listMeta.textStartPx : 0),
337
- listMeta,
338
- });
339
- openTags = result.openTags;
340
- currentTokens = [];
341
- showMarkerForLine = false;
342
- processedWord = char;
343
- processedWidth = doc.widthOfString(expandTabsToTabStops(char, 8), props);
344
- }
345
- else {
346
- processedWord += char;
347
- processedWidth = testChunkWidth;
348
- }
349
- }
350
- else {
351
- processedWord += char;
352
- processedWidth = testChunkWidth;
353
- }
354
- }
355
- currentLineDecoded = processedWord;
356
- currentWidth = processedWidth;
357
- const chunkContent = encodeHtmlEntities(processedWord);
358
- currentTokens.push({
359
- type: 'text',
360
- content: chunkContent,
361
- decodedContent: processedWord,
362
- });
363
- }
364
- }
365
- else {
366
- // Cannot wrap anymore (max lines reached).
367
- // Append to current line regardless of width.
368
- if (currentLineDecoded.length > 0) {
369
- currentLineDecoded = testLineDecoded;
370
- currentWidth = testWidth;
371
- const rawContent = separator.length > 0 ? `${separator}${rawWord}` : rawWord;
372
- const decodedContent = separator.length > 0
373
- ? `${separator}${decodedWord}`
374
- : decodedWord;
375
- currentTokens.push({
376
- type: 'text',
377
- content: rawContent,
378
- decodedContent: decodedContent,
379
- });
380
- }
381
- else {
382
- // Edge case: Start of line, can't wrap, but word is too big.
383
- // Since we can't wrap, we just force it in.
384
- currentLineDecoded = decodedWord;
385
- const decodedWordExpanded = expandTabsToTabStops(decodedWord, 8);
386
- currentWidth = doc.widthOfString(decodedWordExpanded, props);
387
- currentTokens.push({
388
- type: 'text',
389
- content: rawWord,
390
- decodedContent: decodedWord,
391
- });
392
- }
393
- }
394
- }
395
- }
396
- }
397
- // Add the last line
398
- if (currentLineDecoded.length > 0) {
399
- const result = tokensToHTML(currentTokens, openTags);
400
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
401
- lines.push({
402
- text: result.html,
403
- width: currentWidth,
404
- fullWidth: currentWidth + (listMeta ? listMeta.textStartPx : 0),
405
- listMeta,
406
- });
407
- }
408
- else if (currentTokens.length === 0) {
409
- // Handle case when paragraph becomes empty after wrapping logic
410
- const listMeta = cloneListMetaForLine(baseMeta, showMarkerForLine);
411
- lines.push({
412
- text: '',
413
- width: 0,
414
- fullWidth: listMeta ? listMeta.textStartPx : 0,
415
- listMeta,
416
- });
417
- }
418
- }
419
- }
420
- return lines;
421
- }
422
- export function calculateTextMetrics(doc, element) {
423
- const textOptions = {
424
- align: element.align === 'justify' ? 'justify' : 'left',
425
- baseline: 'alphabetic',
426
- lineGap: 1,
427
- width: element.width,
428
- underline: element.textDecoration.indexOf('underline') >= 0,
429
- characterSpacing: element.letterSpacing
430
- ? element.letterSpacing * element.fontSize
431
- : 0,
432
- lineBreak: false,
433
- stroke: false,
434
- fill: false,
435
- };
436
- const currentLineHeight = doc.heightOfString('A', textOptions);
437
- const lineHeight = element.lineHeight * element.fontSize;
438
- const fontBoundingBoxAscent = (doc._font.ascender / 1000) * element.fontSize;
439
- const fontBoundingBoxDescent = (doc._font.descender / 1000) * element.fontSize;
440
- // Calculate baseline offset based on font metrics (similar to Konva rendering)
441
- const baselineOffset = (fontBoundingBoxAscent - Math.abs(fontBoundingBoxDescent)) / 2 +
442
- lineHeight / 2;
443
- // Adjust line gap to match desired line height
444
- const lineHeightDiff = currentLineHeight - lineHeight;
445
- textOptions.lineGap = textOptions.lineGap - lineHeightDiff;
446
- const textLines = splitTextIntoLines(doc, element, textOptions);
447
- return {
448
- textOptions,
449
- lineHeightPx: lineHeight,
450
- baselineOffset,
451
- textLines,
452
- };
453
- }
454
- export function calculateVerticalAlignment(doc, element, textOptions) {
455
- if (!element.verticalAlign || element.verticalAlign === 'top') {
456
- return 0;
457
- }
458
- const strippedContent = stripHtml(element.text).result;
459
- const textHeight = doc.heightOfString(strippedContent, textOptions);
460
- if (element.verticalAlign === 'middle') {
461
- return (element.height - textHeight) / 2;
462
- }
463
- else if (element.verticalAlign === 'bottom') {
464
- return element.height - textHeight;
465
- }
466
- return 0;
467
- }
468
- export function fitTextToHeight(doc, element, textOptions) {
469
- const strippedContent = stripHtml(element.text).result;
470
- for (let size = element.fontSize; size > 0; size -= 1) {
471
- doc.fontSize(size);
472
- const height = doc.heightOfString(strippedContent, textOptions);
473
- if (height <= element.height) {
474
- break;
475
- }
476
- }
477
- }
478
- /**
479
- * Calculate X offset for list markers (not used for text content positioning)
480
- */
481
- export function calculateLineXOffset(element, line) {
482
- // Markers are always at the left edge, regardless of text alignment
483
- if (line.listMeta) {
484
- return 0;
485
- }
486
- // For non-list lines, markers follow text alignment
487
- const align = element.align;
488
- const targetWidth = line.width;
489
- if (align === 'right') {
490
- return element.width - targetWidth;
491
- }
492
- else if (align === 'center') {
493
- return (element.width - targetWidth) / 2;
494
- }
495
- // left or justify: markers at position 0
496
- return 0;
497
- }
498
- export function calculateTextContentXOffset(element, line) {
499
- const align = element.align;
500
- const textWidth = line.width;
501
- const baseStart = line.listMeta?.textStartPx ?? 0;
502
- const availableWidth = Math.max(element.width - baseStart, 0);
503
- if (align === 'right') {
504
- return baseStart + Math.max(availableWidth - textWidth, 0);
505
- }
506
- else if (align === 'center') {
507
- return baseStart + Math.max((availableWidth - textWidth) / 2, 0);
508
- }
509
- return baseStart;
510
- }
511
- /**
512
- * Calculate effective width for text rendering, considering justify and underline constraints
513
- */
514
- export function calculateEffectiveWidth(element, line, widthOption, hasUnderline) {
515
- if (widthOption !== undefined) {
516
- return widthOption;
517
- }
518
- if (hasUnderline) {
519
- return element.width;
520
- }
521
- return undefined;
522
- }