@dytsou/resume-converter 2.0.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.
@@ -0,0 +1,774 @@
1
+ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs';
2
+ import { join, basename, extname } from 'path';
3
+ import { parse } from '@unified-latex/unified-latex-util-parse';
4
+ import { unified } from 'unified';
5
+ import { unifiedLatexToHast } from '@unified-latex/unified-latex-to-hast';
6
+ import { toHtml } from 'hast-util-to-html';
7
+
8
+ const LATEX_DIR = './latex';
9
+ const OUTPUT_DIR = './public/converted-docs';
10
+ const MANIFEST_FILE = './public/documents-manifest.json';
11
+
12
+ function ensureDirectoryExists(dir) {
13
+ if (!existsSync(dir)) {
14
+ mkdirSync(dir, { recursive: true });
15
+ }
16
+ }
17
+
18
+ function extractMetadata(latexContent) {
19
+ const titleMatch = latexContent.match(/\\title\{([^}]+)\}/);
20
+ const authorMatch = latexContent.match(/\\author\{([^}]+)\}/);
21
+ const dateMatch = latexContent.match(/\\date\{([^}]+)\}/);
22
+
23
+ return {
24
+ title: titleMatch ? titleMatch[1] : 'Untitled Document',
25
+ author: authorMatch ? authorMatch[1] : 'Unknown Author',
26
+ date: dateMatch ? dateMatch[1] : new Date().toISOString().split('T')[0]
27
+ };
28
+ }
29
+
30
+ function convertLatexToHtml(latexContent, filename) {
31
+ try {
32
+ const ast = parse(latexContent);
33
+ const hast = unified().use(unifiedLatexToHast).runSync(ast);
34
+ // Generate raw HTML from HAST
35
+ let html = toHtml(hast);
36
+
37
+ // Extract metadata for title block
38
+ const metadata = extractMetadata(latexContent);
39
+
40
+ // Post-process HTML to better mirror LaTeX semantics/typography
41
+ // 1) Remove the placeholder produced for \\maketitle and inject a proper title block
42
+ const maketitleRegex = /<p><span class="macro macro-maketitle"><\/span><\/p>/;
43
+ const titleBlock = `\n<h1 class="title">${metadata.title}<\/h1>\n<div class="author">${metadata.author}<\/div>\n<div class="date">${metadata.date}<\/div>\n`;
44
+ if (maketitleRegex.test(html)) {
45
+ html = html.replace(maketitleRegex, titleBlock);
46
+ }
47
+
48
+ // 2) Promote heading levels (section->h2, subsection->h3)
49
+ // Promote h4 to h3 first to avoid double promotion issues
50
+ html = html.replace(/<h4(\b[^>]*)>/g, '<h3$1>')
51
+ .replace(/<\/h4>/g, '<\/h3>');
52
+ // Then promote h3 to h2
53
+ html = html.replace(/<h3(\b[^>]*)>/g, '<h2$1>')
54
+ .replace(/<\/h3>/g, '<\/h2>');
55
+
56
+ // 3) Add an "Abstract" heading before the abstract environment, if present
57
+ html = html.replace(
58
+ /(<div class="environment abstract">)/,
59
+ '<h2>Abstract<\/h2>$1'
60
+ );
61
+
62
+ // 4) Resume-specific post-processing to approximate LaTeX packages/macros (always resume)
63
+ {
64
+ // Function to parse LaTeX macro arguments with proper brace counting
65
+ const parseLatexMacro = (content, macroName) => {
66
+ const matches = [];
67
+ const regex = new RegExp(`\\\\${macroName}\\{`, 'g');
68
+ let match;
69
+
70
+
71
+ while ((match = regex.exec(content)) !== null) {
72
+ const startPos = match.index;
73
+ let pos = match.index + match[0].length - 1; // Position after opening brace
74
+ const args = [];
75
+ let currentArg = '';
76
+ let braceCount = 1;
77
+ let argCount = 0;
78
+
79
+ // Parse arguments (3 for resumeQuadHeadingDetails and resumeTrioHeading, 4 for resumeQuadHeading)
80
+ const maxArgs = macroName === 'resumeQuadHeading' ? 4 : 3;
81
+ while (argCount < maxArgs && pos < content.length) {
82
+ pos++;
83
+ const char = content[pos];
84
+
85
+ if (char === '{') {
86
+ braceCount++;
87
+ currentArg += char;
88
+ } else if (char === '}') {
89
+ braceCount--;
90
+ if (braceCount === 0) {
91
+ // End of current argument
92
+ args.push(currentArg.trim());
93
+ argCount++;
94
+ currentArg = '';
95
+ // Look for next argument
96
+ while (pos < content.length && content[pos] !== '{') {
97
+ pos++;
98
+ }
99
+ if (pos < content.length) {
100
+ braceCount = 1; // Reset for next argument
101
+ }
102
+ } else {
103
+ // Still inside nested braces
104
+ currentArg += char;
105
+ }
106
+ } else {
107
+ currentArg += char;
108
+ }
109
+ }
110
+
111
+ if (args.length >= maxArgs) {
112
+ const fullMatch = content.substring(startPos, pos + 1);
113
+ matches.push([fullMatch, ...args]);
114
+ }
115
+ }
116
+
117
+ return matches;
118
+ };
119
+
120
+ // Pre-parse macro arguments from LaTeX source so we can align by {}
121
+ // Use the parseLatexMacro function to properly handle nested braces
122
+ const trioMatches = parseLatexMacro(latexContent, 'resumeTrioHeading');
123
+ let trioIdx = 0;
124
+ // Parse resumeQuadHeadingDetails with robust nested braces handling
125
+ const quadDetailsMatches = [];
126
+
127
+ quadDetailsMatches.push(...parseLatexMacro(latexContent, 'resumeQuadHeadingDetails'));
128
+
129
+ // Also parse resumeQuadHeading (used in Education section)
130
+ const quadHeadingMatches = parseLatexMacro(latexContent, 'resumeQuadHeading');
131
+
132
+ // Debug: log the trio matches
133
+ console.log('Trio matches:', trioMatches);
134
+ let quadDetailsIdx = 0;
135
+ const sectionTypeMatches = Array.from(
136
+ latexContent.matchAll(/\\resumeSectionType\{([^}]*)\}\{([^}]*)\}\{([^}]*)\}/g)
137
+ );
138
+ let sectionTypeIdx = 0;
139
+ // Replace math pipes with typographic separators
140
+ html = html.replace(/<span class="inline-math">\|<\/span>/g, '<span class="sep">·<\/span>');
141
+
142
+ // Convert first tabular (tabular* or tabularx) contact header into a two-column grid up to next section
143
+ html = html.replace(
144
+ /<div class="environment tabular(?:\*|x)">([\s\S]*?)<\/div>(?=\s*<h2>)/,
145
+ (match, inner) => {
146
+ const iconMap = {
147
+ faLinkedin: 'fab fa-linkedin',
148
+ faGithub: 'fab fa-github',
149
+ faEnvelope: 'fas fa-envelope',
150
+ faMobile: 'fas fa-mobile'
151
+ };
152
+
153
+ const replaceIcons = text =>
154
+ text.replace(/<span class="macro macro-(fa\w+)"><\/span>/g, (_, macro) => {
155
+ const cls = iconMap[macro] || 'fas fa-circle';
156
+ return `<i class="${cls}"></i>`;
157
+ });
158
+
159
+ const innerClean = replaceIcons(
160
+ inner
161
+ .replace(/\n/g, ' ')
162
+ .replace(/>\s*[Xlcrp@{}]+(?=\s|<)/g, ' ')
163
+ .replace(/<span class="vspace"[^>]*><\/span>/g, ' ')
164
+ .replace(/<span class="macro macro-uline"><\/span>/g, '')
165
+ .replace(/\s{2,}/g, ' ')
166
+ .trim()
167
+ );
168
+
169
+ const nameMatch = innerClean.match(/<span class="textsize-Huge">([\s\S]*?)<\/span>/);
170
+ const name = nameMatch ? nameMatch[1].trim() : '';
171
+ const afterName = innerClean.replace(/^[\s\S]*?<br class="linebreak">\s*/, '');
172
+ const ampIdx = afterName.indexOf('&#x26;');
173
+ let primary = afterName;
174
+ let secondary = '';
175
+ if (ampIdx !== -1) {
176
+ primary = afterName.slice(0, ampIdx).trim();
177
+ secondary = afterName.slice(ampIdx + 5).trim();
178
+ }
179
+
180
+ const clean = text =>
181
+ text
182
+ .replace(/<br class="linebreak">/g, ' ')
183
+ .replace(/<span class="inline-math">\|<\/span>/g, '<span class="sep">·<\/span>')
184
+ .replace(/class="href"/g, '')
185
+ .trim();
186
+
187
+ // Process contact links: wrap mobile icon with phone number
188
+ let primaryClean = clean(primary);
189
+
190
+ // Wrap mobile icon and phone number together (handle fa-mobile, fa-mobile-alt, and fa-phone)
191
+ primaryClean = primaryClean.replace(/(<i class="fas fa-(?:mobile|mobile-alt|phone)"><\/i>)\s*([+\d\s\-]+)/g, '<span class="contact-mobile">$1 $2</span>');
192
+
193
+ const secondaryClean = clean(secondary).replace(/^\s*[;:,\-–]\s*/, '');
194
+
195
+ const alignmentClass = secondaryClean ? 'contact dual' : 'contact centered';
196
+ const rightColumn = secondaryClean ? `<div class="contact-right">${secondaryClean}</div>` : '';
197
+
198
+ return `\n<div class="${alignmentClass}">\n <div class="contact-left">\n <div class="contact-name">${name}<\/div>\n <div class="contact-links">${primaryClean}<\/div>\n <\/div>\n ${rightColumn}\n<\/div>`;
199
+ }
200
+ );
201
+
202
+ // Convert Trio heading macro into semantic three-column row (brace-driven)
203
+ html = html.replace(
204
+ /<span class="macro macro-resumeTrioHeading"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
205
+ (m, content) => {
206
+ const parsed = trioMatches[trioIdx++] || null;
207
+ if (!parsed) {
208
+ // Fallback to original content if parsing failed
209
+ return m;
210
+ }
211
+
212
+ let [fullMatch, title, tech, link] = parsed;
213
+
214
+ // Debug: log the parsed data
215
+ console.log('Trio parsed data:', { title, tech, link });
216
+ console.log('Full match:', fullMatch);
217
+ console.log('Link contains href:', link.includes('\\href{'));
218
+ console.log('Link length:', link.length);
219
+
220
+ // Check if the link is incomplete (missing closing braces)
221
+ if (link.includes('\\href{') && !link.includes('}{')) {
222
+ console.log('Incomplete href detected, trying to fix...');
223
+ // Try to find the complete href in the full match
224
+ const completeHrefMatch = fullMatch.match(/\\href\{[^}]+\}\{[^}]+\}/);
225
+ if (completeHrefMatch) {
226
+ console.log('Found complete href:', completeHrefMatch[0]);
227
+ // Update the link with the complete href
228
+ const updatedLink = completeHrefMatch[0];
229
+ console.log('Updated link:', updatedLink);
230
+ // Use the updated link for processing
231
+ link = updatedLink;
232
+ } else {
233
+ // If we can't find the complete href in the full match, try to find it in the original LaTeX content
234
+ const originalHrefMatch = latexContent.match(/\\href\{[^}]+\}\{[^}]+\}/g);
235
+ if (originalHrefMatch) {
236
+ // Find the href that matches our incomplete link
237
+ const matchingHref = originalHrefMatch.find(href => href.startsWith(link));
238
+ if (matchingHref) {
239
+ console.log('Found matching href in original content:', matchingHref);
240
+ link = matchingHref;
241
+ }
242
+ }
243
+ }
244
+ }
245
+
246
+ // Check if the link contains \href command (from LaTeX source)
247
+ let anchorHtml = '';
248
+ if (link.includes('\\href{')) {
249
+ // Extract URL and display text from \href{url}{text} format
250
+ // Handle nested commands like \href{url}{\uline{text}}
251
+ const hrefMatch = link.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
252
+ if (hrefMatch) {
253
+ const [, hrefUrl, hrefText] = hrefMatch;
254
+ // Clean up any nested LaTeX commands in the display text
255
+ const cleanText = hrefText.replace(/\\uline\{([^}]+)\}/g, '$1').replace(/\\uline\{([^}]*)$/g, '$1');
256
+ anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer">${cleanText}</a>`;
257
+ } else {
258
+ // Try a more complex pattern to handle deeply nested commands
259
+ const complexMatch = link.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
260
+ if (complexMatch) {
261
+ const [, hrefUrl, hrefText] = complexMatch;
262
+ const cleanText = hrefText.replace(/\\uline\{([^}]+)\}/g, '$1').replace(/\\uline\{([^}]*)$/g, '$1');
263
+ anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer">${cleanText}</a>`;
264
+ } else {
265
+ // Fallback if \href parsing fails
266
+ anchorHtml = link;
267
+ }
268
+ }
269
+ } else {
270
+ // If no \href, treat as plain text
271
+ anchorHtml = link;
272
+ }
273
+
274
+ return `\n<div class="trio">\n <div class="trio-title"><strong>${title.trim()}<\/strong><\/div>\n <div class="trio-tech"><em>${tech.trim()}<\/em><\/div>\n <div class="trio-link">${anchorHtml}<\/div>\n<\/div>`;
275
+ }
276
+ );
277
+
278
+ // Merge date ranges like "Oct 2023 –" with trailing "Present" or month on next row
279
+ html = html.replace(
280
+ /(<div class=\"quad-details\">[\s\S]*?<div class=\"right\"><span class=\"date\">\s*([^<]*?–)\s*<\/span><\/div>[\s\S]*?<div class=\"left\"><em>)\s*(Present|[A-Z][a-z]{2}\s\d{4})\s*([^<]*)(<\/em><\/div><div class=\"right\">)/g,
281
+ (m, prefix, startDash, word, restRole, suffix) => {
282
+ const date = `${startDash} ${word}`.replace(/\s+/g, ' ').trim();
283
+ const role = restRole.replace(/^\s*[–-]?\s*/, '').trim();
284
+ return `${prefix}${role}${suffix}<span class=\"date\">${date}<\/span>`;
285
+ }
286
+ );
287
+
288
+ // Convert QuadHeadingDetails macro into 2x2 block (brace-driven)
289
+ // Use parsed data from LaTeX source to ensure proper structure
290
+ html = html.replace(
291
+ /<span class="macro macro-resumeQuadHeadingDetails"><\/span>([\s\S]*?)(?=(<ul|<span|<\/div|<\/p))/g,
292
+ (m, content) => {
293
+ const parsed = quadDetailsMatches[quadDetailsIdx++] || null;
294
+ if (!parsed) {
295
+ // Fallback to original content if parsing failed
296
+ return m;
297
+ }
298
+
299
+ const [fullMatch, url, dateText, roleText] = parsed;
300
+
301
+ // Clean up date text - preserve periods in month abbreviations and fix dash formatting
302
+ const cleanDateText = dateText.replace(/\s*--\s*/g, ' – ')
303
+ .replace(/\s+/g, ' ')
304
+ .trim();
305
+
306
+ // Clean up role text
307
+ const cleanRoleText = roleText.replace(/\s+/g, ' ').trim();
308
+
309
+ // Check if the URL contains \href command (from LaTeX source)
310
+ let anchorHtml = '';
311
+ if (url.includes('\\href{')) {
312
+ // Extract URL and display text from \href{url}{text} format
313
+ const hrefMatch = url.match(/\\href\{([^}]+)\}\{([^}]+)\}/);
314
+ if (hrefMatch) {
315
+ const [, hrefUrl, hrefText] = hrefMatch;
316
+ // Make the link text bold while keeping it as a clickable link
317
+ anchorHtml = `<a href="${hrefUrl}" target="_blank" rel="noopener noreferrer"><strong>${hrefText}</strong></a>`;
318
+ } else {
319
+ // Fallback if \href parsing fails
320
+ anchorHtml = `<strong>${url}</strong>`;
321
+ }
322
+ } else {
323
+ // If no \href, treat as plain text and make it bold
324
+ anchorHtml = `<strong>${url}</strong>`;
325
+ }
326
+
327
+ return `\n<div class="quad-details">\n <div class="row"><div class="left">${anchorHtml}<\/div><div class="right"><span class="date">${cleanDateText}<\/span><\/div><\/div>\n <div class="row"><div class="left"><em>${cleanRoleText}<\/em><\/div><div class="right"><\/div><\/div>\n<\/div>`;
328
+ }
329
+ );
330
+
331
+ // Clean up any remaining duplicate content that might have been created
332
+ // This handles cases where the macro replacement left behind raw text
333
+ html = html.replace(
334
+ /(<a[^>]*>.*?<\/a>)([A-Z][a-z]{2,4}\.\s\d{4}\s[–-]\s[A-Z][a-z]{2,4}\.\s\d{4})\s*([^<]*?)(?=<ul|<span|<\/div|<\/p)/g,
335
+ (m, anchor, dateRange, role) => {
336
+ // Only process if this looks like a duplicate (no surrounding div structure)
337
+ if (!m.includes('class="quad-details"') && !m.includes('class="row"')) {
338
+ return ''; // Remove the duplicate
339
+ }
340
+ return m; // Keep the original if it's already properly formatted
341
+ }
342
+ );
343
+
344
+ // After rendering quad-details, merge split date ranges with role prefixes
345
+ // Case A: "Oct 2023 –" + left em starting with "Present ..."
346
+ html = html.replace(
347
+ /(<div class=\"quad-details\">[\s\S]*?<span class=\"date\">)\s*([^<]*?–)\s*(<\/span>[\s\S]*?<div class=\"left\"><em>)\s*Present\s+([^<]*?)(<\/em>)/g,
348
+ (m, pre, startDash, mid, roleRest, end) => `${pre}${startDash} Present${mid}${roleRest}${end}`
349
+ );
350
+
351
+ // Case B: Disabled - was causing incorrect merging of complete date ranges with role text
352
+ // The issue is now fixed in the QuadHeadingDetails parsing above
353
+
354
+ // Replace entire Technical Skills block using brace-driven rows up to the next <h2>
355
+ {
356
+ const rows = sectionTypeMatches.map(s => {
357
+ const label = s[1].trim();
358
+ const sep = s[2].trim();
359
+ const content = s[3].trim();
360
+ // Remove LaTeX escaping from ampersands in labels
361
+ const cleanLabel = label.replace(/\\&/g, '&');
362
+ return `\n<div class=\"skill-row\">\n <div class=\"skill-label\"><strong>${cleanLabel}<\/strong><\/div>\n <div class=\"skill-sep\">${sep}<\/div>\n <div class=\"skill-content\">${content}<\/div>\n<\/div>`;
363
+ }).join('');
364
+ html = html.replace(/<h2>Technical Skills<\/h2>[\s\S]*?(?=<h2>)/, `<h2>Technical Skills<\/h2><div class=\"resume-heading-list\">${rows}<\/div>\n`);
365
+ }
366
+
367
+ // Convert QuadHeading macro into 2x2 block using parsed data
368
+ let quadHeadingIdx = 0;
369
+ html = html.replace(
370
+ /<span class="macro macro-resumeQuadHeading"><\/span>([^<]*?)\s*(Bachelor of[^<]*?)\s*([A-Z][a-z]{2,9}\.\s\d{4}\s[–-]\s[A-Z][a-z]{2,9}\.\s\d{4})/g,
371
+ (m, pre, degreeLine, dates) => {
372
+ const parsed = quadHeadingMatches[quadHeadingIdx++] || null;
373
+ if (parsed) {
374
+ // Use parsed data from LaTeX source
375
+ const uni = parsed[1].trim();
376
+ const loc = parsed[2].trim();
377
+ const degree = parsed[3].trim();
378
+ let dateRange = parsed[4] ? parsed[4].trim() : dates;
379
+
380
+ // Clean up date range - preserve periods and fix dash formatting
381
+ dateRange = dateRange.replace(/\s*--\s*/g, ' – ')
382
+ .replace(/\s+/g, ' ')
383
+ .trim();
384
+
385
+ return `\n<div class="quad">\n <div class="row"><div class="left"><strong>${uni}<\/strong><\/div><div class="right">${loc}<\/div><\/div>\n <div class="row"><div class="left"><em>${degree}<\/em><\/div><div class="right"><em>${dateRange}<\/em><\/div><\/div>\n<\/div>`;
386
+ } else {
387
+ // Fallback to original logic if parsing failed
388
+ const preTrim = (pre || '').replace(/\s+/g, ' ').trim();
389
+ let uni = preTrim;
390
+ let loc = '';
391
+ // Case 1: Standard trailing ", City, Country"
392
+ const cityCountryMatch = preTrim.match(/^(.*?),\s*([A-Za-z'’\- ]+,\s*[A-Za-z'’\- ]+)$/);
393
+ if (cityCountryMatch) {
394
+ uni = cityCountryMatch[1].trim();
395
+ loc = cityCountryMatch[2].trim();
396
+ } else {
397
+ const gluedMatch = preTrim.match(/^(.*?University)\s*(Hsinchu,\s*Taiwan)$/i);
398
+ if (gluedMatch) {
399
+ uni = gluedMatch[1].trim();
400
+ loc = gluedMatch[2].trim();
401
+ }
402
+ }
403
+ const cleanDates = dates.replace(/\s*--\s*/g, ' – ').replace(/\s+/g, ' ').trim();
404
+ return `\n<div class="quad">\n <div class="row"><div class="left"><strong>${uni}<\/strong><\/div><div class="right">${loc}<\/div><\/div>\n <div class="row"><div class="left"><em>${degreeLine}<\/em><\/div><div class="right"><em>${cleanDates}<\/em><\/div><\/div>\n<\/div>`;
405
+ }
406
+ }
407
+ );
408
+
409
+ // Convert custom list macros to semantic lists
410
+ const LIST_START = '<span class="macro macro-resumeItemListStart"></span>';
411
+ const LIST_END = '<span class="macro macro-resumeItemListEnd"></span>';
412
+ const ITEM = '<span class="macro macro-resumeItem"></span>';
413
+
414
+ // Process each list block iteratively
415
+ let searchStart = 0;
416
+ while (true) {
417
+ const startIdx = html.indexOf(LIST_START, searchStart);
418
+ if (startIdx === -1) break;
419
+ const endIdx = html.indexOf(LIST_END, startIdx);
420
+ if (endIdx === -1) break;
421
+
422
+ const before = html.slice(0, startIdx);
423
+ const inner = html.slice(startIdx + LIST_START.length, endIdx);
424
+ const after = html.slice(endIdx + LIST_END.length);
425
+
426
+ // Split by ITEM markers; ignore leading empty chunk
427
+ const parts = inner.split(ITEM).map(s => s.trim());
428
+ const items = [];
429
+ for (const part of parts) {
430
+ if (!part) continue;
431
+ // Close item content when it reaches the next ITEM or end; since we split, wrap directly
432
+ items.push(`<li>${part}</li>`);
433
+ }
434
+ const ul = `<ul class="resume-items">${items.join('')}</ul>`;
435
+ html = before + ul + after;
436
+ searchStart = before.length + ul.length;
437
+ }
438
+
439
+ // Remove container list macros that can appear around headings
440
+ html = html
441
+ .replace(/<span class="macro macro-resumeHeadingListStart"><\/span>/g, '<div class="resume-heading-list">')
442
+ .replace(/<span class="macro macro-resumeHeadingListEnd"><\/span>/g, '<\/div>');
443
+
444
+ // Tidy up stray paragraph wrappers around our new blocks
445
+ html = html
446
+ .replace(/<p>\s*(<ul class="resume-items">)/g, '$1')
447
+ .replace(/(<\/ul>)\s*<\/p>/g, '$1')
448
+ .replace(/<p>\s*(<div class="resume-heading-list">)/g, '$1')
449
+ .replace(/(<\/div>)\s*<\/p>/g, '$1')
450
+ .replace(/<p>\s*(<(?:div) class=\"(?:contact|trio|quad|quad-details)\">)/g, '$1')
451
+ .replace(/(<\/(?:div)>)\s*<\/p>/g, '$1')
452
+ .replace(/<\/p>\s*(<div class=\"trio\">)/g, '$1')
453
+ .replace(/(<\/div>\s*<\/div>\s*)<div class=\"trio-tech\">[\s\S]*?<\/div>\s*<div class=\"trio-link\">[\s\S]*?<\/div>/, '$1');
454
+
455
+ // Fix missing spaces after percent symbols
456
+ html = html.replace(/(\d+)%([a-zA-Z])/g, '$1% $2');
457
+
458
+ // Clean up leftover macro artifacts from Trio headings
459
+ html = html.replace(/<span class="macro macro-uline"><\/span>Source Code<\/a>/g, '');
460
+
461
+ // Global FontAwesome icon replacement - ensure all icon macros are converted
462
+ const iconMap = {
463
+ faLinkedin: 'fab fa-linkedin',
464
+ faGithub: 'fab fa-github',
465
+ faEnvelope: 'fas fa-envelope',
466
+ faMobile: 'fas fa-mobile'
467
+ };
468
+ html = html.replace(/<span class="macro macro-(fa\w+)"><\/span>/g, (_, macro) => {
469
+ const cls = iconMap[macro] || 'fas fa-circle';
470
+ return `<i class="${cls}"></i>`;
471
+ });
472
+
473
+ // Remove any remaining class="href" attributes that might have been missed
474
+ html = html.replace(/class="href"/g, '');
475
+
476
+ // Fix any double spaces in href attributes
477
+ html = html.replace(/<a href=/g, '<a href=');
478
+
479
+ // Add target="_blank" to all external links (not mailto or relative links)
480
+ html = html.replace(/<a href="(https?:\/\/[^"]+)"/g, '<a href="$1" target="_blank" rel="noopener noreferrer"');
481
+
482
+ // Clean up duplicate target="_blank" attributes
483
+ html = html.replace(/target="_blank" rel="noopener noreferrer" target="_blank" rel="noopener noreferrer"/g, 'target="_blank" rel="noopener noreferrer"');
484
+ }
485
+
486
+
487
+ const styledHtml = `
488
+ <!DOCTYPE html>
489
+ <html lang="en">
490
+ <head>
491
+ <meta charset="UTF-8">
492
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
493
+ <title>${metadata.title}</title>
494
+ <script id="MathJax-script" async src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
495
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Source+Sans+3:ital,wght@0,300..900;1,300..900&display=swap" rel="stylesheet">
496
+ <!-- Font Awesome 6.5.2 - Primary CDN -->
497
+ <link rel="stylesheet" href="https://use.fontawesome.com/releases/v6.5.2/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU" crossorigin="anonymous" />
498
+ <!-- Font Awesome Fallback CDN -->
499
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
500
+ <style>
501
+ html, body {
502
+ background: #fff;
503
+ margin: 0;
504
+ padding: 0;
505
+ }
506
+ body {
507
+ max-width: 950px;
508
+ width: 100%;
509
+ margin: 0 auto;
510
+ padding: 2rem;
511
+ font-family: "Source Sans 3", system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
512
+ line-height: 1.6;
513
+ color: #000;
514
+ background: #fff;
515
+ box-sizing: border-box;
516
+ }
517
+ * { box-sizing: border-box; }
518
+ h1, h2, h3, h4, h5, h6 {
519
+ margin-top: 1.5em;
520
+ margin-bottom: 0.5em;
521
+ font-weight: bold;
522
+ }
523
+ h1 { font-size: 5em; text-align: center; }
524
+ h2 { font-size: 1.5em; font-variant: small-caps; color: #1e3a8a; border-bottom: 1px solid #1e3a8a; padding-bottom: 0.25rem; margin-top: 1.2em; }
525
+ h3 { font-size: 1.2em; }
526
+ .author { text-align: center; font-style: italic; margin: 1em 0; }
527
+ .date { text-align: center; margin-bottom: 2em; }
528
+ .title { margin-top: 0.5em; }
529
+ p { margin: 1em 0; text-align: left; }
530
+ .theorem, .lemma, .proposition, .corollary {
531
+ font-style: italic;
532
+ margin: 1em 0;
533
+ padding: 0.5em;
534
+ border-left: 3px solid #333;
535
+ }
536
+ .proof {
537
+ margin: 1em 0 1em 2em;
538
+ }
539
+ code, pre {
540
+ font-family: "Courier New", monospace;
541
+ background: #f5f5f5;
542
+ padding: 0.2em 0.4em;
543
+ }
544
+ pre {
545
+ padding: 1em;
546
+ overflow-x: auto;
547
+ }
548
+ .equation {
549
+ margin: 1em 0;
550
+ overflow-x: auto;
551
+ }
552
+ a { color: #0066cc; text-decoration: none; cursor: pointer; }
553
+ a:hover { color: #004499; text-decoration: none; }
554
+ a:visited { color: #551a8b; text-decoration: none; }
555
+ .resume-items { margin: 0.25rem 0 1rem 1.25rem; }
556
+ .resume-items li { margin: 0.25rem 0; }
557
+ .resume-heading-list { margin: 0.25rem 0 0.5rem 0; }
558
+ .contact { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 0.75rem; margin-bottom: 1rem; }
559
+ .contact.centered { grid-template-columns: 1fr; text-align: center; }
560
+ .contact.centered .contact-name { justify-self: center; }
561
+ .contact.centered .contact-links { justify-self: center; }
562
+ .contact-name { font-size: 1.75rem; font-weight: 700; }
563
+ .contact-links { color: #111; display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem; align-items: center; }
564
+ .contact-links a { display: inline-flex; align-items: center; gap: 0.25rem; }
565
+ .contact-links i {
566
+ color: #1e3a8a;
567
+ font-style: normal;
568
+ font-variant: normal;
569
+ text-rendering: auto;
570
+ -webkit-font-smoothing: antialiased;
571
+ display: inline-block;
572
+ font-family: "Font Awesome 6 Free", "Font Awesome 6 Brands", "Font Awesome 6 Pro";
573
+ font-weight: 900;
574
+ }
575
+ .contact-links i.fab { font-family: "Font Awesome 6 Brands"; font-weight: 400; }
576
+ .contact-links > i { display: inline-flex; align-items: center; gap: 0.25rem; }
577
+ .contact-sep { color: #666; margin: 0 0.25rem; }
578
+ .contact-mobile { display: inline-flex; align-items: center; gap: 0.25rem; }
579
+ .contact-right { text-align: right; white-space: nowrap; }
580
+ .sep { margin: 0 0.35rem; color: #666; }
581
+ .trio { display: grid; grid-template-columns: 1fr auto auto; gap: 0.5rem; align-items: baseline; margin: 0.25rem 0; position: relative; }
582
+ .trio-title { justify-self: start; white-space: nowrap; }
583
+ .trio-tech { position: absolute; left: 50%; transform: translateX(-50%); color: #374151; white-space: nowrap; }
584
+ .trio-link { justify-self: end; white-space: nowrap; }
585
+ .quad, .quad-details { margin: 0.25rem 0; }
586
+ .row { display: grid; grid-template-columns: 1fr auto; align-items: baseline; }
587
+ .row .left, .row .right { white-space: nowrap; }
588
+ .row .right { text-align: right; color: #374151; }
589
+ .skill-row { display: grid; grid-template-columns: 0.28fr 0.01fr 0.71fr; align-items: start; gap: 0.5rem; }
590
+ .skill-label { font-weight: 700; }
591
+ .skill-sep { text-align: center; }
592
+ .macro { display: none; }
593
+ .converter-footer {
594
+ margin-top: 3rem;
595
+ padding-top: 1rem;
596
+ border-top: 1px solid #e5e7eb;
597
+ text-align: center;
598
+ font-size: 0.875rem;
599
+ color: #6b7280;
600
+ }
601
+ .converter-footer a {
602
+ color: #3b82f6;
603
+ text-decoration: none;
604
+ }
605
+ .converter-footer a:hover {
606
+ text-decoration: underline;
607
+ }
608
+ @media (max-width: 768px) {
609
+ body {
610
+ padding: 1rem;
611
+ }
612
+ h1 { font-size: 5em; }
613
+ h2 { font-size: 1.25em; margin-top: 1em; }
614
+ .contact-name { font-size: 1.5rem; }
615
+ .contact-links { font-size: 0.9rem; gap: 0.4rem; }
616
+ .trio {
617
+ grid-template-columns: 1fr;
618
+ gap: 0.25rem;
619
+ margin: 0.5rem 0;
620
+ }
621
+ .trio-title {
622
+ justify-self: start;
623
+ white-space: normal;
624
+ }
625
+ .trio-tech {
626
+ position: static;
627
+ transform: none;
628
+ left: auto;
629
+ justify-self: start;
630
+ white-space: normal;
631
+ }
632
+ .trio-link {
633
+ justify-self: start;
634
+ white-space: normal;
635
+ }
636
+ .row {
637
+ grid-template-columns: 1fr;
638
+ gap: 0.25rem;
639
+ }
640
+ .row .left, .row .right {
641
+ white-space: normal;
642
+ }
643
+ .row .right {
644
+ text-align: left;
645
+ }
646
+ .skill-row {
647
+ grid-template-columns: 1fr;
648
+ gap: 0.25rem;
649
+ }
650
+ .skill-label {
651
+ margin-bottom: 0.25rem;
652
+ }
653
+ .skill-sep {
654
+ display: none;
655
+ }
656
+ .skill-content {
657
+ margin-left: 0;
658
+ }
659
+ .contact {
660
+ grid-template-columns: 1fr;
661
+ gap: 0.5rem;
662
+ }
663
+ .contact-right {
664
+ text-align: left;
665
+ white-space: normal;
666
+ }
667
+ }
668
+ @media (max-width: 480px) {
669
+ body {
670
+ padding: 0.75rem;
671
+ }
672
+ h1 { font-size: 5em; }
673
+ h2 { font-size: 1.1em; }
674
+ .contact-name { font-size: 1.25rem; }
675
+ .contact-links {
676
+ font-size: 0.85rem;
677
+ flex-direction: column;
678
+ align-items: flex-start;
679
+ }
680
+ .resume-items {
681
+ margin-left: 1rem;
682
+ }
683
+ }
684
+ </style>
685
+ </head>
686
+ <body>
687
+ ${html}
688
+ <div class="converter-footer">
689
+ Generated with <a href="https://github.com/dytsou/resume" target="_blank" rel="noopener">LaTeX to HTML Converter</a><br>
690
+ © 2025 Tsou, Dong-You. Licensed under <a href="https://github.com/dytsou/resume/blob/main/LICENSE" target="_blank" rel="noopener">MIT License</a>
691
+ </div>
692
+ </body>
693
+ </html>
694
+ `.trim();
695
+
696
+ return { html: styledHtml, metadata, success: true, error: null };
697
+ } catch (error) {
698
+ return { html: null, metadata: null, success: false, error: error.message };
699
+ }
700
+ }
701
+
702
+ function main() {
703
+ console.log('Starting LaTeX to HTML conversion...');
704
+
705
+ ensureDirectoryExists(OUTPUT_DIR);
706
+
707
+ if (!existsSync(LATEX_DIR)) {
708
+ console.error(`Error: LaTeX directory not found: ${LATEX_DIR}`);
709
+ process.exit(1);
710
+ }
711
+
712
+ const files = readdirSync(LATEX_DIR).filter(file => extname(file) === '.tex');
713
+
714
+ if (files.length === 0) {
715
+ console.log('No LaTeX files found in the latex directory.');
716
+ writeFileSync(MANIFEST_FILE, JSON.stringify([], null, 2));
717
+ process.exit(0);
718
+ }
719
+
720
+ console.log(`Found ${files.length} LaTeX file(s) to convert.`);
721
+
722
+ const manifest = [];
723
+ let hasErrors = false;
724
+
725
+ for (const file of files) {
726
+ const filePath = join(LATEX_DIR, file);
727
+ const fileBasename = basename(file, '.tex');
728
+ const outputPath = join(OUTPUT_DIR, `${fileBasename}.html`);
729
+
730
+ console.log(`Converting: ${file}`);
731
+
732
+ try {
733
+ const latexContent = readFileSync(filePath, 'utf-8');
734
+ const result = convertLatexToHtml(latexContent, fileBasename);
735
+
736
+ if (result.success) {
737
+ writeFileSync(outputPath, result.html);
738
+
739
+ manifest.push({
740
+ id: fileBasename,
741
+ filename: file,
742
+ title: result.metadata.title,
743
+ author: result.metadata.author,
744
+ date: result.metadata.date,
745
+ // Use a relative path so it works under any Vite base URL
746
+ htmlPath: `converted-docs/${fileBasename}.html`,
747
+ lastConverted: new Date().toISOString()
748
+ });
749
+
750
+ console.log(` ✓ Successfully converted to: ${outputPath}`);
751
+ } else {
752
+ console.error(` ✗ Failed to convert ${file}: ${result.error}`);
753
+ hasErrors = true;
754
+ }
755
+ } catch (error) {
756
+ console.error(` ✗ Error processing ${file}: ${error.message}`);
757
+ hasErrors = true;
758
+ }
759
+ }
760
+
761
+ writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2));
762
+ console.log(`\nManifest written to: ${MANIFEST_FILE}`);
763
+ console.log(`Successfully converted: ${manifest.length}/${files.length} files`);
764
+
765
+ if (hasErrors) {
766
+ console.error('\n❌ Some files failed to convert. Fix errors before deployment.');
767
+ process.exit(1);
768
+ } else {
769
+ console.log('\n✅ All LaTeX files converted successfully!');
770
+ process.exit(0);
771
+ }
772
+ }
773
+
774
+ main();