-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
528 lines (388 loc) · 15.8 KB
/
index.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
/*******************************************************************************
**
** ANATOLY IVANOV / DESIGN
** https://anatolyivanov.com/
**
** Copyright © Anatoly IVANOV .com, all rights reserved.
** Licensed under the GNU General Public License v3.0.
**
** Subject to and governed by the Berne Convention and French intellectual
** property law. For a primer on both, read:
** https://anatolyivanov.com/methods/legal/usage_rights_primer/
**
** For full terms of the GPL-3.0 license, see the LICENSE file or:
** https://www.gnu.org/licenses/gpl-3.0.en.html
**
**
** RTF DOCUMENTATION: docs/RTF_basics.md
**
******************************************************************************/
/*******************************************************************************
**
** LOCAL CONFIG
**
******************************************************************************/
// Debug (prints Markdown before conversion and RTF as copied into clipboard)
const bool_Debug = false;
// RTF header
const RTF_HEADER = '{\\rtf1\\ansi\\uc1\n';
// RTF fonts
// - bold and monospaced will show up as `Direct Formatting` in Word
const RTF_FONT_TABLE = '{\\fonttbl\n' +
'{\\f0 Myriad;}\n' + // Main font
'{\\f1 Myriad Pro Semibold;}\n' + // Bold sections
'{\\f2 Consolas;}\n' + // Monospaced sections
'}\n';
// RTF colors (RGB)
// - bold and monospaced will show up as `Direct Formatting` in Word
const RTF_COLOR_TABLE = '{\\colortbl\n' +
'{;}\n' + // Default empty color (required to simulated "automatic" in Word)
'{\\red49\\green132\\blue155;}\n' + // Bold character style color
'{\\red0\\green112\\blue192;}\n' + // Monospaced character style color
'}\n';
// RTF Style Definitions
// - MUST match Word styles (defined in advance); will not be `Direct Formatting`
const RTF_STYLESHEET = '{\\stylesheet\n' +
'{\\s0 Normal;}\n' +
'{\\s1 Heading 1;}\n' +
'{\\s2 Heading 2;}\n' +
'{\\s3 Heading 3;}\n' +
'{\\s4 Heading 4;}\n' +
'{\\s5 Heading 5;}\n' +
'{\\s6 Bullets level 1;}\n' +
'{\\s7 Bullets level 2;}\n' +
'}\n';
// Markdown symbology to Word styles map -- headings
const obj_HeadingStyles = {
'#': '\\s1', // Heading 1
'##': '\\s1', // Heading 1
'###': '\\s1', // Heading 1 (starting point)
'####': '\\s2', // Heading 2
};
// Markdown symbology to Word styles map -- lists
const obj_ListStyles = {
'-': '\\s6', // Bullets level 1
'--': '\\s7', // Bullets level 2
};
/*******************************************************************************
**
** IMPORT MODULES -- NPM -- GLOBAL
**
******************************************************************************/
import clipboard from 'clipboardy';
/*******************************************************************************
**
** UTILITY FUNCTIONS
**
******************************************************************************/
/**
* Returns a colored text string for output to the console.
*
* @param {string} str_Text - The text to color.
* @param {string} str_ConsoleColorCode - The color code name ('blue', 'red'…)
* @return {string} str_Text - Wrapped in the specified color code, or unmodified
* if color is invalid.
*/
function console_color_conv ( str_Text, str_ConsoleColorCode ) {
// ANSI escape colors
const obj_ConsoleColorCodes = {
black: '\x1b[30m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
white: '\x1b[37m'
};
/**
* Check if:
*
* - str_ConsoleColorCode is defined
* - if it is defined, convert the case of str_ConsoleColorCode to lowercase…
* just in case :p (so Blue or BLUE will both work)
* - check whether the lower case str_ConsoleColorCode matches any key in
* obj_ConsoleColorCodes, prepend the correct ANSI escape chars, append the
* reset escape code, return the result
* - otherwise, fallback to return str_Text unmodified
*/
return str_ConsoleColorCode ? `${obj_ConsoleColorCodes[str_ConsoleColorCode.toLowerCase()]}${str_Text}\x1b[0m` : str_Text;
}
async function catch_message_print_exit ( obj_Error = {}, str_ErrorMessage = '', str_SolutionMessage = '', bool_Exit = false ) {
// If a specific human-readable error message is provided
if ( str_ErrorMessage ) {
console.log( `- ${str_ErrorMessage }` );
}
// If a possible solution is provided
if ( str_SolutionMessage ) {
console.log( `- ${str_SolutionMessage}` );
}
// Print out the Node.js error
console.log( `- ${obj_Error}` );
// If exit is requested
if ( bool_Exit === true ) {
// Exit the Node.js process and its children
console.log( `Exiting... (shutting down Node.js process)` );
console.log(); // a spacer for legibility
process.exit();
}
}
/**
* Escapes RTF special characters and handles Unicode.
*/
function RTF_special_chars_escape ( str_Text ) {
return str_Text
.replace( /\\/g, '\\\\' ) // Escape backslash
.replace( /{/g, '\\{' ) // Escape left brace
.replace( /}/g, '\\}' ) // Escape right brace
.replace( /[\u0080-\uFFFF]/gu, ( match ) => {
const codePoint = match.codePointAt( 0 );
// Skip characters in the Private Use Area (PUA)
if (
( codePoint >= 0xE000 && codePoint <= 0xF8FF ) || // BMP PUA
( codePoint >= 0xF0000 && codePoint <= 0xFFFFD ) || // Plane 15 PUA
( codePoint >= 0x100000 && codePoint <= 0x10FFFD ) // Plane 16 PUA
) {
return ''; // Remove the character
}
// Adjust code point for signed 16-bit integer
const N = codePoint <= 32767 ? codePoint : codePoint - 65536;
return `\\u${N}?`;
} );
}
/**
* Converts Title Case into Sentence case, except when it's an acronym
*
* @param {string} str_Text
* @return {string} Sentence case of str_Text
*/
function title_case_to_sentence_case_conv ( str_Text ) {
// Split the string into words using the space as a separator
const arr_WordsOriginal = str_Text.split( ' ' );
// Iterate through each word
const arr_WordsTransformed = arr_WordsOriginal.map( ( str_Word, int_Index ) => {
// Check if the word is fully uppercase (acronym)
if ( str_Word === str_Word.toUpperCase() && str_Word.length > 1 ) {
return str_Word; // Return acronyms as is, without transformation
}
// For the first word, apply Sentence case logic
if ( int_Index === 0 ) {
return str_Word.charAt( 0 ).toLocaleUpperCase() + str_Word.slice( 1 ).toLocaleLowerCase();
}
// For other words, convert to lowercase (if not an acronym)
return str_Word.toLocaleLowerCase();
} );
// Join words back into a single string with spaces
return arr_WordsTransformed.join( ' ' );
}
/**
* Replaces inline Markdown formatting with RTF control words.
*
* @param {string} str_Text - The text to format.
* @param {boolean} bool_IsHeading - Whether the text is inside a heading.
* @return {string} - The formatted text.
*/
function replace_inline_formatting ( str_Text, bool_IsHeading = false ) {
// Escape special RTF characters first
// (otherwise wreaks havoc on output, reason unknown)
str_Text = RTF_special_chars_escape( str_Text );
// Handle bold '**text**' using RTF bold control words, but only if not in heading
if ( !bool_IsHeading ) {
str_Text = str_Text.replace( /\*\*(.+?)\*\*/g, ( match, p1 ) => {
// Replace bold markers with color and typography, but first
// convert to Sentence case (because I use EN, FR & RU conventions)
// NB: trailing space required for correct Word import
// (otherwise the control word is ignored -- reason unknown)
return `\\cf1\\f1\\b1 ${title_case_to_sentence_case_conv(p1)}\\b0\\f0\\cf0 `;
} );
}
else {
// In headings with bold markers, remove them
// (Word styles already define the proper font weight)
str_Text = str_Text.replace( /\*\*(.+?)\*\*/g, "$1" );
// In all cases, convert to Sentence case (because I use EN, FR & RU conventions)
str_Text = title_case_to_sentence_case_conv( str_Text );
return str_Text;
}
// Search for inline code '`code`'
str_Text = str_Text.replace( /`(.+?)`/g, ( match, p1 ) => {
// If matched, apply RTF monospaced color and font, then revert to defaults
// NB: trailing space required for correct Word import
// (otherwise the control word is ignored -- reason unknown)
return `\\cf2\\f2 ${p1}\\f0\\cf0 `;
} );
// Search for inline italics '*text*'
str_Text = str_Text.replace( /\*(.+?)\*/g, ( match, p1 ) => {
// If matched, apply RTF italics, then revert to defaults
// NB: trailing space required for correct Word import
// (otherwise the control word is ignored -- reason unknown)
return `\\i ${p1}\\i0 `;
} );
return str_Text;
}
/*******************************************************************************
**
** MAIN CONVERSION LOGIC
**
******************************************************************************/
/**
* Converts Markdown content to RTF format.
* @param {string} markdown - The Markdown content.
* @return {string} - The RTF formatted content.
*
* NB: An RTF paragraph starts with \sN and ends with \par
*/
function transform_markdown_to_rtf ( markdown ) {
let str_RTFcontent = RTF_HEADER;
/* --------------------------------------------
* Add font table, color table, and stylesheet
* -------------------------------------------- */
str_RTFcontent += RTF_FONT_TABLE;
str_RTFcontent += RTF_COLOR_TABLE;
str_RTFcontent += RTF_STYLESHEET;
// Start the document without additional formatting
str_RTFcontent += '\n';
// Parse the Markdown content
const arr_Lines = markdown.split( '\n' );
// Iterate through lines of text
for ( let str_Line of arr_Lines ) {
// Trim whitespace
str_Line = str_Line.trim();
// Skip empty lines
if ( str_Line === '' ) {
continue;
}
// Init the RTF line-by-line var
let str_RTFLine = '';
// Keep track of formatting applied -- set the flag to false by default
let bool_FormattingApplied = false;
/* --------------------------------------------
* Check for headings
* -------------------------------------------- */
// Iterate through the Markdown symbology to Word styles map in the config
for ( const [ str_MDprefix, str_RTFstyle ] of Object.entries( obj_HeadingStyles ) ) {
// For each line in the config object, check whether it matches
if ( str_Line.startsWith( str_MDprefix + ' ' ) ) {
// Extract the contents of the heading
let str_Content = str_Line.substring( str_MDprefix.length + 1 ).trim();
// Remove numbering prefixes and colons
str_Content = str_Content.replace( /^(\d+[\.\)]\s*)?(\w[\.\)]\s*)?/, '' ).trim();
// Remove trailing colon if present (Some text:)
str_Content = str_Content.replace( /:$/, '' ).trim();
// Remove markdown (**Some text**)
str_Content = replace_inline_formatting( str_Content, true );
// Close the paragraph
str_RTFLine += `${str_RTFstyle} ${str_Content}\\par \n`;
// Set the flag as formatting applied
bool_FormattingApplied = true;
// Get out of the loop
break;
}
}
/* --------------------------------------------
* Check for bold-only headings
* Example: **4. Some text**
* -------------------------------------------- */
if ( !bool_FormattingApplied ) {
// Looking for bold-only heading pattern
const arr_RegExMatch_BoldOnlyHeading = str_Line.match( /^\*\*(\d+\.\s*)?(.+?)\*\*$/ );
// OK, we have a match
if ( arr_RegExMatch_BoldOnlyHeading ) {
// Extract content without the number
let str_HeadingText = arr_RegExMatch_BoldOnlyHeading[ 2 ].trim();
// Remove bold markers in heading (bool_IsHeading = true)
str_HeadingText = replace_inline_formatting( str_HeadingText, true );
// Format as Heading 2 (\s2)
str_RTFLine += `\\s2 ${str_HeadingText}\\par \n`;
// Set the flag as formatting applied
bool_FormattingApplied = true;
}
}
/* --------------------------------------------
* Check for list items with bold text and colon
* Example: - **Some Text**: description
* -------------------------------------------- */
if ( !bool_FormattingApplied ) {
// Looking for `- **Some Text**` to be later split into h3 and bullets
const arr_RegExMatch_BoldStart = str_Line.match( /^- \*\*(.+?)\*\*:\s*(.*)/ );
// OK, we have a match
if ( arr_RegExMatch_BoldStart ) {
// Populate the strings for the h3 and bullets
let str_H3text = arr_RegExMatch_BoldStart[ 1 ];
let str_BulletText = arr_RegExMatch_BoldStart[ 2 ];
// Remove bold markers in heading (bool_IsHeading = true)
str_H3text = replace_inline_formatting( str_H3text, true );
// Format bullet text (bool_IsHeading = false)
str_BulletText = replace_inline_formatting( str_BulletText, false );
// Append Heading 3 -- using **Some text** for content
str_RTFLine += `\\s3 ${str_H3text}\\par \n`;
// Append Bullets level 1 -- using text following **Some text** for content
str_RTFLine += `\\s6 ${str_BulletText}\\par \n`;
// Set the flag as formatting applied
bool_FormattingApplied = true;
}
}
/* --------------------------------------------
* Check for list items
* -------------------------------------------- */
if ( !bool_FormattingApplied ) {
// Iterate through the Markdown symbology to Word styles map in the config
for ( const [ str_MDprefix, str_RTFstyle ] of Object.entries( obj_ListStyles ) ) {
// For each line in the config object, check whether it matches
if ( str_Line.startsWith( str_MDprefix + ' ' ) ) {
// Populate the strings for the bullets
let str_Content = str_Line.substring( str_MDprefix.length + 1 ).trim();
// Format bullet text (bool_IsHeading = false)
str_Content = replace_inline_formatting( str_Content, false ); // bool_IsHeading = false
// Append the bullet text
str_RTFLine += `${str_RTFstyle} ${str_Content}\\par \n`;
// Set the flag as formatting applied
bool_FormattingApplied = true;
// Get out of the loop
break;
}
}
}
/* --------------------------------------------
* Regular paragraph
* -------------------------------------------- */
// The least complicated, just format and append
if ( !bool_FormattingApplied ) {
let str_Content = replace_inline_formatting( str_Line, false );
str_RTFLine += `\\s0 ${str_Content}\\par \n`;
}
// Add the converted RTF line to... the RTF
str_RTFcontent += str_RTFLine + '\n';
}
// Close the RTF document
str_RTFcontent += '}';
return str_RTFcontent;
}
/*******************************************************************************
**
** READ AND CONVERT THE CLIPBOARD
**
******************************************************************************/
/**
* Reads the clipboard, converts Markdown to RTF, and writes back to the clipboard.
*/
async function clipboard_md_to_rtf_conv () {
try {
const str_OriginalClipboard = await clipboard.read();
if ( bool_Debug ) {
console.log( 'Original Clipboard Content:\n', str_OriginalClipboard );
}
const str_TransformedContent = transform_markdown_to_rtf( str_OriginalClipboard );
if ( bool_Debug ) {
console.log( 'Transformed Content:\n', str_TransformedContent );
}
await clipboard.write( str_TransformedContent );
// Output the processing to the CLI
console.log( `${console_color_conv('Ready to paste into Word', 'blue')}` );
}
catch ( obj_err_General ) {
catch_message_print_exit( obj_err_General, 'Some major error somewhere.', 'Try to backtrace...', true );
}
}
// Run
clipboard_md_to_rtf_conv();