├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── applications ├── __init__.py ├── gradio_app │ ├── __init__.py │ ├── loop_chat.py │ ├── once_chat.py │ ├── ppt_generate.py │ └── reveal_generate.py ├── reveal │ ├── __init__.py │ └── reveal.py └── templates │ └── index.html ├── config.py ├── docs └── imgs │ ├── docs_pptx_parameterization_examples.png │ ├── generated_ppt.png │ └── template_ppt.png ├── ppt_template ├── ALL FROM WPS └── beauty.pptx ├── requirements.txt ├── run.py └── utils ├── __init__.py ├── llm.py ├── ppt_tools.py ├── pptx_generator.py ├── prompter ├── __init__.py ├── librarian.py └── library │ ├── formatted │ └── in_json │ │ └── v1.pmt │ └── ppt │ ├── generate_by_template │ └── v1.pmt │ └── generate_content │ └── v1.pmt └── revealjs ├── __init__.py ├── background.py ├── common.py ├── core.py ├── exceptions.py ├── helpers.py └── utils.py /.env.example: -------------------------------------------------------------------------------- 1 | # LLM config 2 | API_KEY=your_api_key 3 | MODEL=your_model 4 | API_URL=https://api.deepseek.com 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | share/python-wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | *.py,cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | cover/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | .pybuilder/ 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | # For a library or package, you might want to ignore these files since the code is 86 | # intended to run in multiple environments; otherwise, check them in: 87 | # .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # poetry 97 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 98 | # This is especially recommended for binary packages to ensure reproducibility, and is more 99 | # commonly ignored for libraries. 100 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 101 | #poetry.lock 102 | 103 | # pdm 104 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 105 | #pdm.lock 106 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 107 | # in version control. 108 | # https://pdm.fming.dev/#use-with-ide 109 | .pdm.toml 110 | 111 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 112 | __pypackages__/ 113 | 114 | # Celery stuff 115 | celerybeat-schedule 116 | celerybeat.pid 117 | 118 | # SageMath parsed files 119 | *.sage.py 120 | 121 | # Environments 122 | .env 123 | .venv 124 | env/ 125 | venv/ 126 | ENV/ 127 | env.bak/ 128 | venv.bak/ 129 | 130 | # Spyder project settings 131 | .spyderproject 132 | .spyproject 133 | 134 | # Rope project settings 135 | .ropeproject 136 | 137 | # mkdocs documentation 138 | /site 139 | 140 | # mypy 141 | .mypy_cache/ 142 | .dmypy.json 143 | dmypy.json 144 | 145 | # Pyre type checker 146 | .pyre/ 147 | 148 | # pytype static type analyzer 149 | .pytype/ 150 | 151 | # Cython debug symbols 152 | cython_debug/ 153 | 154 | # PyCharm 155 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 156 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 157 | # and can be added to the global gitignore or merged into this file. For a more nuclear 158 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 159 | .idea/ 160 | 161 | # setting json 162 | setting.json 163 | 164 | .Agently/ 165 | .cache/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 MetaImagine 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ai-pptx 2 | 3 | 😆 Generate PPT by LLM follow your template. 4 | 5 | 📢 Not only use llm to generate ppt, but also according to your favorite ppt template. Just you need to simply change the template parameters. 6 | 7 | ## ShowCase 8 | | Template | Generated Slide | 9 | |------------------------------------------|-------------------------------------------| 10 | | | | 11 | 12 | ## Installation 13 | 14 | > python >= 3.10 15 | 16 | 1. Clone the [.env.example](./.env.example) and update your own llm api key `llm -> api_key` 17 | 18 | ``` 19 | $ cp .env.example .env 20 | ``` 21 | 22 | 2. Install the dependencies 23 | 24 | ``` 25 | $ pip install -r requirements.txt 26 | ``` 27 | 28 | ## Run in Gradio 29 | 30 | ``` 31 | $ python run.py 32 | ``` 33 | 34 | ## How to use personal pptx template ? 35 | 36 | Because there is no relatively stable extraction parameter scheme, the use of personal ppt templates needs to do their own parameterization and semantization. 37 | 38 | ### Parameterization 39 | 40 | For each text box, you need to use the `{param}` to fill it back. 41 | 42 | #### For example: 43 | 44 | 45 | 46 | ### Semantization 47 | 48 | And above parameter name can be customized, but because it needs to be understood by the LLM, **you must include semantics**. If need the Number to sort, you need to fill `[sth]_[no]` as the parameter name. 49 | 50 | #### For example: 51 | 52 | If the textbox is filled by title info, you should be fill it with `title`. 53 | 54 | If the textbox is filled by the content of the first paragraph, you should be fill it with `content_1`. 55 | 56 | If the textbox is filled by the content of the second paragraph, you should be fill it with `content_2`. 57 | 58 | 59 | ## Features 60 | 61 | - ☑ Generate PPT by LLM 62 | - ☑ New PPT follow your template 63 | - ☑ Support gradio app 64 | - ☐ Generate all content by Agent 65 | 66 | ## License 67 | 68 | Licensed under the [MIT](./LICENSE). 69 | 70 | ## Contact 71 | 72 | See the homepage ;) -------------------------------------------------------------------------------- /applications/__init__.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.templating import Jinja2Templates 3 | from fastapi.staticfiles import StaticFiles 4 | from fastapi import FastAPI, APIRouter 5 | import gradio as gr 6 | from applications import gradio_app 7 | import logging 8 | import os 9 | from config import config 10 | 11 | logger = logging.getLogger(__name__) 12 | app = FastAPI() 13 | 14 | static_path = "./utils/revealjs/reveal_src" 15 | templates_path = "./applications/templates" 16 | 17 | templates = Jinja2Templates(directory=templates_path) 18 | 19 | 20 | @app.get("/presentation/{pid}") 21 | def home(pid, request: Request): 22 | cache_path = os.path.join(os.getcwd(), f"{config.cache_folder}/{pid}.md") 23 | with open(cache_path, "r", encoding="utf-8") as f: 24 | content = f.read() 25 | content = content.replace("\n\n", "\n---\n") 26 | 27 | logger.info(content) 28 | 29 | data = { 30 | "content": content, 31 | "request": request 32 | } 33 | return templates.TemplateResponse("index.html", data) 34 | 35 | 36 | # bind gradio 37 | app = gr.mount_gradio_app(app, gradio_app.current_app, path="/gradio") 38 | -------------------------------------------------------------------------------- /applications/gradio_app/__init__.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | 3 | from .reveal_generate import reveal_generator_tab 4 | from .ppt_generate import ppt_generator_tab 5 | from .once_chat import once_chat_tab 6 | from .loop_chat import loop_chat_tab 7 | 8 | with gr.Blocks(theme=gr.themes.Base()) as app: 9 | reveal_generator_tab() 10 | ppt_generator_tab() 11 | once_chat_tab() 12 | loop_chat_tab() 13 | 14 | current_app = app 15 | -------------------------------------------------------------------------------- /applications/gradio_app/loop_chat.py: -------------------------------------------------------------------------------- 1 | 2 | import gradio as gr 3 | from config import config 4 | 5 | def llm_chat( 6 | message: str, history: list, system_prompt: str, model_name: str, temperature: float 7 | ): 8 | messages = [] 9 | if system_prompt: 10 | messages.append({"role": "system", "content": system_prompt}) 11 | for m in history: 12 | messages.append({"role": "user", "content": m[0]}) 13 | messages.append({"role": "assistant", "content": m[1]}) 14 | messages.append({"role": "user", "content": message}) 15 | gen = config.llm.chat(messages, model_name=model_name, temperature=temperature) 16 | content = "" 17 | for partial_content in gen: 18 | content += partial_content 19 | yield content 20 | return "Done!" 21 | 22 | def loop_chat_tab(): 23 | with gr.Tab("多轮对话"): 24 | with gr.Row(): 25 | with gr.Column(scale=3): 26 | system_prompt = gr.Textbox( 27 | "You are helpful AI.", lines=4, label="📕 System Prompt" 28 | ) 29 | with gr.Column(scale=1): 30 | model_name = gr.Dropdown( 31 | ["moonshot-v1-8k"], label="💻 Model Name", value="moonshot-v1-8k" 32 | ) 33 | temperature = gr.Slider( 34 | minimum=0.0, 35 | maximum=1.0, 36 | value=0.1, 37 | step=0.1, 38 | interactive=True, 39 | label="🌡 Temperature", 40 | ) 41 | 42 | with gr.Row(): 43 | gr.ChatInterface( 44 | llm_chat, additional_inputs=[system_prompt, model_name, temperature] 45 | ) 46 | -------------------------------------------------------------------------------- /applications/gradio_app/once_chat.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | from config import config 3 | 4 | def llm_chat_once( 5 | message: str, system_prompt: str, model_name: str, temperature: float 6 | ): 7 | gen = config.llm.chat_once( 8 | prompt=message, 9 | system_prompt=system_prompt, 10 | model_name=model_name, 11 | temperature=temperature, 12 | ) 13 | content = "" 14 | for partial_content in gen: 15 | content += partial_content 16 | return content 17 | 18 | def once_chat_tab(): 19 | with gr.Tab("单轮对话"): 20 | with gr.Row(): 21 | with gr.Column(scale=4): 22 | system_prompt = gr.Textbox( 23 | "You are helpful AI.", 24 | placeholder="input system prompt here!", 25 | label="📕 System Prompt", 26 | ) 27 | message = gr.Textbox( 28 | "Hello, how are you?", 29 | placeholder="input your prompt here!", 30 | lines=6, 31 | label="📝 User Prompt", 32 | ) 33 | 34 | with gr.Column(scale=1): 35 | with gr.Row(): 36 | temperature = gr.Slider( 37 | minimum=0.0, 38 | maximum=1.0, 39 | value=0.1, 40 | step=0.1, 41 | interactive=True, 42 | label="🌡 Temperature", 43 | ) 44 | with gr.Row(): 45 | with gr.Column(scale=1): 46 | model_name = gr.Dropdown( 47 | ["moonshot-v1-8k"], 48 | label="💻 Model Name", 49 | value="moonshot-v1-8k", 50 | ) 51 | with gr.Column(scale=1): 52 | chat_submit_btn = gr.Button(value="🚀 Send") 53 | 54 | result = gr.Textbox(label="💬 Response", lines=8) 55 | chat_submit_btn.click( 56 | llm_chat_once, 57 | inputs=[message, system_prompt, model_name, temperature], 58 | outputs=[result], 59 | ) 60 | -------------------------------------------------------------------------------- /applications/gradio_app/ppt_generate.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | @Time : 2024/3/25 16:44 4 | @Author : minglang.wu 5 | @Email : minglang.wu@tenclass.com 6 | @File : gradio_app.py 7 | @Desc : 8 | """ 9 | import datetime 10 | import json 11 | 12 | import gradio as gr 13 | import random 14 | import time 15 | import logging 16 | import pythoncom 17 | from config import config 18 | 19 | from utils.pptx_generator import PptxGenerator 20 | 21 | logger = logging.getLogger(__name__) 22 | gen = PptxGenerator( 23 | config.llm, save_path="output.pptx", template_path="./ppt_template/beauty.pptx" 24 | ) 25 | 26 | 27 | def init_or_reload_info(save_path: str, template_path: str): 28 | global gen 29 | gen = PptxGenerator(config.llm, save_path=save_path, template_path=template_path) 30 | 31 | 32 | def generate_ppt_step1(topic: str): 33 | global gen 34 | if gen is None: 35 | return ( 36 | "Error!\n" 37 | "Please input the path of template.pptx and save path, and click [Init/Reload] button!" 38 | ) 39 | return gen.llm_generate_online_content(topic) 40 | 41 | 42 | def generate_ppt_step2(topic, author, company_name, online_content: str): 43 | global gen 44 | if gen is None: 45 | return ( 46 | "Error!\n" 47 | "Please input the path of template.pptx and save path, and click [Init/Reload] button!" 48 | ) 49 | meta_info = { 50 | "topic": topic, 51 | "author": author, 52 | "company_name": company_name, 53 | "now_date": datetime.datetime.now().strftime("%Y-%m-%d"), 54 | } 55 | generation_content = gen.llm_generate_content_slide_content( 56 | meta_info["topic"], online_content 57 | ) 58 | gen.generate_ppt(meta_info, generation_content) 59 | 60 | 61 | def ppt_generator_tab(): 62 | global gen 63 | with gr.Tab("PPT生成"): 64 | with gr.Row(): 65 | template_path = gr.Textbox( 66 | "./ppt_template/beauty.pptx", 67 | label="PPT Template Path", 68 | placeholder="input template.pptx path", 69 | scale=3, 70 | ) 71 | save_path = gr.Textbox( 72 | "output.pptx", 73 | label="PPT Save Path", 74 | placeholder="input save path", 75 | scale=3, 76 | ) 77 | init_or_reload_path_btn = gr.Button(value="🔄 Init/Reload", scale=1) 78 | with gr.Row(): 79 | topic = gr.Textbox(label="Topic", placeholder="input your topic", scale=1) 80 | author = gr.Textbox( 81 | label="Author", placeholder="input author name", scale=1 82 | ) 83 | company_name = gr.Textbox( 84 | label="Company Name", placeholder="input company name", scale=1 85 | ) 86 | with gr.Row(): 87 | # Step.1 88 | with gr.Column(scale=1): 89 | step1_btn = gr.Button(value="Step.1 生成大纲内容 👇") 90 | step1_output = gr.Code( 91 | language="json", 92 | interactive=True, 93 | lines=8, 94 | label="📝 Step.1 PPT大纲内容", 95 | ) 96 | # Step.2 97 | with gr.Column(scale=1): 98 | step2_btn = gr.Button(value="Step.2 生成完整内容 👇") 99 | # Step.3 100 | 101 | init_or_reload_path_btn.click( 102 | init_or_reload_info, inputs=[save_path, template_path] 103 | ) 104 | step1_btn.click(generate_ppt_step1, inputs=[topic], outputs=[step1_output]) 105 | step2_btn.click( 106 | generate_ppt_step2, inputs=[topic, author, company_name, step1_output] 107 | ) 108 | -------------------------------------------------------------------------------- /applications/gradio_app/reveal_generate.py: -------------------------------------------------------------------------------- 1 | import gradio as gr 2 | from config import config 3 | import os 4 | import uuid 5 | SYS_PROMPT_TPL = "你是一名资深的文章撰写专家,可以完成复杂的、长文本的生成工作。" 6 | PROMPT_TPL = """撰写一份标题为《{topic}》的文档。 7 | 输出内容样式必须采用以下template样式。 8 | 其中##为章节,输出内容中至少要有6个章节标题及下面内容! 9 | 其中##为章节,输出内容中至少要有6个章节标题及下面内容! 10 | 其中##为章节,输出内容中至少要有6个章节标题及下面内容!: 11 | 12 | template: 13 | 14 | # 演示标题 15 | 16 | ## 1.章节标题。(后面要求禁止输出:每个##下面必须有2-5个###。) 17 | 18 | ### **章节内容**:章节副标题。章节副标题20个字以内的。 19 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。 20 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。 21 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 22 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 23 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 24 | - ![主题](https://source.unsplash.com/1000x600/?+英文主题) 25 | 26 | ### **表格章节内容**:章节副标题。表格内容下面不需要增加主题配图。 27 | | title | col1 | col2 | 28 | | --- | --- | --- | 29 | | item1 | 2 | 3 | 30 | | item2 | 5 | 6 | 31 | 32 | ### **章节内容**:章节副标题。章节副标题20个字以内的。 33 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。 34 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。 35 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 36 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 37 | - **内容标题**: 内容详述,20个字到100个字。此-必须必须包含加粗的内容标题。看内容是否需要此-,否则删除此行。 38 | - ![主题](https://source.unsplash.com/1000x600/?+英文主题) 39 | 40 | ### ![主题](https://source.unsplash.com/1000x600/?+英文主题) 41 | 42 | ## 2.章节标题(后面要求禁止输出:下面的内容参照上面的模板。每个##下面必须有2-5个###。需要有6个章节标题及下面内容。)""" 43 | 44 | 45 | def generate_presentation_md(topic): 46 | gen = config.llm.chat_once( 47 | prompt=PROMPT_TPL.format(topic=topic), 48 | system_prompt=SYS_PROMPT_TPL, 49 | model_name="moonshot-v1-8k", 50 | temperature=0.1, 51 | ) 52 | content = "" 53 | for partial_content in gen: 54 | content += partial_content 55 | 56 | # 缓存到cache folder中并以随机ID命名 57 | random_id = uuid.uuid4().hex[:8] 58 | with open(os.path.join(os.getcwd(), f"{config.cache_folder}/{random_id}.md"), "w", encoding="utf-8") as f: 59 | f.write(content) 60 | return content, random_id 61 | 62 | 63 | def generate_live_link(pid): 64 | return f"""👇 在线体验链接已生成: 65 | 66 | [点击此处访问在线PPT](http://127.0.0.1:8080/presentation/{pid})""" 67 | 68 | 69 | def reveal_generator_tab(): 70 | with gr.Tab("在线PPT生成"): 71 | with gr.Row(): 72 | topic_textbox = gr.Textbox(label="Topic", scale=3) 73 | random_id_textbox = gr.Textbox(label="内容随机码ID", interactive=False, scale=1) 74 | with gr.Column(scale=1): 75 | generate_md_btn = gr.Button("Step.01 🤖 AI Generate MD", scale=1) 76 | generate_link_btn = gr.Button("Step.02 Generate Live Link", scale=1) 77 | 78 | with gr.Row(): 79 | generated_md = gr.Markdown("Step.01 👉 PPT 大纲生成区域") 80 | generated_live_link_md = gr.Markdown("Step.02 👉 链接生成区域") 81 | 82 | generate_md_btn.click(fn=generate_presentation_md, inputs=topic_textbox, outputs=[ 83 | generated_md, random_id_textbox]) 84 | 85 | generate_link_btn.click(fn=generate_live_link, 86 | inputs=random_id_textbox, outputs=generated_live_link_md) 87 | -------------------------------------------------------------------------------- /applications/reveal/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/applications/reveal/__init__.py -------------------------------------------------------------------------------- /applications/reveal/reveal.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/applications/reveal/reveal.py -------------------------------------------------------------------------------- /applications/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | reveal.js 11 | 12 | 19 | 26 | 27 | 28 | 33 | 34 | 78 | 79 | 80 |
85 | 93 |
94 |
95 |
96 |
97 | 98 |
99 |
100 |
101 | 102 | 108 | 109 | 115 | 121 | 128 | 134 | 141 | 142 | 148 | 154 | 160 | 161 | 167 | 173 | 179 | 180 | 186 | 187 | 198 | 199 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import json 2 | import Agently 3 | from dotenv import load_dotenv 4 | 5 | load_dotenv() 6 | 7 | 8 | class Config: 9 | 10 | cache_folder = "./.cache" 11 | 12 | def __init__(self) -> None: 13 | 14 | # init cache folder 15 | import os 16 | 17 | os.makedirs(self.cache_folder, exist_ok=True) 18 | 19 | # load llm 20 | from utils.llm import LLM 21 | 22 | self.llm = LLM( 23 | api_key=os.environ["API_KEY"], 24 | base_url=os.environ["API_URL"], 25 | model_name=os.environ["MODEL"], 26 | ) 27 | 28 | # load agent factory 29 | self.agent_factory = ( 30 | Agently.AgentFactory() 31 | .set_settings("current_model", "OAIClient") 32 | .set_settings("model.OAIClient.url", os.environ["API_URL"]) 33 | .set_settings("model.OAIClient.auth", {"api_key": os.environ["API_KEY"]}) 34 | .set_settings("model.OAIClient.options", {"model": os.environ["MODEL"]}) 35 | ) 36 | 37 | 38 | config = Config() 39 | -------------------------------------------------------------------------------- /docs/imgs/docs_pptx_parameterization_examples.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/docs/imgs/docs_pptx_parameterization_examples.png -------------------------------------------------------------------------------- /docs/imgs/generated_ppt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/docs/imgs/generated_ppt.png -------------------------------------------------------------------------------- /docs/imgs/template_ppt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/docs/imgs/template_ppt.png -------------------------------------------------------------------------------- /ppt_template/ALL FROM WPS: -------------------------------------------------------------------------------- 1 | 注意: 2 | ppt_template 下的模板来自金山WPS模板库, 仅供参考学习, 切勿商用! 3 | 4 | Attention: 5 | This ppt_template is sourced from the Kingsoft WPS template library \ 6 | and is intended solely for reference and learning purposes. 7 | Commercial use is strictly prohibited! -------------------------------------------------------------------------------- /ppt_template/beauty.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metaimagine/ai-pptx/353e35547e5299ebe87ebccbe5e86f1a5973d1a7/ppt_template/beauty.pptx -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | openai==1.14.0 2 | python-pptx==0.6.23 3 | jojo-office==0.2.6 4 | gradio==4.8.0 5 | markdown==3.6 6 | bs4==0.0.2 7 | Jinja2 8 | Agently -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | import uvicorn, logging 2 | 3 | logging.basicConfig( 4 | level=logging.INFO, 5 | format="%(asctime)s - %(levelname)s - %(message)s", 6 | handlers=[logging.FileHandler("app.log"), logging.StreamHandler()], 7 | ) 8 | 9 | if __name__ == '__main__': 10 | uvicorn.run('applications:app', host='0.0.0.0', port=8080, reload=True) -------------------------------------------------------------------------------- /utils/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | @Time : 2024/3/21 18:35 4 | @Author : minglang.wu 5 | @Email : minglang.wu@tenclass.com 6 | @File : __init__.py.py 7 | @Desc : 8 | """ 9 | -------------------------------------------------------------------------------- /utils/llm.py: -------------------------------------------------------------------------------- 1 | from openai import OpenAI 2 | 3 | 4 | class LLM: 5 | def __init__(self, api_key, base_url=None, model_name=None): 6 | self.client = OpenAI(api_key=api_key, base_url=base_url) 7 | self.default_model_name = model_name 8 | 9 | def chat(self, messages: list, model_name: str, temperature: float): 10 | """对话返回迭代器""" 11 | r = self.client.chat.completions.create( 12 | model=model_name if model_name else self.default_model_name, 13 | messages=messages, 14 | temperature=temperature, 15 | stream=True, 16 | ) 17 | for chunk in r: 18 | c = chunk.choices[0].delta.content 19 | if not c: 20 | continue 21 | yield c 22 | 23 | def chat_in_all(self, messages: list, model_name: str = None, temperature: float = 0.1): 24 | """对话返回全部内容""" 25 | r = self.client.chat.completions.create( 26 | model=model_name if model_name else self.default_model_name, 27 | messages=messages, 28 | temperature=temperature, 29 | stream=True, 30 | ) 31 | collected = [] 32 | for chunk in r: 33 | c = chunk.choices[0].delta.content 34 | if not c: 35 | continue 36 | collected.append(c) 37 | return ''.join(collected) 38 | 39 | def chat_once(self, prompt: str, system_prompt: str = None, model_name: str = None, temperature: float = 0.1): 40 | """一次性对话""" 41 | messages = [ 42 | {"role": "system", "content": system_prompt or "你是个全能助手"}, 43 | {"role": "user", "content": prompt} 44 | ] 45 | return self.chat_in_all(messages, model_name, temperature) 46 | -------------------------------------------------------------------------------- /utils/ppt_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | @Time : 2024/3/28 16:24 4 | @Author : minglang.wu 5 | @Email : minglang.wu@tenclass.com 6 | @File : ppt_tools.py 7 | @Desc : 8 | """ 9 | import os 10 | import time 11 | import uuid 12 | import copy 13 | from pptx.enum.shapes import MSO_SHAPE_TYPE 14 | 15 | 16 | def recreate_slide_by_win32(ppt_path: str, save_path: str, indexs: list): 17 | try: 18 | import pythoncom 19 | import win32com.client 20 | except ImportError: 21 | raise ImportError("Please install pywin32 first, pip install pywin32") 22 | 23 | ppt_instance, prs = None, None 24 | try: 25 | # Avoid the error of "CoInitialize has not been called" 26 | pythoncom.CoInitialize() 27 | ppt_instance = win32com.client.Dispatch("PowerPoint.Application") 28 | # open the powerpoint presentation headless in background 29 | read_only = True 30 | has_title = False 31 | window = False 32 | try: 33 | prs = ppt_instance.Presentations.open( 34 | os.path.join(os.getcwd(), ppt_path), read_only, has_title, window 35 | ) 36 | # Record the count of template slides 37 | count = prs.Slides.Count 38 | print("当前页面数量:", prs.Slides.Count, count) 39 | # Copy and paste the target slides 40 | for index in indexs: 41 | prs.Slides(index + 1).Copy() 42 | prs.Slides.Paste() # Index=insert_index, 不填则默认在最后插入 43 | print("当前页面数量:", prs.Slides.Count, count) 44 | time.sleep(0.3) # 防止复制过快导致丢失 45 | 46 | # Delete template slides 47 | for _ in range(count): 48 | prs.Slides(1).Delete() 49 | prs.SaveAs(os.path.join(os.getcwd(), save_path)) 50 | finally: 51 | if prs: 52 | prs.Close() 53 | finally: 54 | if ppt_instance: 55 | ppt_instance.Quit() 56 | del ppt_instance 57 | pythoncom.CoUninitialize() 58 | 59 | 60 | def duplicate_slide(pres, slide_index): 61 | """ 62 | duplicate side base on one slide 63 | 64 | refs from stackoverflow: 65 | https://stackoverflow.com/a/73954830 66 | https://stackoverflow.com/a/56074651/20159015 67 | https://stackoverflow.com/a/62921848/20159015 68 | https://stackoverflow.com/questions/50866634/how-to-copy-a-slide-with-python-pptx 69 | https://stackoverflow.com/questions/62864082/how-to-copy-a-slide-with-images-using-python-pptx 70 | refs from python-pptx github issue: 71 | https://github.com/scanny/python-pptx/issues/132 72 | https://github.com/scanny/python-pptx/issues/238 73 | """ 74 | 75 | def get_uuid(): 76 | return str(uuid.uuid4()) 77 | 78 | slide_to_copy = pres.slides[slide_index] 79 | 80 | new_slide = pres.slides.add_slide(slide_to_copy.slide_layout) 81 | 82 | # create images dict 83 | imgDict = {} 84 | 85 | for shp in slide_to_copy.shapes: 86 | if shp.shape_type == MSO_SHAPE_TYPE.PICTURE: 87 | img_name = get_uuid() 88 | # save image 89 | with open(img_name + ".jpg", "wb") as f: 90 | f.write(shp.image.blob) 91 | 92 | # add image to dict 93 | imgDict[img_name + ".jpg"] = [shp.left, shp.top, shp.width, shp.height] 94 | else: 95 | # create copy of elem 96 | el = shp.element 97 | newel = copy.deepcopy(el) 98 | 99 | # add elem to shape tree 100 | new_slide.shapes._spTree.insert_element_before(newel, "p:extLst") 101 | 102 | # things added first will be covered by things added last => since I want pictures to be in foreground, I will add them after others elements 103 | # you can change this if you want 104 | # add pictures 105 | for k, v in imgDict.items(): 106 | new_slide.shapes.add_picture(k, v[0], v[1], v[2], v[3]) 107 | os.remove(k) 108 | 109 | return new_slide # this returns slide so you can instantly work with it when it is pasted in presentation 110 | -------------------------------------------------------------------------------- /utils/pptx_generator.py: -------------------------------------------------------------------------------- 1 | import json 2 | import random 3 | import re 4 | import time 5 | import logging 6 | import pptx 7 | from pptx import Presentation 8 | 9 | from utils.llm import LLM 10 | from utils.ppt_tools import recreate_slide_by_win32 11 | from utils.prompter import PromptLibrarian 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | class PptxGenerator: 17 | PPT_PARAM_PATTERN = r'\{(.*?)\}' 18 | MD_CODE_JSON_PATTERN = r'```json.*?\n(.*?)```' 19 | 20 | def __init__(self, llm: LLM, save_path: str, template_path: str = None): 21 | self.llm = llm 22 | self.save_path = save_path 23 | self.has_template = False 24 | if template_path: 25 | self.has_template = True 26 | self.template_path = template_path 27 | self.template_ppt = Presentation(template_path) 28 | self.template_params = self._extract_params_from_template() 29 | 30 | def _extract_params_from_template(self): 31 | """生成PPT文件""" 32 | # 注意提取参数时,需要把模板PPT中的<组合>都解锁,不然可能存在找不到文本框的情况 33 | start_slide_idx = 0 34 | catalogue_slide_idx = 1 35 | title_slide_idx = 2 36 | content_slide_idxs = [3, 4, 5, 6, 7, 8] 37 | end_slide_idx = 9 38 | template_params = { 39 | "first_slide": {"nos": [start_slide_idx], "params": []}, 40 | "catalogue_slide": {"nos": [catalogue_slide_idx], "params": []}, 41 | "title_slide": {"nos": [title_slide_idx], "params": []}, 42 | "content_slide": {"nos": content_slide_idxs, "params": []}, 43 | "end_slide": {"nos": [end_slide_idx], "params": []} 44 | } 45 | 46 | # PPT中同一页的{params}定义必须不同,避免混淆,不同页面不做要求 47 | for slide_name, slide_info in template_params.items(): 48 | nos = slide_info["nos"] 49 | for n in nos: 50 | slide = self.template_ppt.slides[n] 51 | temp_params = [] 52 | for shape in slide.shapes: 53 | if shape.has_text_frame: 54 | for paragraph in shape.text_frame.paragraphs: 55 | for run in paragraph.runs: 56 | matches = [match.group(1) for match in re.finditer(self.PPT_PARAM_PATTERN, run.text)] 57 | temp_params.extend(matches) 58 | slide_info["params"].append(temp_params) 59 | return template_params 60 | 61 | def llm_generate_online_content(self, topic: str): 62 | """根据主题生成大纲""" 63 | output_format = json.dumps( 64 | { 65 | "topic": "str", 66 | "pages": [ 67 | { 68 | "title": "str", 69 | "pages": [ 70 | {"sub_title": "str", "desc": "str", "content": "str"} 71 | ] 72 | } 73 | ] 74 | }, ensure_ascii=False 75 | ) 76 | # remove space to save token 77 | output_format = output_format.replace(" ", "") 78 | prompt = PromptLibrarian.read(path="ppt.generate_content.v1").format(topic=topic, 79 | language="中文", 80 | output_format=output_format) 81 | messages = [ 82 | {"role": "system", "content": "你是个全能助手"}, 83 | {"role": "user", "content": prompt} 84 | ] 85 | c = self.llm.chat_in_all(messages) 86 | if c[-1] != "}": 87 | logger.warning("[Continuous] Reply not end, go on ...") 88 | messages.append({"role": "assistant", "content": c}) 89 | messages.append({"role": "user", "content": "继续"}) 90 | c += self.llm.chat_in_all(messages) 91 | return c 92 | 93 | def _llm_generate_content_slide_in_single(self, prompt: str, temperature: float, tp: dict): 94 | is_match = True 95 | try_count = 0 96 | while try_count <= 3: 97 | try_count += 1 98 | ctx = self.llm.chat_once(prompt=prompt, temperature=temperature) 99 | m = re.findall(self.MD_CODE_JSON_PATTERN, ctx, re.DOTALL) 100 | if m: ctx = m[0].replace("'", '"') 101 | # try to load json 102 | try: 103 | ctx = json.loads(ctx) 104 | except Exception as e: 105 | logger.warning(f"ctx json.loads error: \n{ctx}") 106 | time.sleep(0.8 * try_count) 107 | continue 108 | # try to match params 109 | gcs = [gc for gc in ctx.keys()] 110 | for tk in tp.keys(): 111 | if tk not in gcs: 112 | is_match = False 113 | break 114 | if is_match: 115 | logger.info(f"try generated count <{try_count}>, ctx: \n{ctx}") 116 | return ctx 117 | time.sleep(0.8 * try_count) 118 | return None 119 | 120 | def llm_generate_content_slide_content(self, topic: str, online_content: str): 121 | """根据大纲生成完整内容""" 122 | logger.info(f"online_content: \n{online_content}") 123 | online_content = json.loads(online_content) 124 | current_online_content = online_content["pages"] 125 | content_slide = self.template_params["content_slide"] 126 | 127 | # 新增标题编号、子标题编号 128 | for idx, c in enumerate(current_online_content): 129 | c["no"] = idx + 1 130 | for c in current_online_content: 131 | for idx, s in enumerate(c["pages"]): 132 | s["sub_no"] = idx + 1 133 | 134 | title_count = len(current_online_content) 135 | resorted_no_idxs = random.sample(range(len(content_slide["nos"])), k=title_count) 136 | current_template_resort_nos = [content_slide["nos"][idx] for idx in resorted_no_idxs] 137 | logger.info(f"content_slide_params: {content_slide['params']}") 138 | logger.info(f"resorted_no_idxs: {resorted_no_idxs}") 139 | logger.info(f"current_template_resort_nos: {current_template_resort_nos}") 140 | 141 | current_template_params = [ 142 | {k: "" for k in content_slide["params"][idx]} 143 | for idx in resorted_no_idxs 144 | ] 145 | 146 | for oc, tp in zip(current_online_content, current_template_params): 147 | title = oc["title"] 148 | prompt = f"""# Info 149 | ## OnlineJson 150 | ```{oc}``` 151 | ## TemplateParamsJson 152 | ```{tp}``` 153 | # Tasks 154 | 严格参照[Info.TemplateParamsJson],基于《{topic}》中的`{title}`标题的内容,对应填充[Info.OnlineJson],最后按照markdown的json格式输出。 155 | 注意:json的key值严格对应[Info.TemplateParamsJson],key对应的值不能存在列表或字典。 156 | ------ 157 | output:""" 158 | ctx = self._llm_generate_content_slide_in_single(prompt, 0.6, tp) 159 | if ctx: 160 | # 严格按照template参数匹配赋值 161 | for tk in tp.keys(): 162 | tp[tk] = ctx.get(tk, "") 163 | time.sleep(2) 164 | else: 165 | logger.exception(f"failed to generate content for title: {title}. Skip it!") 166 | 167 | data = { 168 | "titles_param": {f'title_{i + 1}': c["title"] for i, c in enumerate(current_online_content)}, 169 | "contents_param": current_template_params, 170 | "nos": current_template_resort_nos 171 | } 172 | return data 173 | 174 | def generate_ppt(self, meta_info: dict, generation_content: dict): 175 | """ 176 | generate ppt based on content which is generated by llm 177 | :param meta_info: 178 | :param generation_content: 179 | :return: 180 | """ 181 | # 1. 根据模板新开ppt 182 | logger.info(f"meta_info: {meta_info}") 183 | logger.info(f"generation_content: {generation_content}") 184 | 185 | titles_param = generation_content["titles_param"] 186 | contents_param = generation_content["contents_param"] 187 | nos = generation_content["nos"] 188 | 189 | all_params = contents_param 190 | # 插入首页内容和slide No 191 | all_params.insert(0, meta_info) 192 | nos.insert(0, self.template_params["first_slide"]["nos"][0]) 193 | # 插入目录页 194 | all_params.insert(1, titles_param) 195 | nos.insert(1, self.template_params["catalogue_slide"]["nos"][0]) 196 | # 插入结束页(在最后) 197 | all_params.append({}) 198 | nos.append(self.template_params["end_slide"]["nos"][0]) 199 | 200 | # 2. 根据重排的slide No重新生成ppt 201 | logger.info(f"nos: {nos}") 202 | recreate_slide_by_win32(self.template_path, self.save_path, indexs=nos) 203 | 204 | # 3. 在新ppt上,新增页并填充内容 205 | new_ppt = pptx.Presentation(self.save_path) 206 | for idx, p_dict in enumerate(all_params): 207 | for shape in new_ppt.slides[idx].shapes: 208 | if shape.has_text_frame: 209 | for paragraph in shape.text_frame.paragraphs: 210 | for run in paragraph.runs: 211 | for match in re.findall(pattern=self.PPT_PARAM_PATTERN, string=run.text): 212 | m_str = "{" + match + "}" 213 | m_key = match 214 | run.text = run.text.replace(m_str, str(p_dict.get(m_key, ''))) 215 | 216 | # 4. 保存PPT 217 | new_ppt.save(self.save_path) 218 | 219 | def generate(self, meta_info: dict): 220 | """根据模板生成PPT""" 221 | online_content = self.llm_generate_online_content(meta_info["topic"]) 222 | generation_content = self.llm_generate_content_slide_content(meta_info["topic"], online_content) 223 | self.generate_ppt(meta_info, generation_content) 224 | -------------------------------------------------------------------------------- /utils/prompter/__init__.py: -------------------------------------------------------------------------------- 1 | from .librarian import PromptLibrarian 2 | -------------------------------------------------------------------------------- /utils/prompter/librarian.py: -------------------------------------------------------------------------------- 1 | import os 2 | import glob 3 | import sys 4 | 5 | sys.path.append(os.getcwd()) 6 | 7 | 8 | class PromptLibrarian: 9 | def __init__(self, root_path, endswith='.pmt'): 10 | self.endswith = endswith 11 | self.root = self._create_node(root_path) 12 | 13 | def _create_node(self, path): 14 | entries = glob.glob(os.path.join(path, '*')) 15 | node = {} 16 | for entry in entries: 17 | if os.path.isdir(entry): 18 | node[os.path.basename(entry)] = self._create_node(entry) 19 | elif entry.endswith(self.endswith): 20 | with open(entry, 'r', encoding='utf-8') as f: 21 | node[os.path.basename(entry).rstrip(self.endswith)] = f.read() 22 | return node 23 | 24 | def print_tree(self, tree=None, parent_key='', level=0): 25 | """ 26 | 递归打印树结构 27 | :param tree: 28 | :param parent_key: 29 | :param level: 30 | :return: 31 | """ 32 | tree = self.root if tree is None else tree 33 | space = ' ' 34 | indent = space * level 35 | for key, value in tree.items(): 36 | if isinstance(value, dict): 37 | print(f"{indent}|--- {key}") 38 | self.print_tree(value, f"{parent_key}.{key}", level + 1) 39 | else: 40 | print(f"{indent}|--- {key}") 41 | 42 | def read(self, path: str) -> str: 43 | """ 44 | 根据路径获取prompt内容 45 | :param path: "formatted.in_json.v1" 46 | :return: 47 | """ 48 | # 通过路径获取节点内容 49 | path_list = path.split('.') 50 | node = self.root 51 | for p in path_list: 52 | node = node.get(p, None) 53 | if node is None: 54 | raise Exception(f"路径{path}中{p}不存在") 55 | return node 56 | 57 | def __str__(self): 58 | return str(self.root) 59 | 60 | 61 | PromptLibrarian = PromptLibrarian(os.path.join(os.getcwd(), r"./utils/prompter/library")) 62 | -------------------------------------------------------------------------------- /utils/prompter/library/formatted/in_json/v1.pmt: -------------------------------------------------------------------------------- 1 | 输出内容严格按照markdown的json返回: 2 | ```json 3 | {JSON_FORMAT} 4 | ``` -------------------------------------------------------------------------------- /utils/prompter/library/ppt/generate_by_template/v1.pmt: -------------------------------------------------------------------------------- 1 | # Requirement: 2 | 根据[Start.mina_content]的内容,以[PptTemplate]提供的模板,结合模板参数将PPT内容,最后按照[OutputFormat]格式md结构化输出 3 | 4 | # PptTemplate: 5 | ## 模板信息:```{template_params}``` 6 | ## 模板参数说明: 7 | - 格式中的`first_slide`、`catalogue_slide`、`end_slide`、`content_slide`都必须生成 8 | - `content_slide`选择匹配多个模板使用,保持多样性。 9 | - `params`中`title`是PPT页面的标题,`subtitle`是子标题,`desc`是标题的具体描述(20-40字),`content`是更细致的内容(40-60字),`n`是阿拉伯数字序号。 10 | - `params`中同一个子列表带序号的`title`、`subtitle`、`desc、`content`、`n`,在内容上需要一一对应。 11 | 12 | # Attention: 13 | - 严格按照模板格式输出,不要省略 14 | - 请展开你的想象,尽可能丰富`content`描述,不要过于简单,尽量详细描述 15 | - 请注意内容的格式,不要出现格式错误的情况 16 | 17 | # OutputFormat: 18 | ```json 19 | {output_format} 20 | ``` 21 | 22 | # Start: 23 | mina_content: ```{mina_content}``` 24 | author: {author} 25 | nowTime: {now_date} 26 | output: ```json -------------------------------------------------------------------------------- /utils/prompter/library/ppt/generate_content/v1.pmt: -------------------------------------------------------------------------------- 1 | ## Goals: 2 | 请根据《{topic}》主题,用{language}语言,列出紧扣主题的PPT大纲及内容,其中目录要有4个title、每个title中4个sub-title,,最后严格按照[Output Format]输出。 3 | ## Output Format: 4 | {output_format} -------------------------------------------------------------------------------- /utils/revealjs/__init__.py: -------------------------------------------------------------------------------- 1 | # __init__.py 2 | 3 | from .core import PyReveal 4 | from .background import ( 5 | BackgroundFactory, 6 | VideoBackground, 7 | ColorBackground, 8 | Background, 9 | ImageBackground, 10 | ) 11 | -------------------------------------------------------------------------------- /utils/revealjs/background.py: -------------------------------------------------------------------------------- 1 | class Background: 2 | def generate_html(self): 3 | raise NotImplementedError 4 | 5 | 6 | class ColorBackground(Background): 7 | def __init__(self, color): 8 | self.color = color 9 | 10 | def generate_html(self): 11 | return f' data-background-color="{self.color}"' 12 | 13 | 14 | class ImageBackground(Background): 15 | def __init__(self, image_url, size=None): 16 | self.image_url = image_url 17 | self.size = size 18 | 19 | def generate_html(self): 20 | size_str = f' data-background-size="{self.size}"' if self.size else "" 21 | return f' data-background="{self.image_url}"{size_str}' 22 | 23 | 24 | class VideoBackground(Background): 25 | def __init__(self, video_url): 26 | self.video_url = video_url 27 | 28 | def generate_html(self): 29 | return f' data-background-video="{self.video_url}"' 30 | 31 | 32 | class BackgroundFactory: 33 | @staticmethod 34 | def create_background(bg_type, value, **kwargs): 35 | if bg_type == "color": 36 | return ColorBackground(value) 37 | elif bg_type == "image": 38 | return ImageBackground(value, **kwargs) 39 | elif bg_type == "video": 40 | return VideoBackground(value) 41 | else: 42 | raise ValueError(f"Unsupported background type: {bg_type}") 43 | -------------------------------------------------------------------------------- /utils/revealjs/common.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import logging 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | def git_clone(repo_url, target_directory): 9 | cwd = os.getcwd() 10 | # 切换到目标目录 11 | os.chdir(target_directory) 12 | 13 | # 执行git clone命令 14 | try: 15 | subprocess.run(["git", "clone", repo_url], check=True) 16 | logger.error("Git clone completed successfully.") 17 | except subprocess.CalledProcessError as e: 18 | logger.error(f"Git clone failed with return code {e.returncode}: {e.output}") 19 | finally: 20 | os.chdir(cwd) 21 | 22 | 23 | def clone_revealjs(): 24 | # Git仓库的URL 25 | repo_url = "https://github.com/hakimel/reveal.js.git" 26 | # 目标目录路径 27 | target_directory = os.path.join(os.getcwd(), "./utils/revealjs/reveal_src") 28 | # 确保目标目录存在 29 | if not os.path.exists(target_directory): 30 | os.makedirs(target_directory) 31 | git_clone(repo_url, target_directory) 32 | -------------------------------------------------------------------------------- /utils/revealjs/core.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import pkg_resources 4 | 5 | from .exceptions import ( 6 | InvalidThemeError, 7 | InvalidTransitionError, 8 | EmptySlideContentError, 9 | DuplicateSlideTitleError, 10 | SlideGroupNotFoundError, 11 | ) 12 | from .utils import generate_slides_html, wrap_in_html_template 13 | from .background import ImageBackground, VideoBackground 14 | 15 | 16 | class PyReveal: 17 | VALID_THEMES = [ 18 | "black", 19 | "white", 20 | "league", 21 | "sky", 22 | "beige", 23 | "simple", 24 | "serif", 25 | "night", 26 | "moon", 27 | "solarized", 28 | ] 29 | VALID_TRANSITIONS = ["slide", "fade", "convex", "concave", "zoom"] 30 | 31 | def __init__( 32 | self, title="Untitled Presentation", theme="black", transition="slide" 33 | ): 34 | self.title = title 35 | self.slides = [] 36 | self.set_theme(theme) 37 | self.set_transition(transition) 38 | 39 | def add_slide(self, content, title=None, group=None, background=None): 40 | if not content.strip(): 41 | raise EmptySlideContentError("Slide content cannot be empty.") 42 | 43 | # Check for duplicate slide titles 44 | if title and any(slide["title"] == title for slide in self.slides): 45 | raise DuplicateSlideTitleError(title) 46 | 47 | # If the slide is part of a group, validate the group 48 | if group and not any(slide["title"] == group for slide in self.slides): 49 | raise SlideGroupNotFoundError(group) 50 | 51 | slide = { 52 | "title": title, 53 | "content": content, 54 | "group": group, 55 | "background": background, 56 | } 57 | self.slides.append(slide) 58 | 59 | def set_theme(self, theme): 60 | if theme not in self.VALID_THEMES: 61 | raise InvalidThemeError( 62 | f"'{theme}' is not a valid theme. Valid themes are: {', '.join(self.VALID_THEMES)}" 63 | ) 64 | self.theme = theme 65 | 66 | def set_transition(self, transition): 67 | if transition not in self.VALID_TRANSITIONS: 68 | raise InvalidTransitionError( 69 | f"'{transition}' is not a valid transition. Valid transitions are: {', '.join(self.VALID_TRANSITIONS)}" 70 | ) 71 | self.transition = transition 72 | 73 | def generate_html(self): 74 | slides_html = generate_slides_html(self.slides) 75 | return wrap_in_html_template( 76 | self.title, self.theme, self.transition, slides_html 77 | ) 78 | 79 | def save_to_file(self, filename="presentation.html"): 80 | # Ensure the presentations directory exists 81 | presentations_dir = "presentations" 82 | if not os.path.exists(presentations_dir): 83 | os.makedirs(presentations_dir) 84 | 85 | # Ensure the assets directory exists inside presentations 86 | assets_dir = os.path.join(presentations_dir, "assets") 87 | if not os.path.exists(assets_dir): 88 | os.makedirs(assets_dir) 89 | 90 | # Copy assets (background images, videos, etc.) to the assets directory and update slide references 91 | 92 | for slide in self.slides: 93 | background = slide.get("background") 94 | if background and isinstance(background, ImageBackground): 95 | # Copy the image and update the slide's image URL 96 | new_image_path = shutil.copy(background.image_url, assets_dir) 97 | slide["background"].image_url = os.path.relpath( 98 | new_image_path, presentations_dir 99 | ) 100 | elif background and isinstance(background, VideoBackground): 101 | # Copy the video and update the slide's video URL 102 | new_video_path = shutil.copy(background.video_url, assets_dir) 103 | slide["background"].video_url = os.path.relpath( 104 | new_video_path, presentations_dir 105 | ) 106 | 107 | # Locate the Reveal.js assets bundled with your package 108 | revealjs_source = pkg_resources.resource_filename("pyreveal", "revealjs") 109 | 110 | # Construct the full path to save the file 111 | full_path = os.path.join(presentations_dir, filename) 112 | 113 | # Copy the Reveal.js assets to the presentations directory 114 | revealjs_dest = os.path.join(presentations_dir, "revealjs") 115 | if not os.path.exists(revealjs_dest): 116 | shutil.copytree(revealjs_source, revealjs_dest) 117 | 118 | with open(full_path, "w") as f: 119 | f.write(self.generate_html()) 120 | 121 | print(f"Presentation saved to: {full_path}") 122 | -------------------------------------------------------------------------------- /utils/revealjs/exceptions.py: -------------------------------------------------------------------------------- 1 | class PyRevealError(Exception): 2 | """Base exception for PyReveal.""" 3 | 4 | pass 5 | 6 | 7 | class InvalidThemeError(PyRevealError): 8 | """Raised when an invalid theme is set.""" 9 | 10 | pass 11 | 12 | 13 | class InvalidTransitionError(PyRevealError): 14 | """Raised when an invalid transition is set.""" 15 | 16 | pass 17 | 18 | 19 | class EmptySlideContentError(PyRevealError): 20 | """Raised when trying to add a slide with empty content.""" 21 | 22 | pass 23 | 24 | 25 | class SlideGroupNotFoundError(PyRevealError): 26 | """Raised when a slide is added to a non-existent group.""" 27 | 28 | def __init__(self, group_name): 29 | super().__init__(f"Slide group '{group_name}' not found.") 30 | 31 | 32 | class DuplicateSlideTitleError(PyRevealError): 33 | """Raised when two slides have the same title.""" 34 | 35 | def __init__(self, title): 36 | super().__init__( 37 | f"Duplicate slide title '{title}' found. Slide titles must be unique." 38 | ) 39 | -------------------------------------------------------------------------------- /utils/revealjs/helpers.py: -------------------------------------------------------------------------------- 1 | from .exceptions import ( 2 | InvalidThemeError, 3 | InvalidTransitionError, 4 | EmptySlideContentError, 5 | SlideGroupNotFoundError, 6 | DuplicateSlideTitleError, 7 | ) 8 | 9 | 10 | def validate_theme(theme): 11 | valid_themes = [ 12 | "black", 13 | "white", 14 | "league", 15 | "sky", 16 | "beige", 17 | "simple", 18 | "serif", 19 | "night", 20 | "moon", 21 | "solarized", 22 | ] 23 | if theme not in valid_themes: 24 | raise InvalidThemeError( 25 | f"'{theme}' is not a valid theme. Valid themes are: {', '.join(valid_themes)}" 26 | ) 27 | 28 | 29 | def validate_transition(transition): 30 | valid_transitions = ["slide", "fade", "convex", "concave", "zoom"] 31 | if transition not in valid_transitions: 32 | raise InvalidTransitionError( 33 | f"'{transition}' is not a valid transition. Valid transitions are: {', '.join(valid_transitions)}" 34 | ) 35 | 36 | 37 | def validate_slide_content(content): 38 | if not content.strip(): 39 | raise EmptySlideContentError("Slide content cannot be empty.") 40 | -------------------------------------------------------------------------------- /utils/revealjs/utils.py: -------------------------------------------------------------------------------- 1 | # utils.py 2 | 3 | 4 | def generate_slides_html(slides): 5 | slides_html = [] 6 | processed_titles = set() 7 | 8 | for slide in slides: 9 | if slide["title"] in processed_titles: 10 | continue 11 | 12 | background_html = ( 13 | slide["background"].generate_html() if "background" in slide else "" 14 | ) 15 | 16 | if slide["group"]: 17 | # This slide is part of a group 18 | group_slides = [s for s in slides if s["group"] == slide["group"]] 19 | vertical_slides_html = "\n".join( 20 | [ 21 | f"
{sub_slide['content']}
" 22 | for sub_slide in group_slides 23 | ] 24 | ) 25 | slides_html.append( 26 | f"
\n{vertical_slides_html}\n
" 27 | ) 28 | processed_titles.add(slide["group"]) 29 | else: 30 | slides_html.append( 31 | f"
{slide['content']}
" 32 | ) 33 | 34 | return "\n".join(slides_html) 35 | 36 | 37 | def wrap_in_html_template(title, theme, transition, slides_html): 38 | """Wrap the slides HTML in the full Reveal.js template.""" 39 | return f""" 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 |
51 |
52 | {slides_html} 53 |
54 |
55 | 60 | 61 | 62 | """ 63 | --------------------------------------------------------------------------------