├── .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 | - 
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 | - 
39 |
40 | ### 
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 |
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""
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""
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 |
--------------------------------------------------------------------------------