@digicole/pdfmake-rtl 2.1.0 → 2.1.2

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 (65) hide show
  1. package/CHANGELOG.md +118 -83
  2. package/README.md +11 -10
  3. package/build/pdfmake.js +71 -42
  4. package/build/pdfmake.js.map +1 -1
  5. package/build/pdfmake.min.js +2 -2
  6. package/build/pdfmake.min.js.map +1 -1
  7. package/build/vfs_fonts.js +11 -11
  8. package/js/3rd-party/svg-to-pdfkit/source.js +3823 -0
  9. package/js/3rd-party/svg-to-pdfkit.js +7 -0
  10. package/js/DocMeasure.js +713 -0
  11. package/js/DocPreprocessor.js +275 -0
  12. package/js/DocumentContext.js +310 -0
  13. package/js/ElementWriter.js +687 -0
  14. package/js/LayoutBuilder.js +1240 -0
  15. package/js/Line.js +113 -0
  16. package/js/OutputDocument.js +64 -0
  17. package/js/OutputDocumentServer.js +29 -0
  18. package/js/PDFDocument.js +144 -0
  19. package/js/PageElementWriter.js +161 -0
  20. package/js/PageSize.js +74 -0
  21. package/js/Printer.js +351 -0
  22. package/js/Renderer.js +417 -0
  23. package/js/SVGMeasure.js +92 -0
  24. package/js/StyleContextStack.js +191 -0
  25. package/js/TableProcessor.js +575 -0
  26. package/js/TextBreaker.js +166 -0
  27. package/js/TextDecorator.js +152 -0
  28. package/js/TextInlines.js +244 -0
  29. package/js/URLResolver.js +43 -0
  30. package/js/base.js +59 -0
  31. package/js/browser-extensions/OutputDocumentBrowser.js +82 -0
  32. package/js/browser-extensions/fonts/Cairo.js +38 -0
  33. package/js/browser-extensions/fonts/Roboto.js +38 -0
  34. package/js/browser-extensions/index.js +59 -0
  35. package/js/browser-extensions/pdfMake.js +3 -0
  36. package/js/browser-extensions/standard-fonts/Courier.js +38 -0
  37. package/js/browser-extensions/standard-fonts/Helvetica.js +38 -0
  38. package/js/browser-extensions/standard-fonts/Symbol.js +23 -0
  39. package/js/browser-extensions/standard-fonts/Times.js +38 -0
  40. package/js/browser-extensions/standard-fonts/ZapfDingbats.js +23 -0
  41. package/js/browser-extensions/virtual-fs-cjs.js +3 -0
  42. package/js/columnCalculator.js +148 -0
  43. package/js/helpers/node.js +123 -0
  44. package/js/helpers/tools.js +46 -0
  45. package/js/helpers/variableType.js +59 -0
  46. package/js/index.js +15 -0
  47. package/js/qrEnc.js +721 -0
  48. package/js/rtlUtils.js +519 -0
  49. package/js/standardPageSizes.js +56 -0
  50. package/js/tableLayouts.js +98 -0
  51. package/js/virtual-fs.js +60 -0
  52. package/package.json +1 -1
  53. package/src/{docMeasure.js → DocMeasure.js} +8 -8
  54. package/src/{elementWriter.js → ElementWriter.js} +3 -3
  55. package/src/{layoutBuilder.js → LayoutBuilder.js} +1406 -1393
  56. package/src/{tableProcessor.js → TableProcessor.js} +633 -620
  57. package/src/rtlUtils.js +503 -500
  58. /package/src/{docPreprocessor.js → DocPreprocessor.js} +0 -0
  59. /package/src/{documentContext.js → DocumentContext.js} +0 -0
  60. /package/src/{line.js → Line.js} +0 -0
  61. /package/src/{pageElementWriter.js → PageElementWriter.js} +0 -0
  62. /package/src/{printer.js → Printer.js} +0 -0
  63. /package/src/{svgMeasure.js → SVGMeasure.js} +0 -0
  64. /package/src/{styleContextStack.js → StyleContextStack.js} +0 -0
  65. /package/src/{textDecorator.js → TextDecorator.js} +0 -0
package/src/rtlUtils.js CHANGED
@@ -1,500 +1,503 @@
1
- /**
2
- * RTL (Right-to-Left) utilities for handling Arabic, Persian (Farsi), and Urdu languages
3
- * Production-ready module for pdfmake RTL support
4
- */
5
-
6
- // Unicode ranges for Arabic script (includes Persian and Urdu characters)
7
- const ARABIC_RANGE = [
8
- [0x0600, 0x06FF], // Arabic block
9
- [0x0750, 0x077F], // Arabic Supplement
10
- [0x08A0, 0x08FF], // Arabic Extended-A
11
- [0xFB50, 0xFDFF], // Arabic Presentation Forms-A
12
- [0xFE70, 0xFEFF] // Arabic Presentation Forms-B
13
- ];
14
-
15
- // Unicode ranges for Persian (Farsi) specific characters
16
- const PERSIAN_RANGE = [
17
- [0x06A9, 0x06AF], // Persian Kaf, Gaf
18
- [0x06C0, 0x06C3], // Persian Heh, Teh Marbuta variants
19
- [0x06CC, 0x06CE], // Persian Yeh variants
20
- [0x06D0, 0x06D5], // Persian Yeh Barree, Arabic-Indic digits
21
- [0x200C, 0x200D] // Zero Width Non-Joiner, Zero Width Joiner (used in Persian)
22
- ];
23
-
24
- // Unicode ranges for Urdu specific characters
25
- const URDU_RANGE = [
26
- [0x0679, 0x0679], // Urdu Tteh
27
- [0x067E, 0x067E], // Urdu Peh
28
- [0x0686, 0x0686], // Urdu Tcheh
29
- [0x0688, 0x0688], // Urdu Ddal
30
- [0x0691, 0x0691], // Urdu Rreh
31
- [0x0698, 0x0698], // Urdu Jeh
32
- [0x06A9, 0x06A9], // Urdu Keheh
33
- [0x06AF, 0x06AF], // Urdu Gaf
34
- [0x06BA, 0x06BA], // Urdu Noon Ghunna
35
- [0x06BE, 0x06BE], // Urdu Heh Doachashmee
36
- [0x06C1, 0x06C1], // Urdu Heh Goal
37
- [0x06D2, 0x06D2], // Urdu Yeh Barree
38
- [0x06D3, 0x06D3] // Urdu Yeh Barree with Hamza
39
- ];
40
-
41
- // Strong RTL characters (Arabic, Persian, Urdu)
42
- const RTL_CHARS = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u200C-\u200D]/;
43
-
44
- // Strong LTR characters (Latin, etc.)
45
- const LTR_CHARS = /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/;
46
-
47
- /**
48
- * Check if a character is in Arabic script (includes Persian and Urdu)
49
- * @param {string} char - Single character to check
50
- * @returns {boolean} - True if character is Arabic/Persian/Urdu
51
- */
52
- function isArabicChar(char) {
53
- const code = char.charCodeAt(0);
54
- return ARABIC_RANGE.some(range => code >= range[0] && code <= range[1]);
55
- }
56
-
57
- /**
58
- * Check if a character is in Persian (Farsi) script
59
- * @param {string} char - Single character to check
60
- * @returns {boolean} - True if character is Persian
61
- */
62
- function isPersianChar(char) {
63
- const code = char.charCodeAt(0);
64
- return PERSIAN_RANGE.some(range => code >= range[0] && code <= range[1]) || isArabicChar(char);
65
- }
66
-
67
- /**
68
- * Check if a character is in Urdu script
69
- * @param {string} char - Single character to check
70
- * @returns {boolean} - True if character is Urdu
71
- */
72
- function isUrduChar(char) {
73
- const code = char.charCodeAt(0);
74
- return URDU_RANGE.some(range => code >= range[0] && code <= range[1]) || isArabicChar(char);
75
- }
76
-
77
- /**
78
- * Check if a character requires RTL rendering
79
- * @param {string} char - Single character to check
80
- * @returns {boolean} - True if character requires RTL
81
- */
82
- function isRTLChar(char) {
83
- return RTL_CHARS.test(char);
84
- }
85
-
86
- /**
87
- * Check if a character is strongly LTR
88
- * @param {string} char - Single character to check
89
- * @returns {boolean} - True if character is strongly LTR
90
- */
91
- function isLTRChar(char) {
92
- return LTR_CHARS.test(char);
93
- }
94
-
95
- /**
96
- * Check if text contains any RTL characters
97
- * @param {string} text - Text to check
98
- * @returns {boolean} - True if text contains RTL characters
99
- */
100
- function containsRTL(text) {
101
- if (!text || typeof text !== 'string') {
102
- return false;
103
- }
104
- return RTL_CHARS.test(text);
105
- }
106
-
107
- /**
108
- * Determine the predominant text direction of a string
109
- * @param {string} text - Text to analyze
110
- * @returns {string} - 'rtl', 'ltr', or 'neutral'
111
- */
112
- function getTextDirection(text) {
113
- if (!text || typeof text !== 'string') {
114
- return 'neutral';
115
- }
116
-
117
- let rtlCount = 0;
118
- let ltrCount = 0;
119
-
120
- for (let i = 0; i < text.length; i++) {
121
- const char = text.charAt(i);
122
- if (isRTLChar(char)) {
123
- rtlCount++;
124
- } else if (isLTRChar(char)) {
125
- ltrCount++;
126
- }
127
- }
128
-
129
- // If we have any strong directional characters
130
- if (rtlCount > 0 || ltrCount > 0) {
131
- if (rtlCount > ltrCount) {
132
- return 'rtl';
133
- } else if (ltrCount > rtlCount) {
134
- return 'ltr';
135
- } else {
136
- // Equal counts - slight preference for RTL if both exist
137
- return rtlCount > 0 ? 'rtl' : 'ltr';
138
- }
139
- }
140
-
141
- return 'neutral';
142
- }
143
-
144
- /**
145
- * Fix bracket directionality for RTL text
146
- * Only mirrors brackets that are in RTL context (adjacent to RTL characters)
147
- * Brackets next to numbers or LTR text are preserved (e.g. "1)" stays as "1)")
148
- * @param {string} text - Text to process
149
- * @returns {string} - Text with contextually mirrored brackets
150
- */
151
- function fixArabicTextUsingReplace(text) {
152
- if (!text || typeof text !== 'string') {
153
- return text;
154
- }
155
-
156
- // Remove leading dot if present
157
- if (text.startsWith('.')) {
158
- text = text.slice(1);
159
- }
160
-
161
- const DIGIT_OR_LTR = /[0-9A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/;
162
- const mirrorMap = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<' };
163
- const openBrackets = { '(': ')', '[': ']', '{': '}', '<': '>' };
164
-
165
- // --- Pre-pass: find matched bracket pairs ---
166
- // Any bracket that is part of a balanced open/close pair within the same
167
- // text should NOT be mirrored, because the PDF engine will display them
168
- // in visual order and mirroring would invert them.
169
- let pairedIndices = new Set();
170
- let stack = [];
171
- for (let i = 0; i < text.length; i++) {
172
- let ch = text[i];
173
- if (openBrackets[ch] !== undefined) {
174
- stack.push({ ch: ch, idx: i });
175
- } else if (ch === ')' || ch === ']' || ch === '}' || ch === '>') {
176
- // Find matching opening bracket on stack
177
- for (let s = stack.length - 1; s >= 0; s--) {
178
- if (openBrackets[stack[s].ch] === ch) {
179
- pairedIndices.add(stack[s].idx);
180
- pairedIndices.add(i);
181
- stack.splice(s, 1);
182
- break;
183
- }
184
- }
185
- }
186
- }
187
-
188
- let result = '';
189
- for (let i = 0; i < text.length; i++) {
190
- const ch = text[i];
191
- if (mirrorMap[ch] !== undefined) {
192
- // If this bracket is part of a balanced pair, do NOT mirror it
193
- if (pairedIndices.has(i)) {
194
- result += ch;
195
- continue;
196
- }
197
-
198
- // Find the previous non-space character
199
- let prevChar = null;
200
- for (let j = i - 1; j >= 0; j--) {
201
- if (text[j] !== ' ') {
202
- prevChar = text[j];
203
- break;
204
- }
205
- }
206
- // Find the next non-space character
207
- let nextChar = null;
208
- for (let j = i + 1; j < text.length; j++) {
209
- if (text[j] !== ' ') {
210
- nextChar = text[j];
211
- break;
212
- }
213
- }
214
-
215
- // If previous char is a digit or LTR letter, bracket belongs to it — don't mirror
216
- // e.g. "1)" or "a)" — the bracket is part of numbering
217
- if (prevChar && DIGIT_OR_LTR.test(prevChar)) {
218
- result += ch;
219
- }
220
- // If next char is a digit or LTR letter and no RTL before, don't mirror
221
- // e.g. "(Hello"
222
- else if (!prevChar && nextChar && DIGIT_OR_LTR.test(nextChar)) {
223
- result += ch;
224
- }
225
- // Otherwise mirror — bracket is in RTL context (unpaired bracket)
226
- else {
227
- result += mirrorMap[ch];
228
- }
229
- } else {
230
- result += ch;
231
- }
232
- }
233
-
234
- return result;
235
- }
236
-
237
- /**
238
- * Apply RTL properties to a node
239
- * @param {object} node - Document node
240
- * @param {boolean} forceRTL - Force RTL regardless of content
241
- * @returns {object} - Node with RTL properties applied
242
- */
243
- function applyRTLToNode(node, forceRTL = false) {
244
- if (!node || typeof node !== 'object') {
245
- return node;
246
- }
247
-
248
- // Determine if node should be RTL
249
- let shouldBeRTL = forceRTL;
250
-
251
- if (!forceRTL && node.text) {
252
- const textStr = typeof node.text === 'string' ? node.text :
253
- Array.isArray(node.text) ? node.text.join('') : '';
254
- shouldBeRTL = getTextDirection(textStr) === 'rtl';
255
- }
256
-
257
- if (shouldBeRTL) {
258
- // Set structural RTL properties (alignment)
259
- // Font is resolved later by TextInlines.measure with priority:
260
- // item font > style font > defaultStyle font > auto-detect (Cairo for RTL, Roboto for LTR)
261
- // Text shaping (bracket mirroring) is handled at render time in ElementWriter
262
- if (!node.alignment) {
263
- node.alignment = 'right';
264
- }
265
- }
266
-
267
- return node;
268
- }
269
-
270
- /**
271
- * Process table for RTL layout
272
- * @param {object} tableNode - Table definition object
273
- * @param {boolean} forceRTL - Force RTL layout
274
- * @returns {object} - Processed table node
275
- */
276
- /**
277
- * Reverse a table row for RTL layout, correctly handling colSpan groups.
278
- * A colSpan cell and its trailing empty placeholders ({}) are kept together
279
- * as a single logical group and reversed as a unit.
280
- * @param {Array} row - Table row array
281
- * @returns {Array} - Reversed row
282
- */
283
- function reverseTableRow(row) {
284
- // Build logical groups: each group is either a single cell or a colSpan cell + its placeholders
285
- let groups = [];
286
- let i = 0;
287
- while (i < row.length) {
288
- let cell = row[i];
289
- let span = (cell && typeof cell === 'object' && cell.colSpan && cell.colSpan > 1) ? cell.colSpan : 1;
290
- let group = row.slice(i, i + span);
291
- groups.push(group);
292
- i += span;
293
- }
294
- // Reverse the groups, then flatten back to a single array
295
- groups.reverse();
296
- let result = [];
297
- for (let g = 0; g < groups.length; g++) {
298
- for (let k = 0; k < groups[g].length; k++) {
299
- result.push(groups[g][k]);
300
- }
301
- }
302
- return result;
303
- }
304
-
305
- function processRTLTable(tableNode, forceRTL = false) {
306
- if (!tableNode || !tableNode.table || !tableNode.table.body) {
307
- return tableNode;
308
- }
309
-
310
- // Support rtl: true directly on the table object: table: { rtl: true, body: [...] }
311
- // This forces RTL layout regardless of content detection
312
- if (tableNode.table.rtl === true) {
313
- forceRTL = true;
314
- }
315
-
316
- // Determine if table should be RTL
317
- let shouldBeRTL = forceRTL;
318
-
319
- if (!forceRTL) {
320
- // Auto-detect based on content
321
- let rtlCellCount = 0;
322
- let totalCells = 0;
323
-
324
- tableNode.table.body.forEach(row => {
325
- if (Array.isArray(row)) {
326
- row.forEach(cell => {
327
- totalCells++;
328
- const cellText = typeof cell === 'string' ? cell :
329
- (cell && cell.text ? (typeof cell.text === 'string' ? cell.text : String(cell.text)) : '');
330
- if (containsRTL(cellText)) {
331
- rtlCellCount++;
332
- }
333
- });
334
- }
335
- });
336
-
337
- // If more than 30% of cells contain RTL content, treat as RTL table
338
- shouldBeRTL = totalCells > 0 && (rtlCellCount / totalCells) >= 0.3;
339
- }
340
-
341
- if (shouldBeRTL) {
342
- // Reverse table columns for RTL layout, handling colSpan correctly
343
- tableNode.table.body = tableNode.table.body.map(row => {
344
- if (Array.isArray(row)) {
345
- return reverseTableRow(row);
346
- }
347
- return row;
348
- });
349
-
350
- // Reverse widths if defined
351
- if (tableNode.table.widths && Array.isArray(tableNode.table.widths)) {
352
- tableNode.table.widths = tableNode.table.widths.slice().reverse();
353
- }
354
-
355
- // Apply RTL properties to cells (skip empty span placeholders)
356
- tableNode.table.body = tableNode.table.body.map(row => {
357
- if (Array.isArray(row)) {
358
- return row.map(cell => {
359
- // Convert string cells to objects so we can set alignment
360
- if (typeof cell === 'string') {
361
- return { text: cell, alignment: 'right' };
362
- }
363
- // Skip null/undefined
364
- if (!cell || typeof cell !== 'object') return cell;
365
- // Skip empty span placeholders {} — they must stay empty for colSpan/rowSpan
366
- if (Object.keys(cell).length === 0) return cell;
367
- // Skip cells that are only span markers (e.g. {_span: true})
368
- if (cell._span) return cell;
369
- return applyRTLToNode(cell, true);
370
- });
371
- }
372
- return row;
373
- });
374
- }
375
-
376
- return tableNode;
377
- }
378
-
379
- /**
380
- * Process list items for RTL support
381
- * @param {Array} listItems - ul/ol content
382
- * @param {boolean} forceRTL - Force RTL layout
383
- * @returns {Array} - Processed list
384
- */
385
- function processRTLList(listItems, forceRTL = false) {
386
- if (!listItems || !Array.isArray(listItems)) {
387
- return listItems;
388
- }
389
-
390
- return listItems.map(item => {
391
- if (typeof item === 'string') {
392
- const shouldBeRTL = forceRTL || getTextDirection(item) === 'rtl';
393
- if (shouldBeRTL) {
394
- return {
395
- text: item,
396
- alignment: 'right'
397
- };
398
- }
399
- return item;
400
- }
401
-
402
- if (typeof item === 'object') {
403
- const shouldBeRTL = forceRTL || (item.text && getTextDirection(String(item.text)) === 'rtl');
404
-
405
- if (shouldBeRTL) {
406
- item = applyRTLToNode(item, true);
407
- }
408
-
409
- // Process nested lists recursively
410
- if (item.ul) {
411
- item.ul = processRTLList(item.ul, forceRTL);
412
- }
413
- if (item.ol) {
414
- item.ol = processRTLList(item.ol, forceRTL);
415
- }
416
- }
417
-
418
- return item;
419
- });
420
- }
421
-
422
- /**
423
- * Process document element for RTL support
424
- * @param {any} element - Document element
425
- * @param {boolean} forceRTL - Force RTL layout
426
- * @returns {any} - Processed element
427
- */
428
- function processRTLElement(element, forceRTL = false) {
429
- if (!element) {
430
- return element;
431
- }
432
-
433
- // Handle arrays
434
- if (Array.isArray(element)) {
435
- return element.map(item => processRTLElement(item, forceRTL));
436
- }
437
-
438
- // Handle strings
439
- if (typeof element === 'string') {
440
- const shouldBeRTL = forceRTL || getTextDirection(element) === 'rtl';
441
- if (shouldBeRTL) {
442
- return {
443
- text: element,
444
- alignment: 'right'
445
- };
446
- }
447
- return element;
448
- }
449
-
450
- // Handle objects
451
- if (typeof element === 'object') {
452
- // Check for explicit rtl property
453
- const elementForceRTL = element.rtl === true || forceRTL;
454
-
455
- // Process text nodes
456
- if (element.text !== undefined) {
457
- element = applyRTLToNode(element, elementForceRTL);
458
- }
459
-
460
- // Process tables
461
- if (element.table) {
462
- element = processRTLTable(element, elementForceRTL);
463
- }
464
-
465
- // Process lists
466
- if (element.ul) {
467
- element.ul = processRTLList(element.ul, elementForceRTL);
468
- }
469
- if (element.ol) {
470
- element.ol = processRTLList(element.ol, elementForceRTL);
471
- }
472
-
473
- // Process columns
474
- if (element.columns && Array.isArray(element.columns)) {
475
- element.columns = element.columns.map(col => processRTLElement(col, elementForceRTL));
476
- }
477
-
478
- // Process stack
479
- if (element.stack && Array.isArray(element.stack)) {
480
- element.stack = element.stack.map(item => processRTLElement(item, elementForceRTL));
481
- }
482
- }
483
-
484
- return element;
485
- }
486
-
487
- export {
488
- isArabicChar,
489
- isPersianChar,
490
- isUrduChar,
491
- isRTLChar,
492
- isLTRChar,
493
- containsRTL,
494
- getTextDirection,
495
- fixArabicTextUsingReplace,
496
- applyRTLToNode,
497
- processRTLTable,
498
- processRTLList,
499
- processRTLElement
500
- };
1
+ /**
2
+ * RTL (Right-to-Left) utilities for handling Arabic, Persian (Farsi), and Urdu languages
3
+ * Production-ready module for pdfmake RTL support
4
+ */
5
+
6
+ // Unicode ranges for Arabic script (includes Persian and Urdu characters)
7
+ const ARABIC_RANGE = [
8
+ [0x0600, 0x06FF], // Arabic block
9
+ [0x0750, 0x077F], // Arabic Supplement
10
+ [0x08A0, 0x08FF], // Arabic Extended-A
11
+ [0xFB50, 0xFDFF], // Arabic Presentation Forms-A
12
+ [0xFE70, 0xFEFF] // Arabic Presentation Forms-B
13
+ ];
14
+
15
+ // Unicode ranges for Persian (Farsi) specific characters
16
+ const PERSIAN_RANGE = [
17
+ [0x06A9, 0x06AF], // Persian Kaf, Gaf
18
+ [0x06C0, 0x06C3], // Persian Heh, Marbuta variants
19
+ [0x06CC, 0x06CE], // Persian Yeh variants
20
+ [0x06D0, 0x06D5], // Persian Yeh Barree, Arabic-Indic digits
21
+ [0x200C, 0x200D] // Zero Width Non-Joiner, Zero Width Joiner (used in Persian)
22
+ ];
23
+
24
+ // Unicode ranges for Urdu specific characters
25
+ const URDU_RANGE = [
26
+ [0x0679, 0x0679], // Urdu Tteh
27
+ [0x067E, 0x067E], // Urdu Peh
28
+ [0x0686, 0x0686], // Urdu Tcheh
29
+ [0x0688, 0x0688], // Urdu Ddal
30
+ [0x0691, 0x0691], // Urdu Rreh
31
+ [0x0698, 0x0698], // Urdu Jeh
32
+ [0x06A9, 0x06A9], // Urdu Keheh
33
+ [0x06AF, 0x06AF], // Urdu Gaf
34
+ [0x06BA, 0x06BA], // Urdu Noon Ghunna
35
+ [0x06BE, 0x06BE], // Urdu Heh Doachashmee
36
+ [0x06C1, 0x06C1], // Urdu Heh Goal
37
+ [0x06D2, 0x06D2], // Urdu Yeh Barree
38
+ [0x06D3, 0x06D3] // Urdu Yeh Barree with Hamza
39
+ ];
40
+
41
+ // Strong RTL characters (Arabic, Persian, Urdu)
42
+ const RTL_CHARS = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFF\u200C-\u200D]/;
43
+
44
+ // Strong LTR characters (Latin, etc.)
45
+ const LTR_CHARS = /[A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/;
46
+
47
+ /**
48
+ * Check if a character is in Arabic script (includes Persian and Urdu)
49
+ * @param {string} char - Single character to check
50
+ * @returns {boolean} - True if character is Arabic/Persian/Urdu
51
+ */
52
+ function isArabicChar(char) {
53
+ const code = char.charCodeAt(0);
54
+ return ARABIC_RANGE.some(range => code >= range[0] && code <= range[1]);
55
+ }
56
+
57
+ /**
58
+ * Check if a character is in Persian (Farsi) script
59
+ * @param {string} char - Single character to check
60
+ * @returns {boolean} - True if character is Persian
61
+ */
62
+ function isPersianChar(char) {
63
+ const code = char.charCodeAt(0);
64
+ return PERSIAN_RANGE.some(range => code >= range[0] && code <= range[1]) || isArabicChar(char);
65
+ }
66
+
67
+ /**
68
+ * Check if a character is in Urdu script
69
+ * @param {string} char - Single character to check
70
+ * @returns {boolean} - True if character is Urdu
71
+ */
72
+ function isUrduChar(char) {
73
+ const code = char.charCodeAt(0);
74
+ return URDU_RANGE.some(range => code >= range[0] && code <= range[1]) || isArabicChar(char);
75
+ }
76
+
77
+ /**
78
+ * Check if a character requires RTL rendering
79
+ * @param {string} char - Single character to check
80
+ * @returns {boolean} - True if character requires RTL
81
+ */
82
+ function isRTLChar(char) {
83
+ return RTL_CHARS.test(char);
84
+ }
85
+
86
+ /**
87
+ * Check if a character is strongly LTR
88
+ * @param {string} char - Single character to check
89
+ * @returns {boolean} - True if character is strongly LTR
90
+ */
91
+ function isLTRChar(char) {
92
+ return LTR_CHARS.test(char);
93
+ }
94
+
95
+ /**
96
+ * Check if text contains any RTL characters
97
+ * @param {string} text - Text to check
98
+ * @returns {boolean} - True if text contains RTL characters
99
+ */
100
+ function containsRTL(text) {
101
+ if (!text || typeof text !== 'string') {
102
+ return false;
103
+ }
104
+ return RTL_CHARS.test(text);
105
+ }
106
+
107
+ /**
108
+ * Determine the predominant text direction of a string
109
+ * @param {string} text - Text to analyze
110
+ * @returns {string} - 'rtl', 'ltr', or 'neutral'
111
+ */
112
+ function getTextDirection(text) {
113
+ if (!text || typeof text !== 'string') {
114
+ return 'neutral';
115
+ }
116
+
117
+ let rtlCount = 0;
118
+ let ltrCount = 0;
119
+
120
+ for (let i = 0; i < text.length; i++) {
121
+ const char = text.charAt(i);
122
+ if (isRTLChar(char)) {
123
+ rtlCount++;
124
+ } else if (isLTRChar(char)) {
125
+ ltrCount++;
126
+ }
127
+ }
128
+
129
+ // If we have any strong directional characters
130
+ if (rtlCount > 0 || ltrCount > 0) {
131
+ if (rtlCount > ltrCount) {
132
+ return 'rtl';
133
+ } else if (ltrCount > rtlCount) {
134
+ return 'ltr';
135
+ } else {
136
+ // Equal counts - slight preference for RTL if both exist
137
+ return rtlCount > 0 ? 'rtl' : 'ltr';
138
+ }
139
+ }
140
+
141
+ return 'neutral';
142
+ }
143
+
144
+ /**
145
+ * Fix bracket directionality for RTL text
146
+ * Only mirrors brackets that are in RTL context (adjacent to RTL characters)
147
+ * Brackets next to numbers or LTR text are preserved (e.g. "1)" stays as "1)")
148
+ * @param {string} text - Text to process
149
+ * @returns {string} - Text with contextually mirrored brackets
150
+ */
151
+ function fixArabicTextUsingReplace(text) {
152
+ if (!text || typeof text !== 'string') {
153
+ return text;
154
+ }
155
+
156
+ // Remove leading dot if present
157
+ if (text.startsWith('.')) {
158
+ text = text.slice(1);
159
+ }
160
+
161
+ const DIGIT_OR_LTR = /[0-9A-Za-z\u00C0-\u024F\u1E00-\u1EFF]/;
162
+ const mirrorMap = { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<' };
163
+ const openBrackets = { '(': ')', '[': ']', '{': '}', '<': '>' };
164
+
165
+ // --- Pre-pass: find matched bracket pairs ---
166
+ // Any bracket that is part of a balanced open/close pair within the same
167
+ // text should NOT be mirrored, because the PDF engine will display them
168
+ // in visual order and mirroring would invert them.
169
+ let pairedIndices = new Set();
170
+ let stack = [];
171
+ for (let i = 0; i < text.length; i++) {
172
+ let ch = text[i];
173
+ if (openBrackets[ch] !== undefined) {
174
+ stack.push({ ch: ch, idx: i });
175
+ } else if (ch === ')' || ch === ']' || ch === '}' || ch === '>') {
176
+ // Find matching opening bracket on stack
177
+ for (let s = stack.length - 1; s >= 0; s--) {
178
+ if (openBrackets[stack[s].ch] === ch) {
179
+ pairedIndices.add(stack[s].idx);
180
+ pairedIndices.add(i);
181
+ stack.splice(s, 1);
182
+ break;
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ let result = '';
189
+ for (let i = 0; i < text.length; i++) {
190
+ const ch = text[i];
191
+ if (mirrorMap[ch] !== undefined) {
192
+ // If this bracket is part of a balanced pair, do NOT mirror it
193
+ if (pairedIndices.has(i)) {
194
+ result += ch;
195
+ continue;
196
+ }
197
+
198
+ // Find the previous non-space character
199
+ let prevChar = null;
200
+ for (let j = i - 1; j >= 0; j--) {
201
+ if (text[j] !== ' ') {
202
+ prevChar = text[j];
203
+ break;
204
+ }
205
+ }
206
+ // Find the next non-space character
207
+ let nextChar = null;
208
+ for (let j = i + 1; j < text.length; j++) {
209
+ if (text[j] !== ' ') {
210
+ nextChar = text[j];
211
+ break;
212
+ }
213
+ }
214
+
215
+ // If previous char is a digit or LTR letter, bracket belongs to it — don't mirror
216
+ // e.g. "1)" or "a)" — the bracket is part of numbering
217
+ if (prevChar && DIGIT_OR_LTR.test(prevChar)) {
218
+ result += ch;
219
+ }
220
+ // If next char is a digit or LTR letter and no RTL before, don't mirror
221
+ // e.g. "(Hello"
222
+ else if (!prevChar && nextChar && DIGIT_OR_LTR.test(nextChar)) {
223
+ result += ch;
224
+ }
225
+ // Otherwise mirror — bracket is in RTL context (unpaired bracket)
226
+ else {
227
+ result += mirrorMap[ch];
228
+ }
229
+ } else {
230
+ result += ch;
231
+ }
232
+ }
233
+
234
+ return result;
235
+ }
236
+
237
+ /**
238
+ * Apply RTL properties to a node
239
+ * @param {object} node - Document node
240
+ * @param {boolean} forceRTL - Force RTL regardless of content
241
+ * @returns {object} - Node with RTL properties applied
242
+ */
243
+ function applyRTLToNode(node, forceRTL = false) {
244
+ if (!node || typeof node !== 'object') {
245
+ return node;
246
+ }
247
+
248
+ // Determine if node should be RTL
249
+ let shouldBeRTL = forceRTL;
250
+
251
+ if (!forceRTL && node.text) {
252
+ const textStr = typeof node.text === 'string' ? node.text :
253
+ Array.isArray(node.text) ? node.text.join('') : '';
254
+ shouldBeRTL = getTextDirection(textStr) === 'rtl';
255
+ }
256
+
257
+ if (shouldBeRTL) {
258
+ // Set structural RTL properties (alignment)
259
+ // Font is resolved later by TextInlines.measure with priority:
260
+ // item font > style font > defaultStyle font > auto-detect (Cairo for RTL, Roboto for LTR)
261
+ // Text shaping (bracket mirroring) is handled at render time in ElementWriter
262
+ if (!node.alignment) {
263
+ node.alignment = 'right';
264
+ }
265
+ }
266
+
267
+ return node;
268
+ }
269
+
270
+ /**
271
+ * Process table for RTL layout
272
+ * @param {object} tableNode - Table definition object
273
+ * @param {boolean} forceRTL - Force RTL layout
274
+ * @returns {object} - Processed table node
275
+ */
276
+ /**
277
+ * Reverse a table row for RTL layout, correctly handling colSpan groups.
278
+ * A colSpan cell and its trailing empty placeholders ({}) are kept together
279
+ * as a single logical group and reversed as a unit.
280
+ * @param {Array} row - Table row array
281
+ * @returns {Array} - Reversed row
282
+ */
283
+ function reverseTableRow(row) {
284
+ // Build logical groups: each group is either a single cell or a colSpan cell + its placeholders
285
+ let groups = [];
286
+ let i = 0;
287
+ while (i < row.length) {
288
+ let cell = row[i];
289
+ let span = (cell && typeof cell === 'object' && cell.colSpan && cell.colSpan > 1) ? cell.colSpan : 1;
290
+ let group = row.slice(i, i + span);
291
+ groups.push(group);
292
+ i += span;
293
+ }
294
+ // Reverse the groups, then flatten back to a single array
295
+ groups.reverse();
296
+ let result = [];
297
+ for (let g = 0; g < groups.length; g++) {
298
+ for (let k = 0; k < groups[g].length; k++) {
299
+ result.push(groups[g][k]);
300
+ }
301
+ }
302
+ return result;
303
+ }
304
+
305
+ function processRTLTable(tableNode, forceRTL = false) {
306
+ if (!tableNode || !tableNode.table || !tableNode.table.body) {
307
+ return tableNode;
308
+ }
309
+
310
+ // Support rtl: true directly on the table object: table: { rtl: true, body: [...] }
311
+ // This forces RTL layout regardless of content detection
312
+ if (tableNode.table.rtl === true) {
313
+ forceRTL = true;
314
+ }
315
+
316
+ // Determine if table should be RTL
317
+ let shouldBeRTL = forceRTL;
318
+
319
+ if (!forceRTL) {
320
+ // Auto-detect based on content
321
+ let rtlCellCount = 0;
322
+ let totalCells = 0;
323
+
324
+ tableNode.table.body.forEach(row => {
325
+ if (Array.isArray(row)) {
326
+ row.forEach(cell => {
327
+ totalCells++;
328
+ const cellText = typeof cell === 'string' ? cell :
329
+ (cell && cell.text ? (typeof cell.text === 'string' ? cell.text : String(cell.text)) : '');
330
+ if (containsRTL(cellText)) {
331
+ rtlCellCount++;
332
+ }
333
+ });
334
+ }
335
+ });
336
+
337
+ // If more than 30% of cells contain RTL content, treat as RTL table
338
+ shouldBeRTL = totalCells > 0 && (rtlCellCount / totalCells) >= 0.3;
339
+ }
340
+
341
+ if (shouldBeRTL) {
342
+ // Mark the table as RTL for the drawing phase (TableProcessor uses this)
343
+ tableNode.table._rtl = true;
344
+
345
+ // Reverse table columns for RTL layout, handling colSpan correctly
346
+ tableNode.table.body = tableNode.table.body.map(row => {
347
+ if (Array.isArray(row)) {
348
+ return reverseTableRow(row);
349
+ }
350
+ return row;
351
+ });
352
+
353
+ // Reverse widths if defined
354
+ if (tableNode.table.widths && Array.isArray(tableNode.table.widths)) {
355
+ tableNode.table.widths = tableNode.table.widths.slice().reverse();
356
+ }
357
+
358
+ // Apply RTL properties to cells (skip empty span placeholders)
359
+ tableNode.table.body = tableNode.table.body.map(row => {
360
+ if (Array.isArray(row)) {
361
+ return row.map(cell => {
362
+ // Convert string cells to objects so we can set alignment
363
+ if (typeof cell === 'string') {
364
+ return { text: cell, alignment: 'right' };
365
+ }
366
+ // Skip null/undefined
367
+ if (!cell || typeof cell !== 'object') return cell;
368
+ // Skip empty span placeholders {} — they must stay empty for colSpan/rowSpan
369
+ if (Object.keys(cell).length === 0) return cell;
370
+ // Skip cells that are only span markers (e.g. {_span: true})
371
+ if (cell._span) return cell;
372
+ return applyRTLToNode(cell, true);
373
+ });
374
+ }
375
+ return row;
376
+ });
377
+ }
378
+
379
+ return tableNode;
380
+ }
381
+
382
+ /**
383
+ * Process list items for RTL support
384
+ * @param {Array} listItems - ul/ol content
385
+ * @param {boolean} forceRTL - Force RTL layout
386
+ * @returns {Array} - Processed list
387
+ */
388
+ function processRTLList(listItems, forceRTL = false) {
389
+ if (!listItems || !Array.isArray(listItems)) {
390
+ return listItems;
391
+ }
392
+
393
+ return listItems.map(item => {
394
+ if (typeof item === 'string') {
395
+ const shouldBeRTL = forceRTL || getTextDirection(item) === 'rtl';
396
+ if (shouldBeRTL) {
397
+ return {
398
+ text: item,
399
+ alignment: 'right'
400
+ };
401
+ }
402
+ return item;
403
+ }
404
+
405
+ if (typeof item === 'object') {
406
+ const shouldBeRTL = forceRTL || (item.text && getTextDirection(String(item.text)) === 'rtl');
407
+
408
+ if (shouldBeRTL) {
409
+ item = applyRTLToNode(item, true);
410
+ }
411
+
412
+ // Process nested lists recursively
413
+ if (item.ul) {
414
+ item.ul = processRTLList(item.ul, forceRTL);
415
+ }
416
+ if (item.ol) {
417
+ item.ol = processRTLList(item.ol, forceRTL);
418
+ }
419
+ }
420
+
421
+ return item;
422
+ });
423
+ }
424
+
425
+ /**
426
+ * Process document element for RTL support
427
+ * @param {any} element - Document element
428
+ * @param {boolean} forceRTL - Force RTL layout
429
+ * @returns {any} - Processed element
430
+ */
431
+ function processRTLElement(element, forceRTL = false) {
432
+ if (!element) {
433
+ return element;
434
+ }
435
+
436
+ // Handle arrays
437
+ if (Array.isArray(element)) {
438
+ return element.map(item => processRTLElement(item, forceRTL));
439
+ }
440
+
441
+ // Handle strings
442
+ if (typeof element === 'string') {
443
+ const shouldBeRTL = forceRTL || getTextDirection(element) === 'rtl';
444
+ if (shouldBeRTL) {
445
+ return {
446
+ text: element,
447
+ alignment: 'right'
448
+ };
449
+ }
450
+ return element;
451
+ }
452
+
453
+ // Handle objects
454
+ if (typeof element === 'object') {
455
+ // Check for explicit rtl property
456
+ const elementForceRTL = element.rtl === true || forceRTL;
457
+
458
+ // Process text nodes
459
+ if (element.text !== undefined) {
460
+ element = applyRTLToNode(element, elementForceRTL);
461
+ }
462
+
463
+ // Process tables
464
+ if (element.table) {
465
+ element = processRTLTable(element, elementForceRTL);
466
+ }
467
+
468
+ // Process lists
469
+ if (element.ul) {
470
+ element.ul = processRTLList(element.ul, elementForceRTL);
471
+ }
472
+ if (element.ol) {
473
+ element.ol = processRTLList(element.ol, elementForceRTL);
474
+ }
475
+
476
+ // Process columns
477
+ if (element.columns && Array.isArray(element.columns)) {
478
+ element.columns = element.columns.map(col => processRTLElement(col, elementForceRTL));
479
+ }
480
+
481
+ // Process stack
482
+ if (element.stack && Array.isArray(element.stack)) {
483
+ element.stack = element.stack.map(item => processRTLElement(item, elementForceRTL));
484
+ }
485
+ }
486
+
487
+ return element;
488
+ }
489
+
490
+ export {
491
+ isArabicChar,
492
+ isPersianChar,
493
+ isUrduChar,
494
+ isRTLChar,
495
+ isLTRChar,
496
+ containsRTL,
497
+ getTextDirection,
498
+ fixArabicTextUsingReplace,
499
+ applyRTLToNode,
500
+ processRTLTable,
501
+ processRTLList,
502
+ processRTLElement
503
+ };