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

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, buildDirectHtmlPrompt, 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 = 3;
20
21
  function extractContent(msg) {
21
22
  const content = msg['content'];
22
23
  if (content && content.trim())
@@ -35,6 +36,9 @@ function validateAndFixPlan(plan) {
35
36
  if (!Array.isArray(plan.slides) || plan.slides.length < 3) {
36
37
  return 'slides array must have at least 3 entries';
37
38
  }
39
+ if (plan.slides.length < 10) {
40
+ return `Only ${plan.slides.length} slides — minimum 10 required (aim for 10-12). Add more content slides with specific data.`;
41
+ }
38
42
  if (plan.slides[0]?.type !== 'title') {
39
43
  logger.info('Auto-fixing: first slide type changed to "title"');
40
44
  plan.slides[0].type = 'title';
@@ -47,7 +51,23 @@ function validateAndFixPlan(plan) {
47
51
  for (let i = 0; i < plan.slides.length; i++) {
48
52
  if (!plan.slides[i].title) {
49
53
  plan.slides[i].title = `Slide ${i + 1}`;
50
- logger.info(`Auto-fixing: slide ${i + 1} missing title, set placeholder`);
54
+ }
55
+ }
56
+ const layoutOnlyPatterns = [
57
+ /^(?:전체\s*배경|왼쪽에|오른쪽에|중앙에|상단에|하단에)/,
58
+ /#[0-9a-fA-F]{3,8}에서.*그라데이션/,
59
+ /(?:accent_light|primary|gradient_end)\s*(?:배경|글씨|색상)/,
60
+ /^(?:CSS|flexbox|grid|conic-gradient|linear-gradient)/i,
61
+ ];
62
+ for (let i = 0; i < plan.slides.length; i++) {
63
+ const slide = plan.slides[i];
64
+ if (slide.type === 'title' || slide.type === 'closing')
65
+ continue;
66
+ const cd = slide.content_direction || '';
67
+ const hasNumbers = /\d/.test(cd);
68
+ const isLayoutOnly = layoutOnlyPatterns.some(p => p.test(cd));
69
+ if (isLayoutOnly && !hasNumbers) {
70
+ return `Slide ${i + 1} "${slide.title}" content_direction contains layout instructions instead of actual data.`;
51
71
  }
52
72
  }
53
73
  return null;
@@ -130,19 +150,15 @@ function parseJsonPlan(raw) {
130
150
  cleaned = cleaned.replace(/^```(?:json|JSON)?\s*\n?/, '').replace(/\n?```\s*$/, '');
131
151
  }
132
152
  const firstBrace = cleaned.indexOf('{');
133
- if (firstBrace > 0) {
153
+ if (firstBrace > 0)
134
154
  cleaned = cleaned.slice(firstBrace);
135
- }
136
155
  const lastBrace = cleaned.lastIndexOf('}');
137
- if (lastBrace >= 0 && lastBrace < cleaned.length - 1) {
156
+ if (lastBrace >= 0 && lastBrace < cleaned.length - 1)
138
157
  cleaned = cleaned.slice(0, lastBrace + 1);
139
- }
140
158
  try {
141
159
  return JSON.parse(cleaned);
142
160
  }
143
- catch (e) {
144
- logger.debug('parseJsonPlan: direct parse failed', { error: String(e), length: cleaned.length });
145
- }
161
+ catch { }
146
162
  const match = cleaned.match(/\{[\s\S]*\}/);
147
163
  if (!match)
148
164
  return null;
@@ -154,9 +170,7 @@ function parseJsonPlan(raw) {
154
170
  try {
155
171
  return JSON.parse(repaired);
156
172
  }
157
- catch (e) {
158
- logger.debug('parseJsonPlan: repaired parse failed', { error: String(e) });
159
- }
173
+ catch { }
160
174
  try {
161
175
  let final = repaired;
162
176
  let braces = 0, brackets = 0;
@@ -187,9 +201,9 @@ function parseJsonPlan(raw) {
187
201
  }
188
202
  if (inStr)
189
203
  final += '"';
190
- for (let i = 0; i < brackets; i++)
204
+ for (let x = 0; x < brackets; x++)
191
205
  final += ']';
192
- for (let i = 0; i < braces; i++)
206
+ for (let x = 0; x < braces; x++)
193
207
  final += '}';
194
208
  return JSON.parse(final);
195
209
  }
@@ -199,69 +213,50 @@ function parseJsonPlan(raw) {
199
213
  }
200
214
  function extractHtml(raw) {
201
215
  const trimmed = raw.trim();
202
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html')) {
216
+ if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html'))
203
217
  return trimmed;
204
- }
205
218
  const fenceMatch = trimmed.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
206
- if (fenceMatch?.[1]) {
219
+ if (fenceMatch?.[1])
207
220
  return fenceMatch[1].trim();
208
- }
209
221
  const docMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
210
- if (docMatch?.[1]) {
222
+ if (docMatch?.[1])
211
223
  return docMatch[1].trim();
212
- }
213
224
  return null;
214
225
  }
215
- function injectViewportCss(html, backgroundColor) {
226
+ function injectEdgeSizing(html, backgroundColor) {
216
227
  let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
217
228
  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;
229
+ 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
230
  if (result.includes('</head>')) {
222
- result = result.replace('</head>', `${injection}</head>`);
231
+ result = result.replace('</head>', `${sizingCss}</head>`);
223
232
  }
224
233
  else if (result.includes('<head>')) {
225
- result = result.replace('<head>', `<head>${injection}`);
234
+ result = result.replace('<head>', `<head>${sizingCss}`);
226
235
  }
227
236
  else if (result.includes('<html')) {
228
- result = result.replace(/<html[^>]*>/, (match) => `${match}<head>${injection}</head>`);
237
+ result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${sizingCss}</head>`);
229
238
  }
230
239
  else {
231
- result = injection + result;
240
+ result = sizingCss + result;
232
241
  }
233
242
  return result;
234
243
  }
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>`);
244
+ function injectViewportCss(html, backgroundColor) {
245
+ let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
246
+ const bgColor = backgroundColor || '#000000';
247
+ 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;font-size:26px!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>*:only-child{display:flex!important;flex-direction:column!important;flex:1!important;min-height:1080px!important}body>*:only-child>*:nth-child(2),body>*:only-child>*:nth-child(3),body>*:only-child>*:last-child:not(:first-child){flex:1!important;display:flex!important;align-items:stretch!important}body>.content,body>*:nth-child(2),body>*:nth-child(3),body *>.content,body [class*=content]{flex:1!important;align-items:stretch!important;align-content:stretch!important;grid-auto-rows:1fr!important}[class*=card],[class*=item],[class*=step],[class*=milestone],[class*=feature]{justify-content:flex-start!important;gap:20px!important}</style>`;
248
+ if (result.includes('</head>')) {
249
+ result = result.replace('</head>', `${overrideCss}</head>`);
250
+ }
251
+ else if (result.includes('<head>')) {
252
+ result = result.replace('<head>', `<head>${overrideCss}`);
253
+ }
254
+ else if (result.includes('<html')) {
255
+ result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${overrideCss}</head>`);
256
+ }
257
+ else {
258
+ result = overrideCss + result;
251
259
  }
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
260
  return result;
266
261
  }
267
262
  function injectTitleContrastFix(html, designTextColor) {
@@ -290,40 +285,10 @@ function injectTitleContrastFix(html, designTextColor) {
290
285
  }
291
286
  return html + script;
292
287
  }
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
288
  function escapeHtml(text) {
320
- return text
321
- .replace(/&/g, '&amp;')
322
- .replace(/</g, '&lt;')
323
- .replace(/>/g, '&gt;')
324
- .replace(/"/g, '&quot;');
289
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
325
290
  }
326
- function buildTitleSlideHtml(design, mainTitle, subtitle, date, slideNum) {
291
+ function buildTitleSlideHtml(design, mainTitle, subtitle, date, _slideNum) {
327
292
  return `<!DOCTYPE html>
328
293
  <html lang="ko">
329
294
  <head>
@@ -335,29 +300,21 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
335
300
  body {
336
301
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
337
302
  display: flex;
303
+ flex-direction: column;
338
304
  align-items: center;
339
305
  justify-content: center;
340
- position: relative;
341
306
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
342
307
  }
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;
308
+ body::before {
309
+ content: '';
310
+ display: block;
311
+ width: 100%;
312
+ height: 6px;
356
313
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
314
+ flex-shrink: 0;
357
315
  }
358
- .content {
316
+ .slide-content {
359
317
  text-align: center;
360
- z-index: 1;
361
318
  max-width: 1400px;
362
319
  padding: 0 60px;
363
320
  }
@@ -391,32 +348,22 @@ body {
391
348
  color: rgba(255,255,255,0.55);
392
349
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
393
350
  }
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
351
  </style>
401
352
  </head>
402
353
  <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">
354
+ <div class="slide-content">
409
355
  <div class="main-title">${escapeHtml(mainTitle)}</div>
410
356
  <div class="accent-bar"></div>
411
357
  ${subtitle ? `<div class="subtitle">${escapeHtml(subtitle)}</div>` : ''}
412
358
  <div class="date-text">${escapeHtml(date)}</div>
413
359
  </div>
414
- <div class="page-num">${slideNum}</div>
360
+ <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
361
  </body>
416
362
  </html>`;
417
363
  }
418
- function buildClosingSlideHtml(design, companyName, slideNum, language) {
364
+ function buildClosingSlideHtml(design, companyName, _slideNum, language, tagline) {
419
365
  const thankYou = language === 'ko' ? '감사합니다' : 'Thank You';
366
+ const taglineHtml = tagline ? `<div class="tagline">${escapeHtml(tagline)}</div>` : '';
420
367
  return `<!DOCTYPE html>
421
368
  <html lang="${language}">
422
369
  <head>
@@ -428,75 +375,77 @@ html, body { width: 1920px; height: 1080px; overflow: hidden; }
428
375
  body {
429
376
  background: linear-gradient(135deg, ${design.primary_color} 0%, ${design.gradient_end} 60%, ${design.primary_color} 100%);
430
377
  display: flex;
378
+ flex-direction: column;
431
379
  align-items: center;
432
380
  justify-content: center;
433
- position: relative;
434
381
  font-family: "${design.font_title}", "Segoe UI", sans-serif;
435
382
  }
436
- .decor {
383
+ body::before {
384
+ content: '';
437
385
  position: absolute;
438
- border-radius: 50%;
439
- background: rgba(255,255,255,0.03);
440
- pointer-events: none;
386
+ top: 0; left: 0; right: 0;
387
+ height: 6px;
388
+ background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
441
389
  }
442
- .d1 { width: 600px; height: 600px; top: -200px; left: -150px; }
443
- .d2 { width: 400px; height: 400px; bottom: -120px; right: -80px; }
444
- .bottom-accent {
390
+ body::after {
391
+ content: '';
445
392
  position: absolute;
446
- bottom: 0; left: 0; width: 100%; height: 6px;
393
+ bottom: 0; left: 0; right: 0;
394
+ height: 6px;
447
395
  background: linear-gradient(90deg, transparent, ${design.accent_color}, transparent);
448
396
  }
449
- .content {
397
+ .slide-content {
450
398
  text-align: center;
451
- z-index: 1;
399
+ max-width: 1200px;
452
400
  }
453
401
  .thank-you {
454
- font-size: 96px;
402
+ font-size: 104px;
455
403
  font-weight: 800;
456
404
  color: #ffffff;
457
405
  letter-spacing: -1px;
458
406
  text-shadow: 0 6px 40px rgba(0,0,0,0.25);
459
- margin-bottom: 32px;
407
+ margin-bottom: 36px;
460
408
  }
461
409
  .accent-bar {
462
- width: 100px; height: 5px;
410
+ width: 120px; height: 5px;
463
411
  background: ${design.accent_color};
464
- margin: 0 auto 32px;
412
+ margin: 0 auto 36px;
465
413
  border-radius: 3px;
466
414
  box-shadow: 0 0 20px ${design.accent_color}40;
467
415
  }
468
416
  .company {
469
- font-size: 36px;
470
- font-weight: 500;
471
- color: rgba(255,255,255,0.80);
417
+ font-size: 44px;
418
+ font-weight: 600;
419
+ color: rgba(255,255,255,0.88);
472
420
  font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
421
+ margin-bottom: 20px;
473
422
  }
474
- .page-num {
475
- position: absolute;
476
- bottom: 24px; right: 44px;
477
- font-size: 13px;
478
- color: rgba(255,255,255,0.35);
423
+ .tagline {
424
+ font-size: 28px;
425
+ font-weight: 400;
426
+ color: rgba(255,255,255,0.60);
427
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
428
+ line-height: 1.6;
429
+ max-width: 900px;
430
+ margin: 0 auto;
479
431
  }
480
432
  </style>
481
433
  </head>
482
434
  <body>
483
- <div class="decor d1"></div>
484
- <div class="decor d2"></div>
485
- <div class="bottom-accent"></div>
486
- <div class="content">
435
+ <div class="slide-content">
487
436
  <div class="thank-you">${escapeHtml(thankYou)}</div>
488
437
  <div class="accent-bar"></div>
489
438
  <div class="company">${escapeHtml(companyName)}</div>
439
+ ${taglineHtml}
490
440
  </div>
491
- <div class="page-num">${slideNum}</div>
441
+ <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
442
  </body>
493
443
  </html>`;
494
444
  }
495
445
  function isOverviewSlide(title, slideIndex) {
496
446
  if (slideIndex !== 1)
497
447
  return false;
498
- const overviewKeywords = /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i;
499
- return overviewKeywords.test(title);
448
+ return /개요|목차|overview|agenda|outline|순서|발표\s*구성|contents|목록/i.test(title);
500
449
  }
501
450
  function parseOverviewItems(contentDirection) {
502
451
  const items = [];
@@ -520,11 +469,8 @@ function parseOverviewItems(contentDirection) {
520
469
  }
521
470
  function buildOverviewSlideHtml(design, title, subtitle, items, slideNum) {
522
471
  const badgeColors = [
523
- design.primary_color,
524
- design.accent_color,
525
- design.gradient_end,
526
- design.primary_color,
527
- design.accent_color,
472
+ design.primary_color, design.accent_color, design.gradient_end,
473
+ design.primary_color, design.accent_color,
528
474
  ];
529
475
  const itemCount = items.length;
530
476
  const topRow = itemCount <= 3 ? items : items.slice(0, Math.ceil(itemCount / 2));
@@ -584,7 +530,6 @@ body {
584
530
  display: flex; flex-direction: column;
585
531
  align-items: center; text-align: center;
586
532
  gap: 12px;
587
- transition: none;
588
533
  }
589
534
  .badge {
590
535
  width: 44px; height: 44px;
@@ -653,73 +598,26 @@ function normalizeDesign(raw) {
653
598
  design_notes: raw['design_notes'] || DEFAULT_DESIGN.design_notes,
654
599
  };
655
600
  }
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;
601
+ async function runDesignPhase(llmClient, instruction, phaseLogger) {
704
602
  if (phaseLogger)
705
- phaseLogger('powerpoint-create', 'planning', 'Generating JSON plan...');
603
+ phaseLogger('powerpoint-create', 'design', 'Generating design system + slide plan...');
706
604
  let plan = null;
707
605
  try {
708
- const planRes = await llmClient.chatCompletion({
606
+ const res = await llmClient.chatCompletion({
709
607
  messages: [
710
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
711
- { role: 'user', content: enhancedInstruction },
608
+ { role: 'system', content: PPT_DESIGN_PROMPT },
609
+ { role: 'user', content: instruction },
712
610
  ],
713
- temperature: 0.4,
611
+ temperature: 0.5,
714
612
  max_tokens: 8000,
715
613
  });
716
- const planMsg = planRes.choices[0]?.message;
717
- const finishReason = planRes.choices[0]?.finish_reason;
718
- const rawPlan = planMsg ? extractContent(planMsg) : '';
614
+ const msg = res.choices[0]?.message;
615
+ const rawPlan = msg ? extractContent(msg) : '';
616
+ const finishReason = res.choices[0]?.finish_reason;
719
617
  if (finishReason === 'length') {
720
- logger.warn('PPT planning response was truncated (finish_reason=length)');
618
+ logger.warn('PPT design response was truncated (finish_reason=length)');
721
619
  }
722
- logger.debug('PPT planning raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
620
+ logger.debug('PPT design raw response', { length: rawPlan.length, finishReason, first200: rawPlan.slice(0, 200) });
723
621
  plan = rawPlan ? parseJsonPlan(rawPlan) : null;
724
622
  if (plan) {
725
623
  plan.design = normalizeDesign(plan.design);
@@ -727,13 +625,15 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
727
625
  if (validationError) {
728
626
  logger.warn('PPT plan validation failed', { error: validationError });
729
627
  if (phaseLogger)
730
- phaseLogger('powerpoint-create', 'planning', `Validation failed: ${validationError}. Retrying...`);
628
+ phaseLogger('powerpoint-create', 'design', `Validation failed: ${validationError}. Retrying...`);
731
629
  const retryRes = await llmClient.chatCompletion({
732
630
  messages: [
733
- { role: 'system', content: PPT_STRUCTURED_PLANNING_PROMPT },
734
- { role: 'user', content: enhancedInstruction },
631
+ { role: 'system', content: PPT_DESIGN_PROMPT },
632
+ { role: 'user', content: instruction },
633
+ { role: 'assistant', content: rawPlan },
634
+ { 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
635
  ],
736
- temperature: 0.2,
636
+ temperature: 0.3,
737
637
  max_tokens: 8000,
738
638
  });
739
639
  const retryMsg = retryRes.choices[0]?.message;
@@ -745,7 +645,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
745
645
  if (!retryError) {
746
646
  plan = retryPlan;
747
647
  if (phaseLogger)
748
- phaseLogger('powerpoint-create', 'planning', `Retry succeeded (${plan.slides.length} slides)`);
648
+ phaseLogger('powerpoint-create', 'design', `Retry succeeded (${plan.slides.length} slides)`);
749
649
  }
750
650
  else {
751
651
  plan = null;
@@ -757,199 +657,185 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
757
657
  }
758
658
  else {
759
659
  if (phaseLogger)
760
- phaseLogger('powerpoint-create', 'planning', `Done (${plan.slides.length} slides)`);
660
+ phaseLogger('powerpoint-create', 'design', `Done (${plan.slides.length} slides, mood: ${plan.design.mood})`);
761
661
  }
762
662
  }
763
- else {
764
- logger.warn('PPT JSON plan parsing failed');
765
- if (phaseLogger)
766
- phaseLogger('powerpoint-create', 'planning', 'JSON parsing failed. Falling back.');
663
+ }
664
+ catch (e) {
665
+ logger.warn('PPT design failed', { error: String(e) });
666
+ }
667
+ return plan;
668
+ }
669
+ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, totalSlides, language) {
670
+ const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
671
+ const layoutType = extractLayoutHint(slide.content_direction || '');
672
+ const directPrompt = buildDirectHtmlPrompt(slide.title, cleanedDirection, design, slideIndex, totalSlides, language, layoutType);
673
+ try {
674
+ const res = await llmClient.chatCompletion({
675
+ messages: [
676
+ { role: 'system', content: directPrompt },
677
+ { role: 'user', content: 'Generate the HTML slide now.' },
678
+ ],
679
+ temperature: 0.4,
680
+ max_tokens: 6000,
681
+ });
682
+ const msg = res.choices[0]?.message;
683
+ const rawHtml = msg ? extractContent(msg) : '';
684
+ const html = extractHtml(rawHtml);
685
+ if (html) {
686
+ const validation = validateSlideHtml(html, layoutType);
687
+ if (validation.pass) {
688
+ return { html, isCodeTemplate: false };
689
+ }
690
+ logger.info(`Slide ${slideIndex + 1}: Direct HTML failed validation: ${validation.feedback}`);
767
691
  }
768
692
  }
769
693
  catch (e) {
770
- logger.warn('PPT planning failed', { error: String(e) });
771
- if (phaseLogger)
772
- phaseLogger('powerpoint-create', 'planning', 'Planning error. Falling back.');
694
+ logger.warn(`Slide ${slideIndex + 1}: Direct HTML LLM call failed: ${e}`);
773
695
  }
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
- };
696
+ logger.info(`Slide ${slideIndex + 1}: Falling back to code template "${layoutType}"`);
697
+ const jsonPrompt = buildContentFillJsonPrompt(slide.title, cleanedDirection, layoutType, language);
698
+ try {
699
+ const jsonRes = await llmClient.chatCompletion({
700
+ messages: [
701
+ { role: 'system', content: jsonPrompt },
702
+ { role: 'user', content: 'Output the JSON now.' },
703
+ ],
704
+ temperature: 0.3,
705
+ max_tokens: 2000,
706
+ });
707
+ const jsonMsg = jsonRes.choices[0]?.message;
708
+ const jsonRaw = jsonMsg ? extractContent(jsonMsg) : '';
709
+ let slideData = parseContentFillJson(jsonRaw, layoutType);
710
+ if (!slideData) {
711
+ const retryRes = await llmClient.chatCompletion({
712
+ messages: [
713
+ { role: 'system', content: jsonPrompt },
714
+ { role: 'user', content: 'Output ONLY valid JSON. No markdown fences, no explanation. Start with { and end with }.' },
715
+ ],
716
+ temperature: 0.2,
717
+ max_tokens: 2000,
718
+ });
719
+ const retryMsg = retryRes.choices[0]?.message;
720
+ const retryRaw = retryMsg ? extractContent(retryMsg) : '';
721
+ slideData = parseContentFillJson(retryRaw, layoutType);
722
+ }
723
+ if (slideData) {
724
+ const html = buildContentSlideHtml(design, slide.title, layoutType, slideData, slideIndex + 1, slideIndex);
725
+ return { html, isCodeTemplate: true };
726
+ }
780
727
  }
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`);
728
+ catch (e) {
729
+ logger.warn(`Slide ${slideIndex + 1}: Code template fallback failed: ${e}`);
789
730
  }
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];
731
+ return null;
732
+ }
733
+ async function generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger) {
734
+ const results = new Map();
735
+ for (let i = 0; i < plan.slides.length; i++) {
736
+ const slide = plan.slides[i];
737
+ if (slide.type === 'title') {
738
+ results.set(i, {
739
+ index: i,
740
+ html: buildTitleSlideHtml(plan.design, companyName, titleSubtitle, kstDate, i + 1),
741
+ isCodeTemplate: true,
742
+ });
799
743
  }
800
- else {
801
- const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
802
- if (nameMatch) {
803
- savePath = `C:\\temp\\${nameMatch[1]}`;
744
+ else if (slide.type === 'closing') {
745
+ const closingTagline = (slide.content_direction || '').replace(/감사합니다|thank\s*you/gi, '').trim() || undefined;
746
+ results.set(i, {
747
+ index: i,
748
+ html: buildClosingSlideHtml(plan.design, companyName, i + 1, language, closingTagline),
749
+ isCodeTemplate: true,
750
+ });
751
+ }
752
+ else if (isOverviewSlide(slide.title, i)) {
753
+ const overviewItems = parseOverviewItems(slide.content_direction || '');
754
+ if (overviewItems.length >= 2) {
755
+ const firstLine = (slide.content_direction || '').split('\n')[0] || '';
756
+ const overviewSubtitle = /^\d/.test(firstLine.trim()) ? '' : firstLine.trim();
757
+ results.set(i, {
758
+ index: i,
759
+ html: buildOverviewSlideHtml(plan.design, slide.title, overviewSubtitle, overviewItems, i + 1),
760
+ isCodeTemplate: true,
761
+ });
804
762
  }
805
763
  }
806
764
  }
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;
765
+ const contentIndices = plan.slides
766
+ .map((s, i) => ({ slide: s, index: i }))
767
+ .filter(({ index }) => !results.has(index));
768
+ if (phaseLogger)
769
+ phaseLogger('powerpoint-create', 'html-generation', `Generating ${contentIndices.length} content slides in parallel (batch size ${MAX_CONCURRENT})...`);
770
+ for (let batch = 0; batch < contentIndices.length; batch += MAX_CONCURRENT) {
771
+ const chunk = contentIndices.slice(batch, batch + MAX_CONCURRENT);
772
+ const promises = chunk.map(({ slide, index }) => generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language)
773
+ .then(result => ({ index, result }))
774
+ .catch(err => {
775
+ logger.warn(`Slide ${index + 1}: Generation error: ${err}`);
776
+ return { index, result: null };
777
+ }));
778
+ const chunkResults = await Promise.all(promises);
779
+ for (const { index, result } of chunkResults) {
780
+ if (result) {
781
+ results.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
782
+ }
820
783
  }
784
+ const done = Math.min(batch + MAX_CONCURRENT, contentIndices.length);
785
+ if (phaseLogger)
786
+ phaseLogger('powerpoint-create', 'html-generation', `Generated ${done}/${contentIndices.length} content slides`);
821
787
  }
822
- if (!titleSubtitle && titleSlidePlan) {
823
- titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
788
+ return results;
789
+ }
790
+ async function validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger) {
791
+ const failedIndices = [];
792
+ for (const [index, result] of htmlResults) {
793
+ const slide = plan.slides[index];
794
+ if (slide.type === 'title' || slide.type === 'closing')
795
+ continue;
796
+ const layoutType = extractLayoutHint(slide.content_direction || '');
797
+ const validation = validateSlideHtml(result.html, layoutType);
798
+ if (!validation.pass) {
799
+ logger.info(`Slide ${index + 1}: Post-validation failed: ${validation.feedback}`);
800
+ failedIndices.push(index);
801
+ }
824
802
  }
825
- if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
826
- titleSubtitle = '';
803
+ if (failedIndices.length === 0) {
804
+ if (phaseLogger)
805
+ phaseLogger('powerpoint-create', 'validation', 'All slides passed validation');
806
+ return htmlResults;
827
807
  }
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']}` };
808
+ if (phaseLogger)
809
+ phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating...`);
810
+ for (const index of failedIndices) {
811
+ const slide = plan.slides[index];
812
+ const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
813
+ if (result) {
814
+ htmlResults.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
815
+ }
816
+ else {
817
+ htmlResults.delete(index);
818
+ }
834
819
  }
820
+ return htmlResults;
821
+ }
822
+ async function assemblePresentation(htmlResults, plan, timestamp, savePath, phaseLogger, toolCallLogger) {
823
+ const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
824
+ ensureTempDir(tempWritePath);
835
825
  const builtSlides = [];
836
826
  let failCount = 0;
827
+ let totalToolCalls = 0;
837
828
  const tempFiles = [];
838
- for (let i = 0; i < plan.slides.length; i++) {
839
- const slidePlan = plan.slides[i];
840
- const slideNum = i + 1;
829
+ const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
830
+ for (const [index, result] of sortedEntries) {
831
+ const slidePlan = plan.slides[index];
832
+ const slideNum = builtSlides.length + 1;
841
833
  if (failCount >= 3) {
842
834
  logger.warn('Too many slide failures, stopping');
843
835
  break;
844
836
  }
845
837
  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);
851
- }
852
- else if (slidePlan.type === 'closing') {
853
- html = buildClosingSlideHtml(plan.design, companyName, slideNum, language);
854
- }
855
- else if (isOverviewSlide(slidePlan.title, i)) {
856
- const overviewItems = parseOverviewItems(slidePlan.content_direction || '');
857
- if (overviewItems.length >= 2) {
858
- const firstLine = (slidePlan.content_direction || '').split('\n')[0] || '';
859
- 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)`);
862
- }
863
- }
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
- }
892
- }
893
- catch (e) {
894
- logger.warn(`Slide ${slideNum}: LLM call failed: ${e}`);
895
- }
896
- }
897
- if (!html) {
898
- logger.warn(`Slide ${slideNum}: Failed to generate HTML, skipping`);
899
- failCount++;
900
- continue;
901
- }
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;
946
- }
947
- }
948
- catch {
949
- break;
950
- }
951
- }
952
- }
838
+ phaseLogger('powerpoint-create', 'assembly', `Rendering slide ${slideNum}: ${slidePlan.title}`);
953
839
  const htmlFileName = `hanseol_slide_${slideNum}_${timestamp}.html`;
954
840
  const pngFileName = `hanseol_slide_${slideNum}_${timestamp}.png`;
955
841
  const htmlWritePath = path.join(tempWritePath, htmlFileName);
@@ -957,7 +843,10 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
957
843
  const htmlWinPath = `${tempWinPath}\\${htmlFileName}`;
958
844
  const pngWinPath = `${tempWinPath}\\${pngFileName}`;
959
845
  try {
960
- const processed = injectTitleContrastFix(ensureSafeBodyPadding(removeAbsolutePositioning(enforceMinFontSize(injectViewportCss(html, plan.design.background_color)))), plan.design.text_color);
846
+ const viewportHtml = result.isCodeTemplate
847
+ ? injectEdgeSizing(result.html, plan.design.background_color)
848
+ : injectViewportCss(result.html, plan.design.background_color);
849
+ const processed = injectTitleContrastFix(viewportHtml, plan.design.text_color);
961
850
  fs.writeFileSync(htmlWritePath, processed, 'utf-8');
962
851
  tempFiles.push(htmlWritePath);
963
852
  }
@@ -990,6 +879,10 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
990
879
  renderSuccess = false;
991
880
  }
992
881
  }
882
+ try {
883
+ fs.unlinkSync(htmlWritePath);
884
+ }
885
+ catch { }
993
886
  if (!renderSuccess) {
994
887
  logger.warn(`Slide ${slideNum}: Screenshot rendering failed, skipping`);
995
888
  failCount++;
@@ -1023,7 +916,7 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1023
916
  if (bgResult.success) {
1024
917
  builtSlides.push(`Slide ${slideNum}: ${slidePlan.title} (${slidePlan.type})`);
1025
918
  try {
1026
- await powerpointClient.powerpointAddNote(slideNum, html);
919
+ await powerpointClient.powerpointAddNote(slideNum, result.html);
1027
920
  }
1028
921
  catch { }
1029
922
  }
@@ -1037,7 +930,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1037
930
  const slideCountResult = await powerpointClient.powerpointGetSlideCount();
1038
931
  const totalSlidesInPpt = slideCountResult['slide_count'] || 0;
1039
932
  if (totalSlidesInPpt > builtSlides.length) {
1040
- logger.warn(`PPT has ${totalSlidesInPpt} slides but only ${builtSlides.length} were rendered — deleting ${totalSlidesInPpt - builtSlides.length} trailing blanks`);
1041
933
  for (let d = totalSlidesInPpt; d > builtSlides.length; d--) {
1042
934
  await powerpointClient.powerpointDeleteSlide(d);
1043
935
  totalToolCalls++;
@@ -1049,6 +941,13 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1049
941
  }
1050
942
  }
1051
943
  if (builtSlides.length > 0) {
944
+ if (savePath) {
945
+ const wslSavePath = savePath.replace(/\\/g, '/').replace(/^([A-Za-z]):/, (_m, d) => `/mnt/${d.toLowerCase()}`);
946
+ try {
947
+ fs.unlinkSync(wslSavePath);
948
+ }
949
+ catch { }
950
+ }
1052
951
  let saveResult = await powerpointClient.powerpointSave(savePath);
1053
952
  totalToolCalls++;
1054
953
  if (toolCallLogger)
@@ -1057,8 +956,6 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1057
956
  const fallbackPath = 'C:\\temp\\presentation.pptx';
1058
957
  saveResult = await powerpointClient.powerpointSave(fallbackPath);
1059
958
  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
959
  }
1063
960
  }
1064
961
  for (const tempFile of tempFiles) {
@@ -1067,9 +964,117 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1067
964
  }
1068
965
  catch { }
1069
966
  }
967
+ return { builtSlides, totalToolCalls };
968
+ }
969
+ async function runStructured(llmClient, instruction, explicitSavePath) {
970
+ const startTime = Date.now();
971
+ const phaseLogger = getSubAgentPhaseLogger();
972
+ const toolCallLogger = getSubAgentToolCallLogger();
973
+ const timestamp = Date.now();
974
+ logger.enter('PPT-Create.runStructured.v2');
975
+ const hasKorean = /[\uac00-\ud7af\u1100-\u11ff]/.test(instruction);
976
+ const language = hasKorean ? 'ko' : 'en';
977
+ if (phaseLogger)
978
+ phaseLogger('powerpoint-create', 'init', 'Starting Design phase + opening PowerPoint...');
979
+ const [plan, createResult] = await Promise.all([
980
+ runDesignPhase(llmClient, instruction, phaseLogger),
981
+ powerpointClient.powerpointCreate(),
982
+ ]);
983
+ if (!createResult.success) {
984
+ return { success: false, error: `Failed to create presentation: ${createResult['error']}` };
985
+ }
986
+ if (!plan) {
987
+ logger.error('PPT planning failed after retries — cannot create presentation');
988
+ return { success: false, error: 'Failed to generate presentation plan. Please try again.' };
989
+ }
990
+ if (plan.slides.length > 20) {
991
+ const firstSlide = plan.slides[0];
992
+ const lastSlide = plan.slides[plan.slides.length - 1];
993
+ const contentSlides = plan.slides.slice(1, -1).slice(0, 18);
994
+ plan.slides = [firstSlide, ...contentSlides, lastSlide];
995
+ }
996
+ const userYearMatch = instruction.match(/(\d{4})년/);
997
+ if (userYearMatch) {
998
+ const userYear = userYearMatch[1];
999
+ for (const slide of plan.slides) {
1000
+ if (slide.type === 'content' && slide.content_direction) {
1001
+ if (!slide.content_direction.includes(`${userYear}년`)) {
1002
+ slide.content_direction += ` (Note: This report covers ${userYear}년 data.)`;
1003
+ }
1004
+ }
1005
+ }
1006
+ }
1007
+ let savePath = explicitSavePath;
1008
+ if (!savePath) {
1009
+ const fullPathMatch = instruction.match(/([A-Za-z]:\\[^\s,]+\.pptx|\/[^\s,]+\.pptx)/i);
1010
+ if (fullPathMatch) {
1011
+ savePath = fullPathMatch[1];
1012
+ }
1013
+ else {
1014
+ const nameMatch = instruction.match(/([\w][\w\-_.]*\.pptx)/i);
1015
+ if (nameMatch) {
1016
+ savePath = `C:\\temp\\${nameMatch[1]}`;
1017
+ }
1018
+ }
1019
+ }
1020
+ const titleSlidePlanForDate = plan.slides.find(s => s.type === 'title');
1021
+ const dateSearchTexts = [instruction, titleSlidePlanForDate?.title || '', titleSlidePlanForDate?.content_direction || ''];
1022
+ let kstDate = '';
1023
+ for (const text of dateSearchTexts) {
1024
+ const dateMatch = text.match(/(\d{4})년\s*(\d{1,2})\s*(월|분기)/);
1025
+ if (dateMatch) {
1026
+ kstDate = `${dateMatch[1]}년 ${dateMatch[2]}${dateMatch[3]}`;
1027
+ break;
1028
+ }
1029
+ }
1030
+ if (!kstDate) {
1031
+ const kstNow = new Date(Date.now() + 9 * 60 * 60 * 1000);
1032
+ kstDate = `${kstNow.getUTCFullYear()}년 ${kstNow.getUTCMonth() + 1}월`;
1033
+ }
1034
+ const titleSlidePlan = plan.slides.find(s => s.type === 'title');
1035
+ const rawTitleText = titleSlidePlan?.title || '';
1036
+ const titleSeps = [' - ', ' – ', ' — ', ': ', ' | '];
1037
+ let companyName = rawTitleText;
1038
+ let titleSubtitle = '';
1039
+ for (const sep of titleSeps) {
1040
+ const idx = rawTitleText.indexOf(sep);
1041
+ if (idx > 0) {
1042
+ companyName = rawTitleText.slice(0, idx).trim();
1043
+ titleSubtitle = rawTitleText.slice(idx + sep.length).trim();
1044
+ break;
1045
+ }
1046
+ }
1047
+ if (!titleSubtitle && titleSlidePlan) {
1048
+ titleSubtitle = ((titleSlidePlan.content_direction || '').split('\n')[0] || '').trim().slice(0, 120);
1049
+ }
1050
+ if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
1051
+ titleSubtitle = '';
1052
+ }
1053
+ const companyMatch = instruction.match(/회사명\s*[::]\s*([^\s,,、]+)/);
1054
+ if (companyMatch && companyMatch[1]) {
1055
+ const companyName_ = companyMatch[1];
1056
+ if (titleSlidePlan && titleSlidePlan.title.trim() !== companyName_) {
1057
+ const originalTitle = titleSlidePlan.title;
1058
+ titleSlidePlan.title = companyName_;
1059
+ companyName = companyName_;
1060
+ if (!titleSlidePlan.content_direction?.includes(originalTitle)) {
1061
+ const stripped = originalTitle.replace(companyName_, '').replace(/^\s*[-–—:|\s]+/, '').trim();
1062
+ titleSubtitle = stripped || originalTitle;
1063
+ titleSlidePlan.content_direction = titleSubtitle + (titleSlidePlan.content_direction ? '\n' + titleSlidePlan.content_direction : '');
1064
+ }
1065
+ }
1066
+ }
1067
+ if (phaseLogger)
1068
+ phaseLogger('powerpoint-create', 'html-generation', 'Starting parallel HTML generation...');
1069
+ const htmlResults = await generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger);
1070
+ const validatedResults = await validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger);
1071
+ if (phaseLogger)
1072
+ phaseLogger('powerpoint-create', 'assembly', `Assembling ${validatedResults.size} slides into PowerPoint...`);
1073
+ const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, phaseLogger, toolCallLogger);
1070
1074
  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 });
1075
+ const slideList = builtSlides.join('\n');
1076
+ 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}`;
1077
+ logger.exit('PPT-Create.runStructured.v2', { slideCount: builtSlides.length, totalToolCalls, duration });
1073
1078
  return {
1074
1079
  success: builtSlides.length > 0,
1075
1080
  result: summary,