""" final-merge.py Склеивает 5 секционных PPTX в один + cover/TOC/итоги. Использует python-pptx и прямые XML-манипуляции. """ import copy import os from pptx import Presentation from pptx.util import Inches, Pt from pptx.enum.shapes import MSO_SHAPE from pptx.dml.color import RGBColor WORKSPACE = "/Users/alexandr/.mavis/plans/plan_85053139/workspace/lc-evo-deck" SECTIONS = [ ("section1-chains", "LangChain 1.0: chains, LCEL, agents, retrievers", 27), ("section2-langgraph", "LangGraph 1.0: state, nodes, persistence, HITL", 33), ("section3-deepagents", "Deep Agents: harness, todos, virtual FS, subagents", 26), ("section4-openswe", "Open SWE: async coding agent, triggers, dashboard", 24), ("section5-ecosystem", "Ecosystem: LangSmith, Studio, deployment", 12), ] # Theme colors (from design-system.js) BG_PRIMARY = RGBColor(0x0A, 0x1A, 0x2A) BG_ELEVATED = RGBColor(0x14, 0x2B, 0x3F) TEXT_PRIMARY = RGBColor(0xE6, 0xF0, 0xF7) TEXT_SECONDARY = RGBColor(0xB5, 0xC4, 0xD1) TEXT_MUTED = RGBColor(0x8A, 0x9A, 0xAB) ACCENT_TEAL = RGBColor(0x21, 0x9E, 0xBC) ACCENT_GOLD = RGBColor(0xFF, 0xB7, 0x03) ACCENT_BLUE = RGBColor(0x8E, 0xCA, 0xE6) BORDER_SUBTLE = RGBColor(0x23, 0x3A, 0x4F) def add_dark_background(slide): """Fill slide background with dark navy.""" bg = slide.background fill = bg.fill fill.solid() fill.fore_color.rgb = BG_PRIMARY def add_text(slide, x, y, w, h, text, *, size=18, bold=False, color=TEXT_PRIMARY, align=None, font="Inter"): tb = slide.shapes.add_textbox(Inches(x), Inches(y), Inches(w), Inches(h)) tf = tb.text_frame tf.word_wrap = True tf.margin_left = Inches(0.05) tf.margin_right = Inches(0.05) tf.margin_top = Inches(0.02) tf.margin_bottom = Inches(0.02) p = tf.paragraphs[0] if align == "center": from pptx.enum.text import PP_ALIGN p.alignment = PP_ALIGN.CENTER elif align == "right": from pptx.enum.text import PP_ALIGN p.alignment = PP_ALIGN.RIGHT run = p.add_run() run.text = text run.font.size = Pt(size) run.font.bold = bold run.font.name = font run.font.color.rgb = color return tb def add_rect(slide, x, y, w, h, fill, line=None, line_width=0.75): shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)) shape.fill.solid() shape.fill.fore_color.rgb = fill if line is None: shape.line.fill.background() else: shape.line.color.rgb = line shape.line.width = Pt(line_width) return shape def add_rounded_rect(slide, x, y, w, h, fill, line=None, line_width=0.75): shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(x), Inches(y), Inches(w), Inches(h)) shape.fill.solid() shape.fill.fore_color.rgb = fill if line is None: shape.line.fill.background() else: shape.line.color.rgb = line shape.line.width = Pt(line_width) return shape def make_cover_slide(prs): """Cover slide.""" slide = prs.slides.add_slide(blank_layout_global) add_dark_background(slide) # Top accent bar add_rect(slide, 0, 0, 10, 0.15, ACCENT_TEAL) # Eyebrow add_text(slide, 0.5, 0.6, 9, 0.4, "DEEP DIVE / TUTORIAL", size=14, bold=True, color=ACCENT_TEAL) # Main title add_text(slide, 0.5, 1.3, 9, 1.2, "Эволюция LangChain", size=44, bold=True, color=TEXT_PRIMARY) # Subtitle add_text(slide, 0.5, 2.5, 9, 0.7, "от chains до Deep Agents и Open SWE", size=28, color=TEXT_SECONDARY) # Decorative line add_rect(slide, 0.5, 3.4, 1.5, 0.06, ACCENT_GOLD) # Description add_text(slide, 0.5, 3.7, 9, 1.3, "Большой tutorial по экосистеме LangChain: chains, LCEL, " "LangGraph, Deep Agents, Open SWE. Python ≥ 1.0, реальные API, " "плотный код.", size=18, color=TEXT_SECONDARY) # Bottom metadata add_text(slide, 0.5, 5.05, 9, 0.4, "100+ слайдов | 2022 -- 2026 | Python 1.0+", size=12, color=TEXT_MUTED, align="center") def make_toc_slide(prs, sections): """Table of contents slide.""" slide = prs.slides.add_slide(blank_layout_global) add_dark_background(slide) add_text(slide, 0.5, 0.4, 9, 0.5, "Содержание", size=36, bold=True, color=TEXT_PRIMARY) add_rect(slide, 0.5, 1.0, 1.2, 0.05, ACCENT_TEAL) y = 1.4 total = 0 for i, (sid, title, count) in enumerate(sections, 1): # Numbered card add_rounded_rect(slide, 0.5, y, 9, 0.7, BG_ELEVATED, line=BORDER_SUBTLE, line_width=0.5) # Number badge add_rounded_rect(slide, 0.7, y + 0.1, 0.5, 0.5, ACCENT_TEAL) add_text(slide, 0.7, y + 0.13, 0.5, 0.4, str(i), size=22, bold=True, color=BG_PRIMARY, align="center") # Title add_text(slide, 1.4, y + 0.05, 6, 0.35, title, size=18, bold=True, color=TEXT_PRIMARY) # Subtitle / count add_text(slide, 1.4, y + 0.38, 6, 0.3, f"{count} слайдов", size=12, color=TEXT_MUTED) # Page range placeholder (will be filled after merge) add_text(slide, 8.3, y + 0.18, 1.1, 0.4, f"~{count} сл.", size=14, color=ACCENT_BLUE, align="right") total += count y += 0.85 # Total add_text(slide, 0.5, y + 0.1, 9, 0.4, f"Всего: {total} слайдов + cover, TOC, итоги = {total + 3}", size=14, color=ACCENT_GOLD) def make_summary_slide(prs, total, section_counts): """Final summary / takeaways slide.""" slide = prs.slides.add_slide(blank_layout_global) add_dark_background(slide) add_text(slide, 0.5, 0.4, 9, 0.5, "Итоги", size=36, bold=True, color=TEXT_PRIMARY) add_rect(slide, 0.5, 1.0, 1.2, 0.05, ACCENT_GOLD) add_text(slide, 0.5, 1.3, 9, 0.6, f"Всего {total} слайдов: от chains 2022 до Open SWE 2025/2026.", size=18, color=TEXT_SECONDARY) # Takeaways list y = 2.1 add_text(slide, 0.5, y, 9, 0.4, "Что мы разобрали:", size=20, bold=True, color=ACCENT_TEAL) y += 0.6 items = [ "LangChain 1.0: chains, LCEL, agents, retrievers -- ядро фреймворка", "LangGraph 1.0: stateful графы, persistence, HITL, streaming", "Deep Agents: harness, todos, virtual FS, subagents -- 'out of the box' reasoning", "Open SWE: async coding agent с триггерами и дашбордом", "LangSmith + LangGraph Studio: observability и локальная разработка", ] for item in items: add_text(slide, 0.7, y, 9, 0.4, "- " + item, size=14, color=TEXT_PRIMARY) y += 0.45 # Final call-out y += 0.2 add_rounded_rect(slide, 0.5, y, 9, 0.7, BG_ELEVATED, line=ACCENT_GOLD, line_width=1.5) add_text(slide, 0.7, y + 0.1, 8.6, 0.5, "Главный тренд: от chain-of-prompts к stateful агентам с harness и human-in-the-loop.", size=15, bold=True, color=ACCENT_GOLD) # Footer add_text(slide, 0.5, 5.25, 9, 0.3, "Mavis / 2026 / Python 1.0+ / pre-compile lint passed (no em-dash / smart quotes)", size=10, color=TEXT_MUTED, align="center") def copy_slide_from_to(src_prs, src_idx, dst_prs): """Copy slide at src_idx from src_prs to dst_prs, preserving shapes/formatting via XML.""" src_slide = src_prs.slides[src_idx] # Use blank layout of dest dst_slide = dst_prs.slides.add_slide(blank_layout_global) # Copy background if present if src_slide.background and src_slide.background.fill.type is not None: try: dst_slide.background.fill.solid() # Don't override -- just let shapes drive background except Exception: pass # Copy all shapes via deep XML clone for shape in src_slide.shapes: el = shape.element new_el = copy.deepcopy(el) dst_slide.shapes._spTree.insert_element_before(new_el, "p:extLst") # Copy slide notes if any if src_slide.has_notes_slide: try: notes_text = src_slide.notes_slide.notes_text_frame.text if notes_text.strip(): dst_slide.notes_slide.notes_text_frame.text = notes_text except Exception: pass def main(): out_path = os.path.join(WORKSPACE, "output", "langchain-evolution.pptx") os.makedirs(os.path.dirname(out_path), exist_ok=True) # Start from scratch with 16:9 from pptx.util import Emu prs = Presentation() prs.slide_width = Inches(10) prs.slide_height = Inches(5.625) blank_layout = prs.slide_layouts[6] if len(prs.slide_layouts) > 6 else prs.slide_layouts[-1] print(f"[merge] using layout index {prs.slide_layouts.index(blank_layout)} (total: {len(prs.slide_layouts)})") global blank_layout_global blank_layout_global = blank_layout # Add cover print("[merge] adding cover...") make_cover_slide(prs) # Add TOC print("[merge] adding TOC...") make_toc_slide(prs, SECTIONS) # Copy slides from each section total = 0 for sid, title, expected in SECTIONS: sec_path = os.path.join(WORKSPACE, "slides", sid, f"{sid.replace('section', 'section')}.pptx") # Actually file is sectionN.pptx inside section-/ pptx_name = f"{sid.split('-')[0]}.pptx" # e.g. "section1.pptx" sec_path = os.path.join(WORKSPACE, "slides", sid, pptx_name) if not os.path.exists(sec_path): print(f"[merge] MISSING: {sec_path}") continue print(f"[merge] merging {sid} from {sec_path}") sec_prs = Presentation(sec_path) actual = len(sec_prs.slides) for i in range(actual): copy_slide_from_to(sec_prs, i, prs) total += actual print(f"[merge] copied {actual} slides (expected {expected})") # Add summary slide print(f"[merge] adding summary (total so far: {total + 3})") make_summary_slide(prs, total + 2, [s[2] for s in SECTIONS]) prs.save(out_path) print(f"[merge] saved {out_path}") print(f"[merge] final slide count: {len(prs.slides)}") if __name__ == "__main__": main()