hanseol-dev 5.0.2-dev.99 → 5.0.3-dev.10

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.
@@ -4,7 +4,7 @@ import { powerpointClient } from '../../tools/office/powerpoint-client.js';
4
4
  import { getSubAgentPhaseLogger, getSubAgentToolCallLogger } from '../common/sub-agent.js';
5
5
  import { logger } from '../../utils/logger.js';
6
6
  import { getPlatform } from '../../utils/platform-utils.js';
7
- import { PPT_CREATE_ENHANCEMENT_PROMPT, PPT_STRUCTURED_PLANNING_PROMPT, buildSlideHtmlPrompt, } from './powerpoint-create-prompts.js';
7
+ import { PPT_DESIGN_PROMPT, validateSlideHtml, extractLayoutHint, buildContentFillJsonPrompt, parseContentFillJson, buildContentSlideHtml, } from './powerpoint-create-prompts.js';
8
8
  const DEFAULT_DESIGN = {
9
9
  primary_color: '#1B2A4A',
10
10
  accent_color: '#00D4AA',
@@ -17,6 +17,7 @@ const DEFAULT_DESIGN = {
17
17
  mood: 'modern-minimal',
18
18
  design_notes: 'Clean gradients, card-based layouts',
19
19
  };
20
+ const MAX_CONCURRENT = 5;
20
21
  function extractContent(msg) {
21
22
  const content = msg['content'];
22
23
  if (content && content.trim())
@@ -26,6 +27,23 @@ function extractContent(msg) {
26
27
  return reasoning;
27
28
  return '';
28
29
  }
30
+ function hasPlaceholderText(html) {
31
+ const placeholderPatterns = [
32
+ 'Card title (2-5 words)',
33
+ 'Detail with number/data',
34
+ 'single emoji',
35
+ 'Display value (e.g.',
36
+ 'Category name',
37
+ '1-2 sentence key insight',
38
+ 'Another detail',
39
+ 'Third point',
40
+ 'Fourth point',
41
+ 'Brief context',
42
+ 'Segment name',
43
+ ];
44
+ const lowerHtml = html.toLowerCase();
45
+ return placeholderPatterns.some(p => lowerHtml.includes(p.toLowerCase()));
46
+ }
29
47
  function validateAndFixPlan(plan) {
30
48
  if (!plan.design)
31
49
  return 'Missing design object';
@@ -35,6 +53,9 @@ function validateAndFixPlan(plan) {
35
53
  if (!Array.isArray(plan.slides) || plan.slides.length < 3) {
36
54
  return 'slides array must have at least 3 entries';
37
55
  }
56
+ if (plan.slides.length < 10) {
57
+ return `Only ${plan.slides.length} slides — minimum 10 required (aim for 10-12). Add more content slides with specific data.`;
58
+ }
38
59
  if (plan.slides[0]?.type !== 'title') {
39
60
  logger.info('Auto-fixing: first slide type changed to "title"');
40
61
  plan.slides[0].type = 'title';
@@ -47,7 +68,23 @@ function validateAndFixPlan(plan) {
47
68
  for (let i = 0; i < plan.slides.length; i++) {
48
69
  if (!plan.slides[i].title) {
49
70
  plan.slides[i].title = `Slide ${i + 1}`;
50
- logger.info(`Auto-fixing: slide ${i + 1} missing title, set placeholder`);
71
+ }
72
+ }
73
+ const layoutOnlyPatterns = [
74
+ /^(?:전체\s*배경|왼쪽에|오른쪽에|중앙에|상단에|하단에)/,
75
+ /#[0-9a-fA-F]{3,8}에서.*그라데이션/,
76
+ /(?:accent_light|primary|gradient_end)\s*(?:배경|글씨|색상)/,
77
+ /^(?:CSS|flexbox|grid|conic-gradient|linear-gradient)/i,
78
+ ];
79
+ for (let i = 0; i < plan.slides.length; i++) {
80
+ const slide = plan.slides[i];
81
+ if (slide.type === 'title' || slide.type === 'closing')
82
+ continue;
83
+ const cd = slide.content_direction || '';
84
+ const hasNumbers = /\d/.test(cd);
85
+ const isLayoutOnly = layoutOnlyPatterns.some(p => p.test(cd));
86
+ if (isLayoutOnly && !hasNumbers) {
87
+ return `Slide ${i + 1} "${slide.title}" content_direction contains layout instructions instead of actual data.`;
51
88
  }
52
89
  }
53
90
  return null;
@@ -130,19 +167,15 @@ function parseJsonPlan(raw) {
130
167
  cleaned = cleaned.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```\s*$/, '');
131
168
  }
132
169
  const firstBrace = cleaned.indexOf('{');
133
- if (firstBrace > 0) {
170
+ if (firstBrace > 0)
134
171
  cleaned = cleaned.slice(firstBrace);
135
- }
136
172
  const lastBrace = cleaned.lastIndexOf('}');
137
- if (lastBrace >= 0 && lastBrace < cleaned.length - 1) {
173
+ if (lastBrace >= 0 && lastBrace < cleaned.length - 1)
138
174
  cleaned = cleaned.slice(0, lastBrace + 1);
139
- }
140
175
  try {
141
176
  return JSON.parse(cleaned);
142
177
  }
143
- catch (e) {
144
- logger.debug('parseJsonPlan: direct parse failed', { error: String(e), length: cleaned.length });
145
- }
178
+ catch { }
146
179
  const match = cleaned.match(/\{[\s\S]*\}/);
147
180
  if (!match)
148
181
  return null;
@@ -154,9 +187,7 @@ function parseJsonPlan(raw) {
154
187
  try {
155
188
  return JSON.parse(repaired);
156
189
  }
157
- catch (e) {
158
- logger.debug('parseJsonPlan: repaired parse failed', { error: String(e) });
159
- }
190
+ catch { }
160
191
  try {
161
192
  let final = repaired;
162
193
  let braces = 0, brackets = 0;
@@ -187,9 +218,9 @@ function parseJsonPlan(raw) {
187
218
  }
188
219
  if (inStr)
189
220
  final += '"';
190
- for (let i = 0; i < brackets; i++)
221
+ for (let x = 0; x < brackets; x++)
191
222
  final += ']';
192
- for (let i = 0; i < braces; i++)
223
+ for (let x = 0; x < braces; x++)
193
224
  final += '}';
194
225
  return JSON.parse(final);
195
226
  }
@@ -197,71 +228,22 @@ function parseJsonPlan(raw) {
197
228
  return null;
198
229
  }
199
230
  }
200
- function extractHtml(raw) {
201
- const trimmed = raw.trim();
202
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
203
- return trimmed;
204
- }
205
- const fenceMatch = trimmed.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
206
- if (fenceMatch?.[1]) {
207
- return fenceMatch[1].trim();
208
- }
209
- const docMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
210
- if (docMatch?.[1]) {
211
- return docMatch[1].trim();
212
- }
213
- return null;
214
- }
215
- function injectViewportCss(html, backgroundColor) {
231
+ function injectEdgeSizing(html, backgroundColor) {
216
232
  let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
217
233
  const bgColor = backgroundColor || '#000000';
218
- const viewportMeta = `<meta name="viewport" content="width=2040">`;
219
- const overrideCss = `<style>*{box-sizing:border-box;word-break:keep-all;overflow-wrap:break-word}html{width:2040px!important;height:1200px!important;overflow:hidden!important;margin:0!important;background-color:${bgColor}!important;zoom:1!important}body{width:1920px!important;height:1080px!important;min-width:1920px!important;min-height:1080px!important;overflow:hidden!important;margin:0!important;display:flex!important;flex-direction:column!important;zoom:1!important}body>div,body>main,body>section,body>article,body>header,body>footer{max-width:none!important;zoom:1!important}body>*>div,body>*>main,body>*>section,body>*>article{max-width:none!important;zoom:1!important}body>*:nth-child(2){flex:1!important;align-items:stretch!important;align-content:stretch!important;justify-content:center!important}</style>`;
220
- const injection = viewportMeta + overrideCss;
234
+ const sizingCss = `<style id="edge-sizing">html{width:2040px!important;height:1200px!important;overflow:hidden!important;margin:0!important;background-color:${bgColor}!important;zoom:1!important}body{width:1920px!important;height:1080px!important;min-width:1920px!important;min-height:1080px!important;overflow:hidden!important;margin:0!important;zoom:1!important}</style>`;
221
235
  if (result.includes('</head>')) {
222
- result = result.replace('</head>', `${injection}</head>`);
236
+ result = result.replace('</head>', `${sizingCss}</head>`);
223
237
  }
224
238
  else if (result.includes('<head>')) {
225
- result = result.replace('<head>', `<head>${injection}`);
239
+ result = result.replace('<head>', `<head>${sizingCss}`);
226
240
  }
227
241
  else if (result.includes('<html')) {
228
- result = result.replace(/<html[^>]*>/, (match) => `${match}<head>${injection}</head>`);
242
+ result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${sizingCss}</head>`);
229
243
  }
230
244
  else {
231
- result = injection + result;
232
- }
233
- return result;
234
- }
235
- function enforceMinFontSize(html) {
236
- let result = html.replace(/font-size:\s*(\d+(?:\.\d+)?)px/g, (_match, size) => {
237
- const px = parseFloat(size);
238
- return (px > 12 && px < 22) ? 'font-size:22px' : _match;
239
- });
240
- result = result.replace(/font-size:\s*(\d+(?:\.\d+)?)pt/g, (_match, size) => {
241
- const pt = parseFloat(size);
242
- const px = pt * 1.333;
243
- return (px > 12 && px < 22) ? 'font-size:22px' : _match;
244
- });
245
- return result;
246
- }
247
- function ensureSafeBodyPadding(html) {
248
- const safeStyle = `<style>body{padding-top:20px!important;padding-bottom:40px!important;min-height:auto!important}body>div:last-child,body>section:last-child{margin-bottom:0!important;padding-bottom:0!important}</style>`;
249
- if (html.includes('</head>')) {
250
- return html.replace('</head>', `${safeStyle}</head>`);
245
+ result = sizingCss + result;
251
246
  }
252
- return html;
253
- }
254
- function removeAbsolutePositioning(html) {
255
- let result = html.replace(/style="([^"]*)position:\s*absolute([^"]*)"/gi, (match, before, after) => {
256
- if (/(?:width|height):\s*[1-5]?\dpx/i.test(before + after) || /opacity:\s*0\.[0-4]/i.test(before + after)) {
257
- return match;
258
- }
259
- return `style="${before}position:relative${after}"`;
260
- });
261
- result = result.replace(/(\.(?:title|heading|label|text|badge|stat|value|card|item|metric)[^{]*\{[^}]*)position:\s*absolute/gi, '$1position:relative');
262
- result = result.replace(/transform:\s*scale\(\s*0\.\d+\s*\)/gi, 'transform:none');
263
- result = result.replace(/transform:\s*scale\(\s*0\.\d+\s*,\s*0\.\d+\s*\)/gi, 'transform:none');
264
- result = result.replace(/zoom:\s*0\.\d+/gi, 'zoom:1');
265
247
  return result;
266
248
  }
267
249
  function injectTitleContrastFix(html, designTextColor) {
@@ -290,40 +272,10 @@ function injectTitleContrastFix(html, designTextColor) {
290
272
  }
291
273
  return html + script;
292
274
  }
293
- function injectMeasureCss(html) {
294
- let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
295
- const viewportMeta = `<meta name="viewport" content="width=2040">`;
296
- const measureCss = `<style>*{box-sizing:border-box;word-break:keep-all;overflow-wrap:break-word}html{width:2040px!important;margin:0!important}body{width:1920px!important;min-width:1920px!important;min-height:1080px!important;margin:0!important;display:flex!important;flex-direction:column!important}body>div,body>main,body>section,body>article,body>header,body>footer{max-width:none!important}</style>`;
297
- const injection = viewportMeta + measureCss;
298
- if (result.includes('</head>')) {
299
- result = result.replace('</head>', `${injection}</head>`);
300
- }
301
- else if (result.includes('<head>')) {
302
- result = result.replace('<head>', `<head>${injection}`);
303
- }
304
- else if (result.includes('<html')) {
305
- result = result.replace(/<html[^>]*>/, (match) => `${match}<head>${injection}</head>`);
306
- }
307
- else {
308
- result = injection + result;
309
- }
310
- const measureScript = `<script>document.title='SH:'+document.documentElement.scrollHeight</script>`;
311
- if (result.includes('</body>')) {
312
- result = result.replace('</body>', `${measureScript}</body>`);
313
- }
314
- else {
315
- result += measureScript;
316
- }
317
- return result;
318
- }
319
275
  function escapeHtml(text) {
320
- return text
321
- .replace(/&/g, '&amp;')
322
- .replace(/</g, '&lt;')
323
- .replace(/>/g, '&gt;')
324
- .replace(/"/g, '&quot;');
276
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
325
277
  }
326
- function buildTitleSlideHtml(design, mainTitle, subtitle, date, slideNum) {
278
+ function buildTitleSlideHtml(design, mainTitle, subtitle, date, _slideNum) {
327
279
  return `<!DOCTYPE html>
328
280
  <html lang="ko">
329
281
  <head>
@@ -335,29 +287,21 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
335
287
  body {
336
288
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
337
289
  display: flex;
290
+ flex-direction: column;
338
291
  align-items: center;
339
292
  justify-content: center;
340
- position: relative;
341
293
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
342
294
  }
343
- .decor {
344
- position: absolute;
345
- border-radius: 50%;
346
- background: rgba(255,255,255,0.03);
347
- pointer-events: none;
348
- }
349
- .d1 { width: 700px; height: 700px; top: -250px; right: -150px; }
350
- .d2 { width: 500px; height: 500px; bottom: -180px; left: -120px; }
351
- .d3 { width: 250px; height: 250px; top: 40%; left: 8%; background: rgba(255,255,255,0.02); }
352
- .d4 { width: 180px; height: 180px; bottom: 15%; right: 12%; background: rgba(255,255,255,0.02); }
353
- .top-accent {
354
- position: absolute;
355
- top: 0; left: 0; width: 100%; height: 6px;
295
+ body::before {
296
+ content: '';
297
+ display: block;
298
+ width: 100%;
299
+ height: 6px;
356
300
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
301
+ flex-shrink: 0;
357
302
  }
358
- .content {
303
+ .slide-content {
359
304
  text-align: center;
360
- z-index: 1;
361
305
  max-width: 1400px;
362
306
  padding: 0 60px;
363
307
  }
@@ -391,32 +335,22 @@ body {
391
335
  color: rgba(255,255,255,0.55);
392
336
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
393
337
  }
394
- .page-num {
395
- position: absolute;
396
- bottom: 24px; right: 44px;
397
- font-size: 13px;
398
- color: rgba(255,255,255,0.35);
399
- }
400
338
  </style>
401
339
  </head>
402
340
  <body>
403
- <div class="top-accent"></div>
404
- <div class="decor d1"></div>
405
- <div class="decor d2"></div>
406
- <div class="decor d3"></div>
407
- <div class="decor d4"></div>
408
- <div class="content">
341
+ <div class="slide-content">
409
342
  <div class="main-title">${escapeHtml(mainTitle)}</div>
410
343
  <div class="accent-bar"></div>
411
344
  ${subtitle ? `<div class="subtitle">${escapeHtml(subtitle)}</div>` : ''}
412
345
  <div class="date-text">${escapeHtml(date)}</div>
413
346
  </div>
414
- <div class="page-num">${slideNum}</div>
347
+ <style>body{align-items:center!important;justify-content:center!important}.slide-content{flex:unset!important;min-height:unset!important;display:flex!important;flex-direction:column!important;align-items:center!important;justify-content:center!important}.slide-content>*{flex:unset!important;min-height:unset!important}</style>
415
348
  </body>
416
349
  </html>`;
417
350
  }
418
- function buildClosingSlideHtml(design, companyName, slideNum, language) {
351
+ function buildClosingSlideHtml(design, companyName, _slideNum, language, tagline) {
419
352
  const thankYou = language === 'ko' ? '감사합니다' : 'Thank You';
353
+ const taglineHtml = tagline ? `<div class="tagline">${escapeHtml(tagline)}</div>` : '';
420
354
  return `<!DOCTYPE html>
421
355
  <html lang="${language}">
422
356
  <head>
@@ -428,75 +362,77 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
428
362
  body {
429
363
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
430
364
  display: flex;
365
+ flex-direction: column;
431
366
  align-items: center;
432
367
  justify-content: center;
433
- position: relative;
434
368
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
435
369
  }
436
- .decor {
370
+ body::before {
371
+ content: '';
437
372
  position: absolute;
438
- border-radius: 50%;
439
- background: rgba(255,255,255,0.03);
440
- pointer-events: none;
373
+ top: 0; left: 0; right: 0;
374
+ height: 6px;
375
+ background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
441
376
  }
442
- .d1 { width: 600px; height: 600px; top: -200px; left: -150px; }
443
- .d2 { width: 400px; height: 400px; bottom: -120px; right: -80px; }
444
- .bottom-accent {
377
+ body::after {
378
+ content: '';
445
379
  position: absolute;
446
- bottom: 0; left: 0; width: 100%; height: 6px;
380
+ bottom: 0; left: 0; right: 0;
381
+ height: 6px;
447
382
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
448
383
  }
449
- .content {
384
+ .slide-content {
450
385
  text-align: center;
451
- z-index: 1;
386
+ max-width: 1200px;
452
387
  }
453
388
  .thank-you {
454
- font-size: 96px;
389
+ font-size: 104px;
455
390
  font-weight: 800;
456
391
  color: #ffffff;
457
392
  letter-spacing: -1px;
458
393
  text-shadow: 0 6px 40px rgba(0,0,0,0.25);
459
- margin-bottom: 32px;
394
+ margin-bottom: 36px;
460
395
  }
461
396
  .accent-bar {
462
- width: 100px; height: 5px;
397
+ width: 120px; height: 5px;
463
398
  background: ${design.accent_color};
464
- margin: 0 auto 32px;
399
+ margin: 0 auto 36px;
465
400
  border-radius: 3px;
466
401
  box-shadow: 0 0 20px ${design.accent_color}40;
467
402
  }
468
403
  .company {
469
- font-size: 36px;
470
- font-weight: 500;
471
- color: rgba(255,255,255,0.80);
404
+ font-size: 44px;
405
+ font-weight: 600;
406
+ color: rgba(255,255,255,0.88);
472
407
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
408
+ margin-bottom: 20px;
473
409
  }
474
- .page-num {
475
- position: absolute;
476
- bottom: 24px; right: 44px;
477
- font-size: 13px;
478
- color: rgba(255,255,255,0.35);
410
+ .tagline {
411
+ font-size: 28px;
412
+ font-weight: 400;
413
+ color: rgba(255,255,255,0.60);
414
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
415
+ line-height: 1.6;
416
+ max-width: 900px;
417
+ margin: 0 auto;
479
418
  }
480
419
  </style>
481
420
  </head>
482
421
  <body>
483
- <div class="decor d1"></div>
484
- <div class="decor d2"></div>
485
- <div class="bottom-accent"></div>
486
- <div class="content">
422
+ <div class="slide-content">
487
423
  <div class="thank-you">${escapeHtml(thankYou)}</div>
488
424
  <div class="accent-bar"></div>
489
425
  <div class="company">${escapeHtml(companyName)}</div>
426
+ ${taglineHtml}
490
427
  </div>
491
- <div class="page-num">${slideNum}</div>
428
+ <style>body{align-items:center!important;justify-content:center!important}.slide-content{flex:unset!important;min-height:unset!important;display:flex!important;flex-direction:column!important;align-items:center!important;justify-content:center!important}.slide-content>*{flex:unset!important;min-height:unset!important}</style>
492
429
  </body>
493
430
  </html>`;
494
431
  }
495
432
  function isOverviewSlide(title, slideIndex) {
496
433
  if (slideIndex !== 1)
497
434
  return false;
498
- const overviewKeywords = /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i;
499
- return overviewKeywords.test(title);
435
+ return /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i.test(title);
500
436
  }
501
437
  function parseOverviewItems(contentDirection) {
502
438
  const items = [];
@@ -520,11 +456,8 @@ function parseOverviewItems(contentDirection) {
520
456
  }
521
457
  function buildOverviewSlideHtml(design, title, subtitle, items, slideNum) {
522
458
  const badgeColors = [
523
- design.primary_color,
524
- design.accent_color,
525
- design.gradient_end,
526
- design.primary_color,
527
- design.accent_color,
459
+ design.primary_color, design.accent_color, design.gradient_end,
460
+ design.primary_color, design.accent_color,
528
461
  ];
529
462
  const itemCount = items.length;
530
463
  const topRow = itemCount <= 3 ? items : items.slice(0, Math.ceil(itemCount / 2));
@@ -584,7 +517,6 @@ body {
584
517
  display: flex; flex-direction: column;
585
518
  align-items: center; text-align: center;
586
519
  gap: 12px;
587
- transition: none;
588
520
  }
589
521
  .badge {
590
522
  width: 44px; height: 44px;
@@ -627,6 +559,53 @@ body {
627
559
  </body>
628
560
  </html>`;
629
561
  }
562
+ function buildFallbackSlideHtml(design, title, contentDirection, slideNum) {
563
+ const items = [];
564
+ const parts = contentDirection.split(/\(\d+\)\s*|•\s*|\n-\s*|\n\d+\.\s*/);
565
+ for (const part of parts) {
566
+ const cleaned = part.replace(/Layout:.*$/i, '').trim();
567
+ if (cleaned.length > 5) {
568
+ const sentence = cleaned.split(/[.。!]\s/)[0] || cleaned;
569
+ items.push(sentence.slice(0, 80));
570
+ }
571
+ }
572
+ const bulletItems = items.slice(0, 6);
573
+ const bulletsHtml = bulletItems.map(item => `<div class="bullet"><span class="dot"></span><span>${escapeHtml(item)}</span></div>`).join('\n ');
574
+ return `<!DOCTYPE html>
575
+ <html lang="ko">
576
+ <head>
577
+ <meta charset="UTF-8">
578
+ <style>
579
+ * { margin: 0; padding: 0; box-sizing: border-box; }
580
+ html, body { width: 1920px; height: 1080px; overflow: hidden; }
581
+ body {
582
+ background: ${design.background_color};
583
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
584
+ padding: 80px 100px;
585
+ display: flex;
586
+ flex-direction: column;
587
+ }
588
+ .header { margin-bottom: 48px; }
589
+ .slide-num { font-size: 14px; color: ${design.accent_color}; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
590
+ h1 { font-size: 52px; font-weight: 700; color: ${design.text_color}; font-family: "${design.font_title}", "Segoe UI", sans-serif; line-height: 1.2; }
591
+ .accent-bar { width: 80px; height: 4px; background: ${design.accent_color}; margin-top: 20px; border-radius: 2px; }
592
+ .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 20px; }
593
+ .bullet { display: flex; align-items: flex-start; gap: 16px; font-size: 26px; color: ${design.text_color}; line-height: 1.5; }
594
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: ${design.accent_color}; flex-shrink: 0; margin-top: 10px; }
595
+ </style>
596
+ </head>
597
+ <body>
598
+ <div class="header">
599
+ <div class="slide-num">SLIDE ${slideNum}</div>
600
+ <h1>${escapeHtml(title)}</h1>
601
+ <div class="accent-bar"></div>
602
+ </div>
603
+ <div class="content">
604
+ ${bulletsHtml}
605
+ </div>
606
+ </body>
607
+ </html>`;
608
+ }
630
609
  function getTempDir() {
631
610
  const platform = getPlatform();
632
611
  if (platform === 'wsl') {
@@ -653,73 +632,26 @@ function normalizeDesign(raw) {
653
632
  design_notes: raw['design_notes'] || DEFAULT_DESIGN.design_notes,
654
633
  };
655
634
  }
656
- async function runStructured(llmClient, instruction, explicitSavePath) {
657
- const startTime = Date.now();
658
- const phaseLogger = getSubAgentPhaseLogger();
659
- const toolCallLogger = getSubAgentToolCallLogger();
660
- let totalToolCalls = 0;
661
- const timestamp = Date.now();
662
- logger.enter('PPT-Create.runStructured');
663
- const hasKorean = /[\uac00-\ud7af\u1100-\u11ff]/.test(instruction);
664
- const language = hasKorean ? 'ko' : 'en';
665
- if (phaseLogger)
666
- phaseLogger('powerpoint-create', 'enhancement', 'Generating creative guidance...');
667
- let guidance = '';
668
- try {
669
- const enhRes = await llmClient.chatCompletion({
670
- messages: [
671
- { role: 'system', content: PPT_CREATE_ENHANCEMENT_PROMPT },
672
- { role: 'user', content: instruction },
673
- ],
674
- temperature: 0.7,
675
- max_tokens: 2000,
676
- });
677
- const enhMsg = enhRes.choices[0]?.message;
678
- guidance = enhMsg ? extractContent(enhMsg) : '';
679
- if (guidance.length < 500) {
680
- logger.warn('PPT enhancement too short, retrying', { length: guidance.length });
681
- const retryEnhRes = await llmClient.chatCompletion({
682
- messages: [
683
- { role: 'system', content: PPT_CREATE_ENHANCEMENT_PROMPT },
684
- { role: 'user', content: `${instruction}\n\nIMPORTANT: Produce a COMPLETE, DETAILED design system and content plan. Include ALL 7 sections: DOCUMENT_TYPE, AUDIENCE, MOOD, COLOR PALETTE (6 hex values), FONTS, SLIDE PLAN (10-15 slides), DESIGN NOTES. Do NOT stop early.` },
685
- ],
686
- temperature: 0.7,
687
- max_tokens: 2000,
688
- });
689
- const retryMsg = retryEnhRes.choices[0]?.message;
690
- const retryGuidance = retryMsg ? extractContent(retryMsg) : '';
691
- if (retryGuidance.length > guidance.length) {
692
- guidance = retryGuidance;
693
- }
694
- }
695
- if (phaseLogger)
696
- phaseLogger('powerpoint-create', 'enhancement', `Done (${guidance.length} chars)`);
697
- }
698
- catch (e) {
699
- logger.warn('PPT enhancement failed, proceeding without', { error: String(e) });
700
- }
701
- const enhancedInstruction = guidance
702
- ? `${instruction}\n\n═══ CREATIVE GUIDANCE ═══\n${guidance}\n═══ END GUIDANCE ═══`
703
- : instruction;
635
+ async function runDesignPhase(llmClient, instruction, phaseLogger) {
704
636
  if (phaseLogger)
705
- phaseLogger('powerpoint-create', 'planning', 'Generating JSON plan...');
637
+ phaseLogger('powerpoint-create', 'design', 'Generating design system + slide plan...');
706
638
  let plan = null;
707
639
  try {
708
- const planRes = await llmClient.chatCompletion({
640
+ const res = await llmClient.chatCompletion({
709
641
  messages: [
710
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
711
- { role: 'user', content: enhancedInstruction },
642
+ { role: 'system', content: PPT_DESIGN_PROMPT },
643
+ { role: 'user', content: instruction },
712
644
  ],
713
- temperature: 0.4,
645
+ temperature: 0.5,
714
646
  max_tokens: 8000,
715
647
  });
716
- const planMsg = planRes.choices[0]?.message;
717
- const finishReason = planRes.choices[0]?.finish_reason;
718
- const rawPlan = planMsg ? extractContent(planMsg) : '';
648
+ const msg = res.choices[0]?.message;
649
+ const rawPlan = msg ? extractContent(msg) : '';
650
+ const finishReason = res.choices[0]?.finish_reason;
719
651
  if (finishReason === 'length') {
720
- logger.warn('PPT planning response was truncated (finish_reason=length)');
652
+ logger.warn('PPT design response was truncated (finish_reason=length)');
721
653
  }
722
- logger.debug('PPT planning raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
654
+ logger.debug('PPT design raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
723
655
  plan = rawPlan ? parseJsonPlan(rawPlan) : null;
724
656
  if (plan) {
725
657
  plan.design = normalizeDesign(plan.design);
@@ -727,13 +659,15 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
727
659
  if (validationError) {
728
660
  logger.warn('PPT plan validation failed', { error: validationError });
729
661
  if (phaseLogger)
730
- phaseLogger('powerpoint-create', 'planning', `Validation failed: ${validationError}. Retrying...`);
662
+ phaseLogger('powerpoint-create', 'design', `Validation failed: ${validationError}. Retrying...`);
731
663
  const retryRes = await llmClient.chatCompletion({
732
664
  messages: [
733
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
734
- { role: 'user', content: enhancedInstruction },
665
+ { role: 'system', content: PPT_DESIGN_PROMPT },
666
+ { role: 'user', content: instruction },
667
+ { role: 'assistant', content: rawPlan },
668
+ { role: 'user', content: `ERROR: ${validationError}\n\nFix the issues and output the corrected JSON. Remember: aim for 10-12 slides with REAL content data.` },
735
669
  ],
736
- temperature: 0.2,
670
+ temperature: 0.3,
737
671
  max_tokens: 8000,
738
672
  });
739
673
  const retryMsg = retryRes.choices[0]?.message;
@@ -745,7 +679,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
745
679
  if (!retryError) {
746
680
  plan = retryPlan;
747
681
  if (phaseLogger)
748
- phaseLogger('powerpoint-create', 'planning', `Retry succeeded (${plan.slides.length} slides)`);
682
+ phaseLogger('powerpoint-create', 'design', `Retry succeeded (${plan.slides.length} slides)`);
749
683
  }
750
684
  else {
751
685
  plan = null;
@@ -757,199 +691,188 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
757
691
  }
758
692
  else {
759
693
  if (phaseLogger)
760
- phaseLogger('powerpoint-create', 'planning', `Done (${plan.slides.length} slides)`);
694
+ phaseLogger('powerpoint-create', 'design', `Done (${plan.slides.length} slides, mood: ${plan.design.mood})`);
761
695
  }
762
696
  }
763
- else {
764
- logger.warn('PPT JSON plan parsing failed');
765
- if (phaseLogger)
766
- phaseLogger('powerpoint-create', 'planning', 'JSON parsing failed. Falling back.');
767
- }
768
697
  }
769
698
  catch (e) {
770
- logger.warn('PPT planning failed', { error: String(e) });
771
- if (phaseLogger)
772
- phaseLogger('powerpoint-create', 'planning', 'Planning error. Falling back.');
773
- }
774
- if (!plan) {
775
- logger.error('PPT planning failed after retries — cannot create presentation');
776
- return {
777
- success: false,
778
- error: 'Failed to generate presentation plan. Please try again.',
779
- };
780
- }
781
- if (plan.slides.length > 15) {
782
- logger.warn(`PPT plan has ${plan.slides.length} slides, capping to 15`);
783
- const firstSlide = plan.slides[0];
784
- const lastSlide = plan.slides[plan.slides.length - 1];
785
- const contentSlides = plan.slides.slice(1, -1).slice(0, 13);
786
- plan.slides = [firstSlide, ...contentSlides, lastSlide];
787
- if (phaseLogger)
788
- phaseLogger('powerpoint-create', 'planning', `Capped to ${plan.slides.length} slides`);
699
+ logger.warn('PPT design failed', { error: String(e) });
789
700
  }
790
- if (phaseLogger)
791
- phaseLogger('powerpoint-create', 'execution', 'Starting HTML rendering pipeline...');
792
- const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
793
- ensureTempDir(tempWritePath);
794
- let savePath = explicitSavePath;
795
- if (!savePath) {
796
- const fullPathMatch = instruction.match(/([A-Za-z]:\\[^\s,]+\.pptx|\/[^\s,]+\.pptx)/i);
797
- if (fullPathMatch) {
798
- savePath = fullPathMatch[1];
799
- }
800
- else {
801
- const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
802
- if (nameMatch) {
803
- savePath = `C:\\temp\\${nameMatch[1]}`;
701
+ return plan;
702
+ }
703
+ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, _totalSlides, language) {
704
+ const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
705
+ const layoutType = extractLayoutHint(slide.content_direction || '');
706
+ logger.info(`Slide ${slideIndex + 1}: Generating code template "${layoutType}"`);
707
+ const jsonPrompt = buildContentFillJsonPrompt(slide.title, cleanedDirection, layoutType, language);
708
+ try {
709
+ const jsonRes = await llmClient.chatCompletion({
710
+ messages: [
711
+ { role: 'system', content: jsonPrompt },
712
+ { role: 'user', content: 'Output the JSON now.' },
713
+ ],
714
+ temperature: 0.3,
715
+ max_tokens: 2000,
716
+ });
717
+ const jsonMsg = jsonRes.choices[0]?.message;
718
+ const jsonRaw = jsonMsg ? extractContent(jsonMsg) : '';
719
+ let slideData = parseContentFillJson(jsonRaw, layoutType);
720
+ if (!slideData) {
721
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 1). Layout: ${layoutType}. Raw length: ${jsonRaw.length}. First 200 chars: ${jsonRaw.slice(0, 200)}`);
722
+ const retryRes = await llmClient.chatCompletion({
723
+ messages: [
724
+ { role: 'system', content: jsonPrompt },
725
+ { role: 'user', content: 'Output ONLY valid JSON. No markdown fences, no explanation. Start with { and end with }.' },
726
+ ],
727
+ temperature: 0.2,
728
+ max_tokens: 2000,
729
+ });
730
+ const retryMsg = retryRes.choices[0]?.message;
731
+ const retryRaw = retryMsg ? extractContent(retryMsg) : '';
732
+ slideData = parseContentFillJson(retryRaw, layoutType);
733
+ if (!slideData) {
734
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 2). Raw length: ${retryRaw.length}. First 200 chars: ${retryRaw.slice(0, 200)}`);
804
735
  }
805
736
  }
806
- }
807
- const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
808
- const kstDate = `${kstNow.getUTCFullYear()}년 ${kstNow.getUTCMonth() + 1}월`;
809
- const titleSlidePlan = plan.slides.find(s => s.type === 'title');
810
- const rawTitleText = titleSlidePlan?.title || '';
811
- const titleSeps = [' - ', ' – ', ' — ', ': ', ' | '];
812
- let companyName = rawTitleText;
813
- let titleSubtitle = '';
814
- for (const sep of titleSeps) {
815
- const idx = rawTitleText.indexOf(sep);
816
- if (idx > 0) {
817
- companyName = rawTitleText.slice(0, idx).trim();
818
- titleSubtitle = rawTitleText.slice(idx + sep.length).trim();
819
- break;
737
+ if (slideData) {
738
+ const html = buildContentSlideHtml(design, slide.title, layoutType, slideData, slideIndex + 1, slideIndex);
739
+ return { html, isCodeTemplate: true };
820
740
  }
821
741
  }
822
- if (!titleSubtitle && titleSlidePlan) {
823
- titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
824
- }
825
- if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
826
- titleSubtitle = '';
827
- }
828
- const createResult = await powerpointClient.powerpointCreate();
829
- totalToolCalls++;
830
- if (toolCallLogger)
831
- toolCallLogger('powerpoint-create', 'powerpoint_create', {}, createResult.success ? 'Created' : createResult['error'] || '', createResult.success, 0, totalToolCalls);
832
- if (!createResult.success) {
833
- return { success: false, error: `Failed to create presentation: ${createResult['error']}` };
742
+ catch (e) {
743
+ logger.warn(`Slide ${slideIndex + 1}: Code template fallback failed: ${e}`);
834
744
  }
835
- const builtSlides = [];
836
- let failCount = 0;
837
- const tempFiles = [];
745
+ return null;
746
+ }
747
+ async function generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger) {
748
+ const results = new Map();
838
749
  for (let i = 0; i < plan.slides.length; i++) {
839
- const slidePlan = plan.slides[i];
840
- const slideNum = i + 1;
841
- if (failCount >= 3) {
842
- logger.warn('Too many slide failures, stopping');
843
- break;
844
- }
845
- if (phaseLogger)
846
- phaseLogger('powerpoint-create', 'execution', `Rendering slide ${slideNum}/${plan.slides.length}: ${slidePlan.title}`);
847
- let html = null;
848
- let htmlPrompt = null;
849
- if (slidePlan.type === 'title') {
850
- html = buildTitleSlideHtml(plan.design, companyName, titleSubtitle, kstDate, slideNum);
750
+ const slide = plan.slides[i];
751
+ if (slide.type === 'title') {
752
+ results.set(i, {
753
+ index: i,
754
+ html: buildTitleSlideHtml(plan.design, companyName, titleSubtitle, kstDate, i + 1),
755
+ isCodeTemplate: true,
756
+ });
851
757
  }
852
- else if (slidePlan.type === 'closing') {
853
- html = buildClosingSlideHtml(plan.design, companyName, slideNum, language);
758
+ else if (slide.type === 'closing') {
759
+ const closingTagline = (slide.content_direction || '').replace(/감사합니다|thank\s*you/gi, '').trim() || undefined;
760
+ results.set(i, {
761
+ index: i,
762
+ html: buildClosingSlideHtml(plan.design, companyName, i + 1, language, closingTagline),
763
+ isCodeTemplate: true,
764
+ });
854
765
  }
855
- else if (isOverviewSlide(slidePlan.title, i)) {
856
- const overviewItems = parseOverviewItems(slidePlan.content_direction || '');
766
+ else if (isOverviewSlide(slide.title, i)) {
767
+ const overviewItems = parseOverviewItems(slide.content_direction || '');
857
768
  if (overviewItems.length >= 2) {
858
- const firstLine = (slidePlan.content_direction || '').split('\n')[0] || '';
769
+ const firstLine = (slide.content_direction || '').split('\n')[0] || '';
859
770
  const overviewSubtitle = /^\d/.test(firstLine.trim()) ? '' : firstLine.trim();
860
- html = buildOverviewSlideHtml(plan.design, slidePlan.title, overviewSubtitle, overviewItems, slideNum);
861
- logger.info(`Slide ${slideNum}: Using code-generated overview template (${overviewItems.length} items)`);
771
+ results.set(i, {
772
+ index: i,
773
+ html: buildOverviewSlideHtml(plan.design, slide.title, overviewSubtitle, overviewItems, i + 1),
774
+ isCodeTemplate: true,
775
+ });
862
776
  }
863
777
  }
864
- if (!html && slidePlan.type !== 'title' && slidePlan.type !== 'closing') {
865
- htmlPrompt = buildSlideHtmlPrompt(slidePlan.title, slidePlan.content_direction || '', plan.design, i, plan.slides.length, language);
866
- try {
867
- const htmlRes = await llmClient.chatCompletion({
868
- messages: [
869
- { role: 'system', content: htmlPrompt },
870
- { role: 'user', content: 'Generate the HTML slide now.' },
871
- ],
872
- temperature: 0.3,
873
- max_tokens: 8000,
874
- });
875
- const htmlMsg = htmlRes.choices[0]?.message;
876
- const rawHtml = htmlMsg ? extractContent(htmlMsg) : '';
877
- html = extractHtml(rawHtml);
878
- if (!html && rawHtml.length > 100) {
879
- logger.warn(`Slide ${slideNum}: HTML extraction failed, retrying`);
880
- const retryRes = await llmClient.chatCompletion({
881
- messages: [
882
- { role: 'system', content: htmlPrompt },
883
- { role: 'user', content: 'Generate the complete HTML document. Start with <!DOCTYPE html> and end with </html>. No markdown fences.' },
884
- ],
885
- temperature: 0.2,
886
- max_tokens: 4000,
887
- });
888
- const retryMsg = retryRes.choices[0]?.message;
889
- const retryRaw = retryMsg ? extractContent(retryMsg) : '';
890
- html = extractHtml(retryRaw);
891
- }
778
+ }
779
+ const contentIndices = plan.slides
780
+ .map((s, i) => ({ slide: s, index: i }))
781
+ .filter(({ index }) => !results.has(index));
782
+ if (phaseLogger)
783
+ phaseLogger('powerpoint-create', 'html-generation', `Generating ${contentIndices.length} content slides in parallel (batch size ${MAX_CONCURRENT})...`);
784
+ for (let batch = 0; batch < contentIndices.length; batch += MAX_CONCURRENT) {
785
+ const chunk = contentIndices.slice(batch, batch + MAX_CONCURRENT);
786
+ const promises = chunk.map(({ slide, index }) => generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language)
787
+ .then(result => ({ index, result }))
788
+ .catch(err => {
789
+ logger.warn(`Slide ${index + 1}: Generation error: ${err}`);
790
+ return { index, result: null };
791
+ }));
792
+ const chunkResults = await Promise.all(promises);
793
+ for (const { index, result } of chunkResults) {
794
+ if (result) {
795
+ results.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
892
796
  }
893
- catch (e) {
894
- logger.warn(`Slide ${slideNum}: LLM call failed: ${e}`);
797
+ else {
798
+ const slide = plan.slides[index];
799
+ const fallbackHtml = buildFallbackSlideHtml(plan.design, slide.title, slide.content_direction || '', index + 1);
800
+ results.set(index, { index, html: fallbackHtml, isCodeTemplate: true });
801
+ logger.warn(`Slide ${index + 1}: Using fallback HTML for "${slide.title}"`);
895
802
  }
896
803
  }
897
- if (!html) {
898
- logger.warn(`Slide ${slideNum}: Failed to generate HTML, skipping`);
899
- failCount++;
804
+ const done = Math.min(batch + MAX_CONCURRENT, contentIndices.length);
805
+ if (phaseLogger)
806
+ phaseLogger('powerpoint-create', 'html-generation', `Generated ${done}/${contentIndices.length} content slides`);
807
+ }
808
+ return results;
809
+ }
810
+ async function validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger) {
811
+ const failedIndices = [];
812
+ for (const [index, result] of htmlResults) {
813
+ const slide = plan.slides[index];
814
+ if (slide.type === 'title' || slide.type === 'closing')
900
815
  continue;
816
+ const layoutType = extractLayoutHint(slide.content_direction || '');
817
+ const validation = validateSlideHtml(result.html, layoutType);
818
+ if (!validation.pass) {
819
+ logger.info(`Slide ${index + 1}: Post-validation failed: ${validation.feedback}`);
820
+ failedIndices.push(index);
901
821
  }
902
- if (htmlPrompt) {
903
- const MAX_REGEN_ATTEMPTS = 5;
904
- for (let attempt = 0; attempt < MAX_REGEN_ATTEMPTS; attempt++) {
905
- const measureFileName = `hanseol_measure_${slideNum}_${timestamp}_${attempt}.html`;
906
- const measureWritePath = path.join(tempWritePath, measureFileName);
907
- const measureWinPath = `${tempWinPath}\\${measureFileName}`;
908
- try {
909
- fs.writeFileSync(measureWritePath, injectMeasureCss(html), 'utf-8');
910
- const contentHeight = await powerpointClient.measureHtmlHeight(measureWinPath);
911
- try {
912
- fs.unlinkSync(measureWritePath);
913
- }
914
- catch { }
915
- if (contentHeight <= 1050) {
916
- if (attempt > 0)
917
- logger.info(`Slide ${slideNum}: Fits after ${attempt} regeneration(s) (${contentHeight}px)`);
918
- break;
919
- }
920
- logger.warn(`Slide ${slideNum}: Content overflows (${contentHeight}px > 1050), attempt ${attempt + 1}/${MAX_REGEN_ATTEMPTS}`);
921
- if (phaseLogger)
922
- phaseLogger('powerpoint-create', 'execution', `Slide ${slideNum}: overflow (${contentHeight}px), regen ${attempt + 1}/${MAX_REGEN_ATTEMPTS}...`);
923
- try {
924
- const regenRes = await llmClient.chatCompletion({
925
- messages: [
926
- { role: 'system', content: htmlPrompt },
927
- { role: 'user', content: `Generate the HTML slide now. CRITICAL: Your previous version was ${contentHeight}px tall but the slide is only 1080px. Content was CLIPPED at the bottom.\n\nYou MUST:\n- Use FEWER items (max 3 cards, max 3 rows, max 3 metrics)\n- SHORTER text per item (1-2 lines max)\n- SIMPLER layout with generous padding\n- body padding: 60px 80px minimum\n- Do NOT exceed 3 content blocks total\n\nThis is attempt ${attempt + 1}. ${attempt >= 2 ? 'DRASTICALLY simplify — use only 2 main items with large text.' : ''}` },
928
- ],
929
- temperature: 0.2,
930
- max_tokens: 8000,
931
- });
932
- const regenMsg = regenRes.choices[0]?.message;
933
- const regenRaw = regenMsg ? extractContent(regenMsg) : '';
934
- const regenHtml = extractHtml(regenRaw);
935
- if (regenHtml) {
936
- html = regenHtml;
937
- logger.info(`Slide ${slideNum}: Regenerated HTML (attempt ${attempt + 1})`);
938
- }
939
- else {
940
- break;
941
- }
942
- }
943
- catch (e) {
944
- logger.warn(`Slide ${slideNum}: Overflow regeneration attempt ${attempt + 1} failed: ${e}`);
945
- break;
822
+ }
823
+ if (failedIndices.length === 0) {
824
+ if (phaseLogger)
825
+ phaseLogger('powerpoint-create', 'validation', 'All slides passed validation');
826
+ return htmlResults;
827
+ }
828
+ if (phaseLogger)
829
+ phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating in parallel...`);
830
+ for (let batch = 0; batch < failedIndices.length; batch += MAX_CONCURRENT) {
831
+ const chunk = failedIndices.slice(batch, batch + MAX_CONCURRENT);
832
+ const promises = chunk.map(async (index) => {
833
+ const slide = plan.slides[index];
834
+ const original = htmlResults.get(index);
835
+ try {
836
+ const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
837
+ if (result) {
838
+ const regenHasPlaceholder = hasPlaceholderText(result.html);
839
+ const origHasPlaceholder = hasPlaceholderText(original.html);
840
+ if (!regenHasPlaceholder || origHasPlaceholder) {
841
+ return { index, result: { index, html: result.html, isCodeTemplate: result.isCodeTemplate } };
946
842
  }
947
843
  }
948
- catch {
949
- break;
950
- }
951
844
  }
845
+ catch (e) {
846
+ logger.warn(`Slide ${index + 1}: Regen error: ${e}`);
847
+ }
848
+ return { index, result: null };
849
+ });
850
+ const chunkResults = await Promise.all(promises);
851
+ for (const { index, result } of chunkResults) {
852
+ if (result) {
853
+ htmlResults.set(index, result);
854
+ }
855
+ }
856
+ }
857
+ return htmlResults;
858
+ }
859
+ async function assemblePresentation(htmlResults, plan, timestamp, savePath, phaseLogger, toolCallLogger) {
860
+ const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
861
+ ensureTempDir(tempWritePath);
862
+ const builtSlides = [];
863
+ let failCount = 0;
864
+ let totalToolCalls = 0;
865
+ const tempFiles = [];
866
+ const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
867
+ for (const [index, result] of sortedEntries) {
868
+ const slidePlan = plan.slides[index];
869
+ const slideNum = builtSlides.length + 1;
870
+ if (failCount >= 3) {
871
+ logger.warn('Too many slide failures, stopping');
872
+ break;
952
873
  }
874
+ if (phaseLogger)
875
+ phaseLogger('powerpoint-create', 'assembly', `Rendering slide ${slideNum}: ${slidePlan.title}`);
953
876
  const htmlFileName = `hanseol_slide_${slideNum}_${timestamp}.html`;
954
877
  const pngFileName = `hanseol_slide_${slideNum}_${timestamp}.png`;
955
878
  const htmlWritePath = path.join(tempWritePath, htmlFileName);
@@ -957,7 +880,8 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
957
880
  const htmlWinPath = `${tempWinPath}\\${htmlFileName}`;
958
881
  const pngWinPath = `${tempWinPath}\\${pngFileName}`;
959
882
  try {
960
- const processed = injectTitleContrastFix(ensureSafeBodyPadding(removeAbsolutePositioning(enforceMinFontSize(injectViewportCss(html, plan.design.background_color)))), plan.design.text_color);
883
+ const viewportHtml = injectEdgeSizing(result.html, plan.design.background_color);
884
+ const processed = injectTitleContrastFix(viewportHtml, plan.design.text_color);
961
885
  fs.writeFileSync(htmlWritePath, processed, 'utf-8');
962
886
  tempFiles.push(htmlWritePath);
963
887
  }
@@ -990,6 +914,10 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
990
914
  renderSuccess = false;
991
915
  }
992
916
  }
917
+ try {
918
+ fs.unlinkSync(htmlWritePath);
919
+ }
920
+ catch { }
993
921
  if (!renderSuccess) {
994
922
  logger.warn(`Slide ${slideNum}: Screenshot rendering failed, skipping`);
995
923
  failCount++;
@@ -1023,7 +951,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1023
951
  if (bgResult.success) {
1024
952
  builtSlides.push(`Slide ${slideNum}: ${slidePlan.title} (${slidePlan.type})`);
1025
953
  try {
1026
- await powerpointClient.powerpointAddNote(slideNum, html);
954
+ await powerpointClient.powerpointAddNote(slideNum, result.html);
1027
955
  }
1028
956
  catch { }
1029
957
  }
@@ -1037,7 +965,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1037
965
  const slideCountResult = await powerpointClient.powerpointGetSlideCount();
1038
966
  const totalSlidesInPpt = slideCountResult['slide_count'] || 0;
1039
967
  if (totalSlidesInPpt > builtSlides.length) {
1040
- logger.warn(`PPT has ${totalSlidesInPpt} slides but only ${builtSlides.length} were rendered — deleting ${totalSlidesInPpt - builtSlides.length} trailing blanks`);
1041
968
  for (let d = totalSlidesInPpt; d > builtSlides.length; d--) {
1042
969
  await powerpointClient.powerpointDeleteSlide(d);
1043
970
  totalToolCalls++;
@@ -1049,6 +976,13 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1049
976
  }
1050
977
  }
1051
978
  if (builtSlides.length > 0) {
979
+ if (savePath) {
980
+ const wslSavePath = savePath.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_m, d) => `/mnt/${d.toLowerCase()}`);
981
+ try {
982
+ fs.unlinkSync(wslSavePath);
983
+ }
984
+ catch { }
985
+ }
1052
986
  let saveResult = await powerpointClient.powerpointSave(savePath);
1053
987
  totalToolCalls++;
1054
988
  if (toolCallLogger)
@@ -1057,8 +991,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1057
991
  const fallbackPath = 'C:\\temp\\presentation.pptx';
1058
992
  saveResult = await powerpointClient.powerpointSave(fallbackPath);
1059
993
  totalToolCalls++;
1060
- if (toolCallLogger)
1061
- toolCallLogger('powerpoint-create', 'powerpoint_save', { path: fallbackPath }, saveResult.success ? (saveResult['path'] || 'OK') : (saveResult.error || 'Failed'), saveResult.success, 0, totalToolCalls);
1062
994
  }
1063
995
  }
1064
996
  for (const tempFile of tempFiles) {
@@ -1067,9 +999,118 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1067
999
  }
1068
1000
  catch { }
1069
1001
  }
1002
+ return { builtSlides, totalToolCalls };
1003
+ }
1004
+ async function runStructured(llmClient, instruction, explicitSavePath) {
1005
+ const startTime = Date.now();
1006
+ const phaseLogger = getSubAgentPhaseLogger();
1007
+ const toolCallLogger = getSubAgentToolCallLogger();
1008
+ const timestamp = Date.now();
1009
+ logger.enter('PPT-Create.runStructured.v2');
1010
+ const hasKorean = /[\uac00-\ud7af\u1100-\u11ff]/.test(instruction);
1011
+ const language = hasKorean ? 'ko' : 'en';
1012
+ if (phaseLogger)
1013
+ phaseLogger('powerpoint-create', 'init', 'Starting Design phase + opening PowerPoint...');
1014
+ const [plan, createResult] = await Promise.all([
1015
+ runDesignPhase(llmClient, instruction, phaseLogger),
1016
+ powerpointClient.powerpointCreate(),
1017
+ ]);
1018
+ if (!createResult.success) {
1019
+ return { success: false, error: `Failed to create presentation: ${createResult['error']}` };
1020
+ }
1021
+ if (!plan) {
1022
+ logger.error('PPT planning failed after retries — cannot create presentation');
1023
+ return { success: false, error: 'Failed to generate presentation plan. Please try again.' };
1024
+ }
1025
+ if (plan.slides.length > 20) {
1026
+ const firstSlide = plan.slides[0];
1027
+ const lastSlide = plan.slides[plan.slides.length - 1];
1028
+ const contentSlides = plan.slides.slice(1, -1).slice(0, 18);
1029
+ plan.slides = [firstSlide, ...contentSlides, lastSlide];
1030
+ }
1031
+ const userYearMatch = instruction.match(/(\d{4})년/);
1032
+ if (userYearMatch) {
1033
+ const userYear = userYearMatch[1];
1034
+ for (const slide of plan.slides) {
1035
+ if (slide.type === 'content' && slide.content_direction) {
1036
+ if (!slide.content_direction.includes(`${userYear}년`)) {
1037
+ slide.content_direction += ` (Note: This report covers ${userYear}년 data.)`;
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+ let savePath = explicitSavePath;
1043
+ if (!savePath) {
1044
+ const fullPathMatch = instruction.match(/([A-Za-z]:\\[^\s,]+\.pptx|\/[^\s,]+\.pptx)/i);
1045
+ if (fullPathMatch) {
1046
+ savePath = fullPathMatch[1];
1047
+ }
1048
+ else {
1049
+ const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
1050
+ if (nameMatch) {
1051
+ savePath = `C:\\temp\\${nameMatch[1]}`;
1052
+ }
1053
+ }
1054
+ }
1055
+ const titleSlidePlanForDate = plan.slides.find(s => s.type === 'title');
1056
+ const dateSearchTexts = [instruction, titleSlidePlanForDate?.title || '', titleSlidePlanForDate?.content_direction || ''];
1057
+ let kstDate = '';
1058
+ for (const text of dateSearchTexts) {
1059
+ const dateMatch = text.match(/(\d{4})년\s*(\d{1,2})\s*(월|분기)/);
1060
+ if (dateMatch) {
1061
+ kstDate = `${dateMatch[1]}년 ${dateMatch[2]}${dateMatch[3]}`;
1062
+ break;
1063
+ }
1064
+ }
1065
+ if (!kstDate) {
1066
+ const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
1067
+ kstDate = `${kstNow.getUTCFullYear()}년 ${kstNow.getUTCMonth() + 1}월`;
1068
+ }
1069
+ const titleSlidePlan = plan.slides.find(s => s.type === 'title');
1070
+ const rawTitleText = titleSlidePlan?.title || '';
1071
+ const titleSeps = [' - ', ' – ', ' — ', ': ', ' | '];
1072
+ let companyName = rawTitleText;
1073
+ let titleSubtitle = '';
1074
+ for (const sep of titleSeps) {
1075
+ const idx = rawTitleText.indexOf(sep);
1076
+ if (idx > 0) {
1077
+ companyName = rawTitleText.slice(0, idx).trim();
1078
+ titleSubtitle = rawTitleText.slice(idx + sep.length).trim();
1079
+ break;
1080
+ }
1081
+ }
1082
+ if (!titleSubtitle && titleSlidePlan) {
1083
+ titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
1084
+ }
1085
+ if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
1086
+ titleSubtitle = '';
1087
+ }
1088
+ const cleanInstruction = instruction.replace(/\*\*/g, '');
1089
+ const companyMatch = cleanInstruction.match(/회사명\s*[::]?\s*([^\s,,、]+)/);
1090
+ if (companyMatch && companyMatch[1]) {
1091
+ const companyName_ = companyMatch[1];
1092
+ if (titleSlidePlan && titleSlidePlan.title.trim() !== companyName_) {
1093
+ const originalTitle = titleSlidePlan.title;
1094
+ titleSlidePlan.title = companyName_;
1095
+ companyName = companyName_;
1096
+ if (!titleSlidePlan.content_direction?.includes(originalTitle)) {
1097
+ const stripped = originalTitle.replace(companyName_, '').replace(/^\s*[-–—:|\s]+/, '').trim();
1098
+ titleSubtitle = stripped || originalTitle;
1099
+ titleSlidePlan.content_direction = titleSubtitle + (titleSlidePlan.content_direction ? '\n' + titleSlidePlan.content_direction : '');
1100
+ }
1101
+ }
1102
+ }
1103
+ if (phaseLogger)
1104
+ phaseLogger('powerpoint-create', 'html-generation', 'Starting parallel HTML generation...');
1105
+ const htmlResults = await generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger);
1106
+ const validatedResults = await validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger);
1107
+ if (phaseLogger)
1108
+ phaseLogger('powerpoint-create', 'assembly', `Assembling ${validatedResults.size} slides into PowerPoint...`);
1109
+ const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, phaseLogger, toolCallLogger);
1070
1110
  const duration = Date.now() - startTime;
1071
- const summary = `Presentation created with ${builtSlides.length} slides (HTML rendering, ${plan.design.mood}):\n${builtSlides.join('\n')}`;
1072
- logger.exit('PPT-Create.runStructured', { slideCount: builtSlides.length, totalToolCalls, duration });
1111
+ const slideList = builtSlides.join('\n');
1112
+ const summary = `Presentation COMPLETE — ${builtSlides.length} slides created and saved successfully.\nAll requested topics are covered across these slides. Do NOT add more slides or call powerpoint_modify_agent.\n\nSlides:\n${slideList}`;
1113
+ logger.exit('PPT-Create.runStructured.v2', { slideCount: builtSlides.length, totalToolCalls, duration });
1073
1114
  return {
1074
1115
  success: builtSlides.length > 0,
1075
1116
  result: summary,