hanseol-dev 5.0.3-dev.2 → 5.0.3-dev.20

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.
@@ -1 +1 @@
1
- {"version":3,"file":"powerpoint-create-agent.d.ts","sourceRoot":"","sources":["../../../src/agents/office/powerpoint-create-agent.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,YAAY,EAAc,MAAM,sBAAsB,CAAC;AAkzChE,wBAAgB,iCAAiC,IAAI,YAAY,CAkChE"}
1
+ {"version":3,"file":"powerpoint-create-agent.d.ts","sourceRoot":"","sources":["../../../src/agents/office/powerpoint-create-agent.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,YAAY,EAAc,MAAM,sBAAsB,CAAC;AAmgDhE,wBAAgB,iCAAiC,IAAI,YAAY,CAkChE"}
@@ -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_DESIGN_PROMPT, buildDirectHtmlPrompt, validateSlideHtml, extractLayoutHint, buildContentFillJsonPrompt, parseContentFillJson, buildContentSlideHtml, } 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,7 +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
+ const MAX_CONCURRENT = 5;
21
21
  function extractContent(msg) {
22
22
  const content = msg['content'];
23
23
  if (content && content.trim())
@@ -228,18 +228,6 @@ function parseJsonPlan(raw) {
228
228
  return null;
229
229
  }
230
230
  }
231
- function extractHtml(raw) {
232
- const trimmed = raw.trim();
233
- if (trimmed.startsWith('<!DOCTYPE') || trimmed.startsWith('<html'))
234
- return trimmed;
235
- const fenceMatch = trimmed.match(/```(?:html)?\s*\n([\s\S]*?)\n```/);
236
- if (fenceMatch?.[1])
237
- return fenceMatch[1].trim();
238
- const docMatch = trimmed.match(/(<!DOCTYPE[\s\S]*<\/html>)/i);
239
- if (docMatch?.[1])
240
- return docMatch[1].trim();
241
- return null;
242
- }
243
231
  function injectEdgeSizing(html, backgroundColor) {
244
232
  let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
245
233
  const bgColor = backgroundColor || '#000000';
@@ -258,24 +246,6 @@ function injectEdgeSizing(html, backgroundColor) {
258
246
  }
259
247
  return result;
260
248
  }
261
- function injectViewportCss(html, backgroundColor) {
262
- let result = html.replace(/<meta\s+name=["']viewport["'][^>]*>/gi, '');
263
- const bgColor = backgroundColor || '#000000';
264
- 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>`;
265
- if (result.includes('</head>')) {
266
- result = result.replace('</head>', `${overrideCss}</head>`);
267
- }
268
- else if (result.includes('<head>')) {
269
- result = result.replace('<head>', `<head>${overrideCss}`);
270
- }
271
- else if (result.includes('<html')) {
272
- result = result.replace(/<html[^>]*>/, (m) => `${m}<head>${overrideCss}</head>`);
273
- }
274
- else {
275
- result = overrideCss + result;
276
- }
277
- return result;
278
- }
279
249
  function injectTitleContrastFix(html, designTextColor) {
280
250
  const safeColor = designTextColor.replace(/'/g, "\\'");
281
251
  const script = `<script>(function(){` +
@@ -589,6 +559,71 @@ body {
589
559
  </body>
590
560
  </html>`;
591
561
  }
562
+ function buildFallbackSlideHtml(design, title, contentDirection, slideNum) {
563
+ const items = [];
564
+ const parts = contentDirection.split(/\(\d+\)\s*|•\s*|\n-\s*|\n\d+[.)]\s*|Step\s*\d+[.:]\s*/i);
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
+ if (items.length < 3) {
573
+ const commaParts = contentDirection.split(/[,;,;]\s*/);
574
+ for (const part of commaParts) {
575
+ const cleaned = part.replace(/Layout:.*$/i, '').trim();
576
+ if (cleaned.length > 8 && !items.includes(cleaned.slice(0, 80))) {
577
+ items.push(cleaned.slice(0, 80));
578
+ }
579
+ }
580
+ }
581
+ if (items.length < 3) {
582
+ const sentences = contentDirection.split(/[.。!?]\s+/);
583
+ for (const s of sentences) {
584
+ const cleaned = s.replace(/Layout:.*$/i, '').trim();
585
+ if (cleaned.length > 10 && !items.some(i => i.startsWith(cleaned.slice(0, 20)))) {
586
+ items.push(cleaned.slice(0, 80));
587
+ }
588
+ }
589
+ }
590
+ const bulletItems = items.slice(0, 6);
591
+ const bulletsHtml = bulletItems.map(item => `<div class="bullet"><span class="dot"></span><span>${escapeHtml(item)}</span></div>`).join('\n ');
592
+ return `<!DOCTYPE html>
593
+ <html lang="ko">
594
+ <head>
595
+ <meta charset="UTF-8">
596
+ <style>
597
+ * { margin: 0; padding: 0; box-sizing: border-box; }
598
+ html, body { width: 1920px; height: 1080px; overflow: hidden; }
599
+ body {
600
+ background: ${design.background_color};
601
+ font-family: "${design.font_body}", "Malgun Gothic", sans-serif;
602
+ padding: 80px 100px;
603
+ display: flex;
604
+ flex-direction: column;
605
+ }
606
+ .header { margin-bottom: 48px; }
607
+ .slide-num { font-size: 14px; color: ${design.accent_color}; font-weight: 600; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 12px; }
608
+ h1 { font-size: 52px; font-weight: 700; color: ${design.text_color}; font-family: "${design.font_title}", "Segoe UI", sans-serif; line-height: 1.2; }
609
+ .accent-bar { width: 80px; height: 4px; background: ${design.accent_color}; margin-top: 20px; border-radius: 2px; }
610
+ .content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 20px; }
611
+ .bullet { display: flex; align-items: flex-start; gap: 16px; font-size: 26px; color: ${design.text_color}; line-height: 1.5; }
612
+ .dot { width: 10px; height: 10px; border-radius: 50%; background: ${design.accent_color}; flex-shrink: 0; margin-top: 10px; }
613
+ </style>
614
+ </head>
615
+ <body>
616
+ <div class="header">
617
+ <div class="slide-num">SLIDE ${slideNum}</div>
618
+ <h1>${escapeHtml(title)}</h1>
619
+ <div class="accent-bar"></div>
620
+ </div>
621
+ <div class="content">
622
+ ${bulletsHtml}
623
+ </div>
624
+ </body>
625
+ </html>`;
626
+ }
592
627
  function getTempDir() {
593
628
  const platform = getPlatform();
594
629
  if (platform === 'wsl') {
@@ -683,34 +718,64 @@ async function runDesignPhase(llmClient, instruction, phaseLogger) {
683
718
  }
684
719
  return plan;
685
720
  }
686
- async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, totalSlides, language) {
687
- const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
688
- const layoutType = extractLayoutHint(slide.content_direction || '');
689
- const directPrompt = buildDirectHtmlPrompt(slide.title, cleanedDirection, design, slideIndex, totalSlides, language, layoutType);
690
- try {
691
- const res = await llmClient.chatCompletion({
692
- messages: [
693
- { role: 'system', content: directPrompt },
694
- { role: 'user', content: 'Generate the HTML slide now.' },
695
- ],
696
- temperature: 0.4,
697
- max_tokens: 6000,
698
- });
699
- const msg = res.choices[0]?.message;
700
- const rawHtml = msg ? extractContent(msg) : '';
701
- const html = extractHtml(rawHtml);
702
- if (html) {
703
- const validation = validateSlideHtml(html, layoutType);
704
- if (validation.pass) {
705
- return { html, isCodeTemplate: false };
721
+ const LAYOUT_ALTERNATIVES = {
722
+ cards: ['progress_bars', 'hero_stat', 'donut_chart', 'two_col_split', 'process_flow', 'timeline'],
723
+ };
724
+ const CARDS_KEEP_KEYWORDS = /team|팀|case|사례|success|성과|partner|파트너|고객|customer|member|멤버/i;
725
+ function rebalanceLayouts(plan) {
726
+ const contentSlides = plan.slides.filter(s => s.type === 'content');
727
+ const counts = new Map();
728
+ const slidesByLayout = new Map();
729
+ for (const slide of contentSlides) {
730
+ const layout = extractLayoutHint(slide.content_direction || '');
731
+ counts.set(layout, (counts.get(layout) || 0) + 1);
732
+ if (!slidesByLayout.has(layout))
733
+ slidesByLayout.set(layout, []);
734
+ slidesByLayout.get(layout).push(slide);
735
+ }
736
+ for (const [layout, count] of counts) {
737
+ if (count <= 2)
738
+ continue;
739
+ const slides = slidesByLayout.get(layout);
740
+ const keepSlides = slides.filter(s => CARDS_KEEP_KEYWORDS.test(s.title));
741
+ const reassignCandidates = slides.filter(s => !CARDS_KEEP_KEYWORDS.test(s.title));
742
+ const keepCount = Math.max(2, keepSlides.length);
743
+ const excess = reassignCandidates.slice(Math.max(0, keepCount - keepSlides.length));
744
+ if (excess.length === 0)
745
+ continue;
746
+ const alternatives = LAYOUT_ALTERNATIVES[layout] || ['progress_bars', 'hero_stat', 'donut_chart', 'two_col_split'];
747
+ const usedAlts = new Set();
748
+ for (const slide of excess) {
749
+ let chosen = null;
750
+ for (const alt of alternatives) {
751
+ const altCount = (counts.get(alt) || 0) + (usedAlts.has(alt) ? 1 : 0);
752
+ if (altCount < 2) {
753
+ chosen = alt;
754
+ usedAlts.add(alt);
755
+ break;
756
+ }
706
757
  }
707
- logger.info(`Slide ${slideIndex + 1}: Direct HTML failed validation: ${validation.feedback}`);
758
+ if (!chosen)
759
+ chosen = alternatives[0];
760
+ const cd = slide.content_direction || '';
761
+ const hasLayoutHint = /Layout:\s*.+$/im.test(cd);
762
+ const layoutName = chosen.replace(/_/g, ' ');
763
+ if (hasLayoutHint) {
764
+ slide.content_direction = cd.replace(/Layout:\s*.+$/im, `Layout: ${layoutName}`);
765
+ }
766
+ else {
767
+ slide.content_direction = cd + `\nLayout: ${layoutName}`;
768
+ }
769
+ counts.set(chosen, (counts.get(chosen) || 0) + 1);
770
+ counts.set(layout, counts.get(layout) - 1);
771
+ logger.info(`Rebalance: "${slide.title}" layout changed from ${layout} → ${chosen}`);
708
772
  }
709
773
  }
710
- catch (e) {
711
- logger.warn(`Slide ${slideIndex + 1}: Direct HTML LLM call failed: ${e}`);
712
- }
713
- logger.info(`Slide ${slideIndex + 1}: Falling back to code template "${layoutType}"`);
774
+ }
775
+ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, _totalSlides, language) {
776
+ const cleanedDirection = (slide.content_direction || '').replace(/\s*Layout\s*:\s*[^\n]*/gi, '').trim();
777
+ const layoutType = extractLayoutHint(slide.content_direction || '');
778
+ logger.info(`Slide ${slideIndex + 1}: Generating code template "${layoutType}"`);
714
779
  const jsonPrompt = buildContentFillJsonPrompt(slide.title, cleanedDirection, layoutType, language);
715
780
  try {
716
781
  const jsonRes = await llmClient.chatCompletion({
@@ -725,6 +790,7 @@ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, tot
725
790
  const jsonRaw = jsonMsg ? extractContent(jsonMsg) : '';
726
791
  let slideData = parseContentFillJson(jsonRaw, layoutType);
727
792
  if (!slideData) {
793
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 1). Layout: ${layoutType}. Raw length: ${jsonRaw.length}. First 200 chars: ${jsonRaw.slice(0, 200)}`);
728
794
  const retryRes = await llmClient.chatCompletion({
729
795
  messages: [
730
796
  { role: 'system', content: jsonPrompt },
@@ -736,6 +802,9 @@ async function generateSingleSlideHtml(llmClient, slide, design, slideIndex, tot
736
802
  const retryMsg = retryRes.choices[0]?.message;
737
803
  const retryRaw = retryMsg ? extractContent(retryMsg) : '';
738
804
  slideData = parseContentFillJson(retryRaw, layoutType);
805
+ if (!slideData) {
806
+ logger.warn(`Slide ${slideIndex + 1}: JSON fill parse failed (attempt 2). Raw length: ${retryRaw.length}. First 200 chars: ${retryRaw.slice(0, 200)}`);
807
+ }
739
808
  }
740
809
  if (slideData) {
741
810
  const html = buildContentSlideHtml(design, slide.title, layoutType, slideData, slideIndex + 1, slideIndex);
@@ -797,6 +866,12 @@ async function generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstD
797
866
  if (result) {
798
867
  results.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
799
868
  }
869
+ else {
870
+ const slide = plan.slides[index];
871
+ const fallbackHtml = buildFallbackSlideHtml(plan.design, slide.title, slide.content_direction || '', index + 1);
872
+ results.set(index, { index, html: fallbackHtml, isCodeTemplate: true });
873
+ logger.warn(`Slide ${index + 1}: Using fallback HTML for "${slide.title}"`);
874
+ }
800
875
  }
801
876
  const done = Math.min(batch + MAX_CONCURRENT, contentIndices.length);
802
877
  if (phaseLogger)
@@ -823,38 +898,55 @@ async function validateAndRegenerate(llmClient, htmlResults, plan, language, pha
823
898
  return htmlResults;
824
899
  }
825
900
  if (phaseLogger)
826
- phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating...`);
827
- for (const index of failedIndices) {
828
- const slide = plan.slides[index];
829
- const original = htmlResults.get(index);
830
- const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
831
- if (result) {
832
- const regenHasPlaceholder = hasPlaceholderText(result.html);
833
- const origHasPlaceholder = hasPlaceholderText(original.html);
834
- if (!regenHasPlaceholder || origHasPlaceholder) {
835
- htmlResults.set(index, { index, html: result.html, isCodeTemplate: result.isCodeTemplate });
901
+ phaseLogger('powerpoint-create', 'validation', `${failedIndices.length} slides failed validation, regenerating in parallel...`);
902
+ for (let batch = 0; batch < failedIndices.length; batch += MAX_CONCURRENT) {
903
+ const chunk = failedIndices.slice(batch, batch + MAX_CONCURRENT);
904
+ const promises = chunk.map(async (index) => {
905
+ const slide = plan.slides[index];
906
+ const original = htmlResults.get(index);
907
+ try {
908
+ const result = await generateSingleSlideHtml(llmClient, slide, plan.design, index, plan.slides.length, language);
909
+ if (result) {
910
+ const regenHasPlaceholder = hasPlaceholderText(result.html);
911
+ const origHasPlaceholder = hasPlaceholderText(original.html);
912
+ if (!regenHasPlaceholder || origHasPlaceholder) {
913
+ return { index, result: { index, html: result.html, isCodeTemplate: result.isCodeTemplate } };
914
+ }
915
+ }
916
+ }
917
+ catch (e) {
918
+ logger.warn(`Slide ${index + 1}: Regen error: ${e}`);
919
+ }
920
+ return { index, result: null };
921
+ });
922
+ const chunkResults = await Promise.all(promises);
923
+ for (const { index, result } of chunkResults) {
924
+ if (result) {
925
+ htmlResults.set(index, result);
836
926
  }
837
927
  }
838
928
  }
839
929
  return htmlResults;
840
930
  }
841
- async function assemblePresentation(htmlResults, plan, timestamp, savePath, phaseLogger, toolCallLogger) {
931
+ const MAX_SCREENSHOT_CONCURRENT = 4;
932
+ async function assemblePresentation(htmlResults, plan, timestamp, savePath, companyName, language, phaseLogger, toolCallLogger) {
842
933
  const { writePath: tempWritePath, winPath: tempWinPath } = getTempDir();
843
934
  ensureTempDir(tempWritePath);
935
+ const totalSlides = htmlResults.size;
936
+ const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
844
937
  const builtSlides = [];
845
- let failCount = 0;
846
938
  let totalToolCalls = 0;
847
939
  const tempFiles = [];
848
- const sortedEntries = [...htmlResults.entries()].sort((a, b) => a[0] - b[0]);
940
+ if (phaseLogger)
941
+ phaseLogger('powerpoint-create', 'assembly', `Pre-creating ${totalSlides} slides...`);
942
+ for (let i = 1; i < totalSlides; i++) {
943
+ await powerpointClient.powerpointAddSlide(7);
944
+ totalToolCalls++;
945
+ }
946
+ const slideFiles = new Map();
947
+ let slideNum = 0;
849
948
  for (const [index, result] of sortedEntries) {
850
- const slidePlan = plan.slides[index];
851
- const slideNum = builtSlides.length + 1;
852
- if (failCount >= 3) {
853
- logger.warn('Too many slide failures, stopping');
854
- break;
855
- }
856
- if (phaseLogger)
857
- phaseLogger('powerpoint-create', 'assembly', `Rendering slide ${slideNum}: ${slidePlan.title}`);
949
+ slideNum++;
858
950
  const htmlFileName = `hanseol_slide_${slideNum}_${timestamp}.html`;
859
951
  const pngFileName = `hanseol_slide_${slideNum}_${timestamp}.png`;
860
952
  const htmlWritePath = path.join(tempWritePath, htmlFileName);
@@ -862,101 +954,202 @@ async function assemblePresentation(htmlResults, plan, timestamp, savePath, phas
862
954
  const htmlWinPath = `${tempWinPath}\\${htmlFileName}`;
863
955
  const pngWinPath = `${tempWinPath}\\${pngFileName}`;
864
956
  try {
865
- const viewportHtml = result.isCodeTemplate
866
- ? injectEdgeSizing(result.html, plan.design.background_color)
867
- : injectViewportCss(result.html, plan.design.background_color);
957
+ const viewportHtml = injectEdgeSizing(result.html, plan.design.background_color);
868
958
  const processed = injectTitleContrastFix(viewportHtml, plan.design.text_color);
869
959
  fs.writeFileSync(htmlWritePath, processed, 'utf-8');
870
960
  tempFiles.push(htmlWritePath);
961
+ slideFiles.set(index, { slideNum, htmlWritePath, pngWritePath, htmlWinPath, pngWinPath });
871
962
  }
872
963
  catch (e) {
873
- logger.warn(`Slide ${slideNum}: Failed to write HTML file: ${e}`);
874
- failCount++;
875
- continue;
876
- }
877
- let renderSuccess = false;
878
- try {
879
- const renderResult = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
880
- totalToolCalls++;
881
- renderSuccess = renderResult.success;
882
- if (!renderSuccess) {
883
- await new Promise(r => setTimeout(r, 2000));
884
- const retryRender = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
885
- totalToolCalls++;
886
- renderSuccess = retryRender.success;
887
- }
964
+ logger.warn(`Slide ${slideNum}: Failed to write HTML: ${e}`);
888
965
  }
889
- catch (e) {
890
- logger.warn(`Slide ${slideNum}: Edge screenshot failed: ${e}`);
966
+ }
967
+ if (phaseLogger)
968
+ phaseLogger('powerpoint-create', 'assembly', `Rendering ${slideFiles.size} screenshots in parallel (batch ${MAX_SCREENSHOT_CONCURRENT})...`);
969
+ const screenshotEntries = [...slideFiles.entries()].sort((a, b) => a[0] - b[0]);
970
+ const screenshotSuccess = new Set();
971
+ for (let batch = 0; batch < screenshotEntries.length; batch += MAX_SCREENSHOT_CONCURRENT) {
972
+ const chunk = screenshotEntries.slice(batch, batch + MAX_SCREENSHOT_CONCURRENT);
973
+ const batchResults = await Promise.all(chunk.map(async ([index, files]) => {
974
+ let success = false;
891
975
  try {
892
- await new Promise(r => setTimeout(r, 2000));
893
- const retryRender = await powerpointClient.renderHtmlToImage(htmlWinPath, pngWinPath);
976
+ const result = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
894
977
  totalToolCalls++;
895
- renderSuccess = retryRender.success;
978
+ success = result.success;
979
+ if (!success) {
980
+ await new Promise(r => setTimeout(r, 1500));
981
+ const retry = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
982
+ totalToolCalls++;
983
+ success = retry.success;
984
+ }
896
985
  }
897
986
  catch {
898
- renderSuccess = false;
987
+ try {
988
+ await new Promise(r => setTimeout(r, 1500));
989
+ const retry = await powerpointClient.renderHtmlToImage(files.htmlWinPath, files.pngWinPath);
990
+ totalToolCalls++;
991
+ success = retry.success;
992
+ }
993
+ catch {
994
+ success = false;
995
+ }
899
996
  }
997
+ try {
998
+ fs.unlinkSync(files.htmlWritePath);
999
+ }
1000
+ catch { }
1001
+ if (success) {
1002
+ try {
1003
+ const stat = fs.statSync(files.pngWritePath);
1004
+ if (stat.size < 15000) {
1005
+ logger.warn(`Slide ${files.slideNum}: Screenshot too small (${stat.size} bytes)`);
1006
+ success = false;
1007
+ try {
1008
+ fs.unlinkSync(files.pngWritePath);
1009
+ }
1010
+ catch { }
1011
+ }
1012
+ }
1013
+ catch { }
1014
+ }
1015
+ if (success)
1016
+ tempFiles.push(files.pngWritePath);
1017
+ return { index, success };
1018
+ }));
1019
+ for (const { index, success } of batchResults) {
1020
+ if (success)
1021
+ screenshotSuccess.add(index);
900
1022
  }
901
- try {
902
- fs.unlinkSync(htmlWritePath);
903
- }
904
- catch { }
905
- if (!renderSuccess) {
906
- logger.warn(`Slide ${slideNum}: Screenshot rendering failed, skipping`);
907
- failCount++;
1023
+ const done = Math.min(batch + MAX_SCREENSHOT_CONCURRENT, screenshotEntries.length);
1024
+ if (phaseLogger)
1025
+ phaseLogger('powerpoint-create', 'assembly', `Screenshots: ${done}/${screenshotEntries.length} done`);
1026
+ }
1027
+ if (phaseLogger)
1028
+ phaseLogger('powerpoint-create', 'assembly', `Filling ${totalSlides} slides...`);
1029
+ const unfilledSlideNums = [];
1030
+ for (const [index] of sortedEntries) {
1031
+ const files = slideFiles.get(index);
1032
+ const slidePlan = plan.slides[index];
1033
+ const htmlResult = htmlResults.get(index);
1034
+ if (!files) {
1035
+ const posInSorted = sortedEntries.findIndex(([idx]) => idx === index);
1036
+ const inferredSlideNum = posInSorted >= 0 ? posInSorted + 1 : -1;
1037
+ logger.warn(`Slide index ${index} (slideNum ${inferredSlideNum}): No file info, marking for deletion`);
1038
+ if (inferredSlideNum > 0)
1039
+ unfilledSlideNums.push(inferredSlideNum);
908
1040
  continue;
909
1041
  }
910
- try {
911
- const pngStats = fs.statSync(pngWritePath);
912
- if (pngStats.size < 15000) {
913
- logger.warn(`Slide ${slideNum}: Screenshot too small (${pngStats.size} bytes), likely blank — skipping`);
914
- failCount++;
1042
+ let filled = false;
1043
+ if (screenshotSuccess.has(index)) {
1044
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(files.slideNum, files.pngWinPath);
1045
+ totalToolCalls++;
1046
+ if (toolCallLogger)
1047
+ toolCallLogger('powerpoint-create', 'addFullSlideImage', { slideNum: files.slideNum, imagePath: files.pngWinPath }, bgResult.success ? 'OK' : 'Failed', bgResult.success, files.slideNum, totalToolCalls);
1048
+ filled = bgResult.success;
1049
+ }
1050
+ if (!filled) {
1051
+ logger.info(`Slide ${files.slideNum}: Primary screenshot failed, trying fallback rendering...`);
1052
+ const slide = plan.slides[index];
1053
+ const fallbackHtml = buildFallbackSlideHtml(plan.design, slide.title, slide.content_direction || '', files.slideNum);
1054
+ const fbHtmlName = `hanseol_fb_${files.slideNum}_${timestamp}.html`;
1055
+ const fbPngName = `hanseol_fb_${files.slideNum}_${timestamp}.png`;
1056
+ const fbHtmlWrite = path.join(tempWritePath, fbHtmlName);
1057
+ const fbPngWrite = path.join(tempWritePath, fbPngName);
1058
+ const fbHtmlWin = `${tempWinPath}\\${fbHtmlName}`;
1059
+ const fbPngWin = `${tempWinPath}\\${fbPngName}`;
1060
+ try {
1061
+ const viewportHtml = injectEdgeSizing(fallbackHtml, plan.design.background_color);
1062
+ fs.writeFileSync(fbHtmlWrite, viewportHtml, 'utf-8');
1063
+ const fbResult = await powerpointClient.renderHtmlToImage(fbHtmlWin, fbPngWin);
1064
+ totalToolCalls++;
915
1065
  try {
916
- fs.unlinkSync(pngWritePath);
1066
+ fs.unlinkSync(fbHtmlWrite);
1067
+ }
1068
+ catch { }
1069
+ if (fbResult.success) {
1070
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(files.slideNum, fbPngWin);
1071
+ totalToolCalls++;
1072
+ filled = bgResult.success;
1073
+ try {
1074
+ fs.unlinkSync(fbPngWrite);
1075
+ }
1076
+ catch { }
1077
+ }
1078
+ }
1079
+ catch (e) {
1080
+ logger.warn(`Slide ${files.slideNum}: Fallback rendering also failed: ${e}`);
1081
+ try {
1082
+ fs.unlinkSync(fbHtmlWrite);
917
1083
  }
918
1084
  catch { }
919
- continue;
920
1085
  }
921
1086
  }
922
- catch { }
923
- tempFiles.push(pngWritePath);
924
- const addResult = await powerpointClient.powerpointAddSlide(7);
925
- totalToolCalls++;
926
- if (!addResult.success) {
927
- logger.warn(`Slide ${slideNum}: Failed to add blank slide`);
928
- failCount++;
929
- continue;
930
- }
931
- const bgResult = await powerpointClient.powerpointAddFullSlideImage(slideNum, pngWinPath);
932
- totalToolCalls++;
933
- if (toolCallLogger)
934
- toolCallLogger('powerpoint-create', 'addFullSlideImage', { slideNum, imagePath: pngWinPath }, bgResult.success ? 'OK' : 'Failed', bgResult.success, slideNum, totalToolCalls);
935
- if (bgResult.success) {
936
- builtSlides.push(`Slide ${slideNum}: ${slidePlan.title} (${slidePlan.type})`);
1087
+ if (filled) {
1088
+ builtSlides.push(`Slide ${files.slideNum}: ${slidePlan.title} (${slidePlan.type})`);
937
1089
  try {
938
- await powerpointClient.powerpointAddNote(slideNum, result.html);
1090
+ await powerpointClient.powerpointAddNote(files.slideNum, htmlResult.html);
939
1091
  }
940
1092
  catch { }
941
1093
  }
942
1094
  else {
943
- logger.warn(`Slide ${slideNum}: Failed to set background: ${JSON.stringify(bgResult)}`);
944
- failCount++;
1095
+ logger.warn(`Slide ${files.slideNum}: All rendering attempts failed, marking for deletion`);
1096
+ unfilledSlideNums.push(files.slideNum);
945
1097
  }
946
1098
  }
947
- if (builtSlides.length > 0) {
1099
+ if (unfilledSlideNums.length > 0) {
1100
+ const sortedDesc = [...unfilledSlideNums].sort((a, b) => b - a);
1101
+ for (const slideNum of sortedDesc) {
1102
+ try {
1103
+ await powerpointClient.powerpointDeleteSlide(slideNum);
1104
+ totalToolCalls++;
1105
+ logger.info(`Deleted unfilled slide ${slideNum}`);
1106
+ }
1107
+ catch (e) {
1108
+ logger.warn(`Failed to delete unfilled slide ${slideNum}: ${e}`);
1109
+ }
1110
+ }
1111
+ }
1112
+ const hasClosingInPlan = plan.slides.some(s => s.type === 'closing');
1113
+ const hasClosingBuilt = builtSlides.some(s => s.includes('(closing)'));
1114
+ if (hasClosingInPlan && !hasClosingBuilt && builtSlides.length > 0) {
1115
+ logger.info('Closing slide was lost during assembly — adding it now');
948
1116
  try {
949
- const slideCountResult = await powerpointClient.powerpointGetSlideCount();
950
- const totalSlidesInPpt = slideCountResult['slide_count'] || 0;
951
- if (totalSlidesInPpt > builtSlides.length) {
952
- for (let d = totalSlidesInPpt; d > builtSlides.length; d--) {
953
- await powerpointClient.powerpointDeleteSlide(d);
954
- totalToolCalls++;
1117
+ await powerpointClient.powerpointAddSlide(7);
1118
+ totalToolCalls++;
1119
+ const slideCountRes = await powerpointClient.powerpointGetSlideCount();
1120
+ const newSlideNum = slideCountRes['slide_count'] || builtSlides.length + 1;
1121
+ const closingPlan = plan.slides.find(s => s.type === 'closing');
1122
+ const closingTagline = (closingPlan.content_direction || '').replace(/감사합니다|thank\s*you/gi, '').trim() || undefined;
1123
+ const closingHtml = buildClosingSlideHtml(plan.design, companyName, newSlideNum, language, closingTagline);
1124
+ const clHtmlName = `hanseol_closing_${timestamp}.html`;
1125
+ const clPngName = `hanseol_closing_${timestamp}.png`;
1126
+ const clHtmlWrite = path.join(tempWritePath, clHtmlName);
1127
+ const clPngWrite = path.join(tempWritePath, clPngName);
1128
+ const clHtmlWin = `${tempWinPath}\\${clHtmlName}`;
1129
+ const clPngWin = `${tempWinPath}\\${clPngName}`;
1130
+ const viewportHtml = injectEdgeSizing(closingHtml, plan.design.background_color);
1131
+ fs.writeFileSync(clHtmlWrite, viewportHtml, 'utf-8');
1132
+ const ssResult = await powerpointClient.renderHtmlToImage(clHtmlWin, clPngWin);
1133
+ totalToolCalls++;
1134
+ try {
1135
+ fs.unlinkSync(clHtmlWrite);
1136
+ }
1137
+ catch { }
1138
+ if (ssResult.success) {
1139
+ const bgResult = await powerpointClient.powerpointAddFullSlideImage(newSlideNum, clPngWin);
1140
+ totalToolCalls++;
1141
+ if (bgResult.success) {
1142
+ builtSlides.push(`Slide ${newSlideNum}: ${closingPlan.title} (closing)`);
1143
+ logger.info('Closing slide added successfully');
1144
+ }
1145
+ try {
1146
+ fs.unlinkSync(clPngWrite);
955
1147
  }
1148
+ catch { }
956
1149
  }
957
1150
  }
958
1151
  catch (e) {
959
- logger.warn(`Failed to clean up trailing slides: ${e}`);
1152
+ logger.warn(`Failed to add closing slide: ${e}`);
960
1153
  }
961
1154
  }
962
1155
  if (builtSlides.length > 0) {
@@ -1069,7 +1262,8 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1069
1262
  if (/로고|슬로건|연락처|contact|logo|placeholder/i.test(titleSubtitle)) {
1070
1263
  titleSubtitle = '';
1071
1264
  }
1072
- const companyMatch = instruction.match(/회사명\s*[::]\s*([^\s,,、]+)/);
1265
+ const cleanInstruction = instruction.replace(/\*\*/g, '');
1266
+ const companyMatch = cleanInstruction.match(/회사명\s*[::]?\s*([^\s,,、]+)/);
1073
1267
  if (companyMatch && companyMatch[1]) {
1074
1268
  const companyName_ = companyMatch[1];
1075
1269
  if (titleSlidePlan && titleSlidePlan.title.trim() !== companyName_) {
@@ -1083,13 +1277,14 @@ async function runStructured(llmClient, instruction, explicitSavePath) {
1083
1277
  }
1084
1278
  }
1085
1279
  }
1280
+ rebalanceLayouts(plan);
1086
1281
  if (phaseLogger)
1087
1282
  phaseLogger('powerpoint-create', 'html-generation', 'Starting parallel HTML generation...');
1088
1283
  const htmlResults = await generateAllHtml(llmClient, plan, companyName, titleSubtitle, kstDate, language, phaseLogger);
1089
1284
  const validatedResults = await validateAndRegenerate(llmClient, htmlResults, plan, language, phaseLogger);
1090
1285
  if (phaseLogger)
1091
1286
  phaseLogger('powerpoint-create', 'assembly', `Assembling ${validatedResults.size} slides into PowerPoint...`);
1092
- const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, phaseLogger, toolCallLogger);
1287
+ const { builtSlides, totalToolCalls } = await assemblePresentation(validatedResults, plan, timestamp, savePath, companyName, language, phaseLogger, toolCallLogger);
1093
1288
  const duration = Date.now() - startTime;
1094
1289
  const slideList = builtSlides.join('\n');
1095
1290
  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}`;