diff --git a/README.en.md b/README.en.md index 8feadb8..98456e0 100644 --- a/README.en.md +++ b/README.en.md @@ -38,7 +38,7 @@ LearnDeck is designed for that moment when you already have knowledge, but need - **Clearer structure** with sections, key points, examples, and takeaways - **Polished pages** that feel ready for lessons, workshops, reports, and sharing - **Editable delivery** so you can keep refining the wording, flow, and style -- **Multiple visual styles** such as clean, academic, warm, bold, or dark +- **Custom visual direction** derived from your brief, audience, brand, or reference image - **Reusable knowledge assets** that can grow into courses, training decks, or study reviews ## Install @@ -57,8 +57,29 @@ To update an existing installation: npx --yes github:LearnAIHubC/LearnDeck --force ``` +## Direct HTML to PPTX + +LearnDeck's default creative workflow does not require an intermediate JSON plan or a fixed layout list: + +`your content + visual direction → model-designed HTML/CSS → browser geometry → editable PPTX` + +The model designs each slide around its message and keeps the deck coherent with shared colors, typography, spacing, and visual motifs. The existing JSON generator remains available only as a deterministic legacy option. + +To convert a model-authored HTML deck directly: + +```bash +node scripts/html_to_ppt.js \ + --input templates/learn-deck-polished-style.html \ + --out-dir output \ + --name learn-deck-polished-style +``` + +The bundled HTML is a seven-slide composition library, not a required sequence or content schema. Keep its visual language while selecting and reshaping only the pages that fit the user's material. + ## Showcase Templates +- [Open the seven-slide polished HTML style library](templates/learn-deck-polished-style.html) +- [Download the polished editable PPTX style template](templates/learn-deck-polished-style.pptx) - [Download the English editable PPTX template](templates/learn-deck-showcase-en.pptx) - [Download the Chinese editable PPTX template](templates/learn-deck-showcase-zh.pptx) diff --git a/README.md b/README.md index 334cf55..2286032 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,181 @@ -# LearnDeck +
-

- 简体中文 · English -

+

LearnDeck

-

- LearnDeck editable PPT showcase template +

Model-designed HTML → polished, editable PowerPoint

+ +

+ Turn a topic, outline, notes, or source material into a coherent presentation.
+ The model designs each slide in HTML/CSS, then LearnDeck rebuilds it as editable PPTX objects.

-

- Let AI create PPTs in one click.
- Turn knowledge into clear, polished, editable presentation decks. +

+ 简体中文 + · + HTML style library + · + Editable PPTX

---- +

+ Version 0.4.0 + Codex Skill + Editable HTML to PPTX + MIT License +

-## What Is LearnDeck? +LearnDeck turns source content into a polished editable presentation -LearnDeck is an AI presentation companion for learning content. +
-It helps you turn a topic, outline, notes, course material, article summary, or rough idea into a clear, beautiful, editable PPT. Instead of starting from a blank deck, you start with knowledge. LearnDeck helps shape that knowledge into a presentation people can follow. +## Why LearnDeck -The idea is simple: let AI create the first high-quality PPT for you, then keep the result easy to revise, personalize, and reuse. +Most presentation generators make you choose between a rigid schema and a flattened screenshot. LearnDeck takes a different route: -## Why It Exists +| Design follows the message | The result stays editable | Quality is visually verified | +| --- | --- | --- | +| The model authors the final HTML/CSS directly. No fixed layout enum is required. | Text, cards, shapes, gradients, and icons are rebuilt as separate PowerPoint objects. | Chromium audits the DOM before conversion; the final PPTX is rendered once for delivery QA. | -Learning content often has enough substance, but turning it into a deck takes time: +The default creative pipeline does **not** require an intermediate JSON slide plan: -- You need a clear order for the ideas -- You need pages that are easy to scan -- You need a polished look that feels presentation-ready -- You need the result to stay editable after generation -- You need something that works for teaching, training, reporting, or sharing +```text +content + visual direction + ↓ +model-designed HTML/CSS + ↓ +browser DOM audit + ↓ +browser-measured geometry + ↓ +editable PowerPoint objects + ↓ +contact-sheet + full-size visual QA +``` -LearnDeck is designed for that moment when you already have knowledge, but need a deck that makes it easier to teach and easier to understand. +## Latest seven-slide style system -## Who It Is For +

+ Seven-slide LearnDeck polished composition library +

-- Teachers, instructors, and workshop hosts -- Course creators and knowledge creators -- Teams preparing internal training or onboarding material -- Learners and researchers turning notes into reports -- Anyone who wants AI to turn scattered knowledge into a presentable PPT +This showcase includes a cover, statement page, relationship map, timeline, comparison, data story, and action close. It is a **composition library, not a mandatory sequence or fixed content template**. The user's content still determines the slide count, structure, visual emphasis, and layout of every page. -## What You Get +## Quick start -- **One-click PPT generation** from a topic, outline, notes, or learning material -- **Clearer structure** with sections, key points, examples, and takeaways -- **Polished pages** that feel ready for lessons, workshops, reports, and sharing -- **Editable delivery** so you can keep refining the wording, flow, and style -- **Multiple visual styles** such as clean, academic, warm, bold, or dark -- **Reusable knowledge assets** that can grow into courses, training decks, or study reviews +### 1. Install the Skill -## Install +```bash +npx --yes github:LearnAIHubC/LearnDeck +``` -Run: +Restart Codex, then invoke `$learn-deck`. To refresh an existing installation: ```bash -npx --yes github:LearnAIHubC/LearnDeck +npx --yes github:LearnAIHubC/LearnDeck --force ``` -Then restart Codex and use `$learn-deck`. +### 2. Describe the presentation you want -To update an existing installation: +```text +Use $learn-deck to turn my onboarding notes into a concise 10-slide deck. +Use a calm editorial style with generous whitespace and a clear data story. +Keep every text box, shape, gradient, and icon editable in PowerPoint. +``` + +You can provide a topic, outline, document, course material, study notes, brand direction, or reference image. LearnDeck derives the visual language from the request instead of forcing the content into a preset card count. + +### 3. Audit the DOM before conversion ```bash -npx --yes github:LearnAIHubC/LearnDeck --force +node scripts/html_to_ppt.js \ + --input path/to/deck.html \ + --dom-audit-only ``` -## Showcase Templates +This Chromium preflight checks slide and text overflow, declared overlap/containment rules, unexpected single-line wrapping, centered text, centered SVG icons, chart labels, and text safety inside circles and compact cards. It produces structured errors without generating screenshots. -- [Download the English editable PPTX template](templates/learn-deck-showcase-en.pptx) -- [Download the Chinese editable PPTX template](templates/learn-deck-showcase-zh.pptx) +### 4. Keep revising the same HTML -The cover and workflow images in this README are rendered from these actual editable decks. +The generated HTML remains the source of truth. When the content or style changes, LearnDeck edits that HTML, reconverts the PPTX, rerenders the previews, and checks the result again. -## What Can You Create? +## What stays editable -LearnDeck is especially useful for: +| HTML/CSS source | PowerPoint output | +| --- | --- | +| Headings, paragraphs, labels, and bullets | Separate editable text boxes | +| Cards, panels, pills, borders, and circles | Native PowerPoint shapes | +| Linear gradients and quiet decorative accents | Editable gradient and shape objects | +| Inline SVG icons | Separate icon objects, with SVG sources preserved | +| Browser positions and dimensions | Rebuilt on a 16:9 PowerPoint canvas | -- Course introductions and lesson outlines -- Classroom slides and learning cards -- Internal training decks -- Learning progress reports -- Study group and book club presentations -- Methodology explainers -- Product or business knowledge training -- Research summaries and knowledge reviews +The converter favors **editable, separated elements** over a single full-slide screenshot. -If your goal is to help people understand something more clearly, LearnDeck gives you a strong first deck. +## DOM first, final render once -## The Experience +LearnDeck separates fast structural validation from final presentation validation: -1. **Bring content**: start with a topic, outline, notes, article summary, or course material -2. **Let AI shape it**: organize the message into titles, key points, examples, and takeaways -3. **Create PPT**: get a coherent deck with a clean visual style and a clear learning rhythm -4. **Edit freely**: adjust wording, tone, brand, sequence, and classroom style +1. **Browser DOM preflight, no screenshots** — catches overflow, overlap, wrapping, centering, icon, chart-label, circle, pill, and card failures before a PPTX is written. +2. **Final contact-sheet review** — checks narrative flow, composition variety, density, whitespace, hierarchy, color, and page-to-page consistency. +3. **Final full-size slide review** — checks PowerPoint-specific clipping, wrapping, reconstruction, fine alignment, contrast, and chart labels. -

- LearnDeck editable HTML-to-PPTX workflow slide -

+The DOM audit replaces repeated draft screenshots, but not the final render: PowerPoint and LibreOffice can use different font metrics and object geometry from Chromium. If a problem appears, the original HTML is edited and the relevant audit/conversion loop runs again. + +Generate the same QA assets manually with: + +```bash +node scripts/render_pptx_preview.js \ + --input output/deck-name-editable.pptx \ + --out-dir output/deck-name-qa +``` + +## Convert HTML directly + +Use any converter-compatible local HTML deck or URL: + +```bash +node scripts/html_to_ppt.js \ + --input templates/learn-deck-polished-style.html \ + --out-dir output \ + --name learn-deck-polished-style +``` + +The output is `output/learn-deck-polished-style-editable.pptx`. The legacy JSON generator remains available for deterministic compatibility, but it is not the default creative path. + +## Included templates + +- [Seven-slide polished HTML style library](templates/learn-deck-polished-style.html) +- [Seven-slide polished editable PPTX](templates/learn-deck-polished-style.pptx) +- [English editable showcase PPTX](templates/learn-deck-showcase-en.pptx) +- [Chinese editable showcase PPTX](templates/learn-deck-showcase-zh.pptx) -## Why The Name LearnDeck? +Use the templates to study converter-compatible visual grammar: palette relationships, typography, spacing rhythm, corner radii, borders, shadows, and decorative motifs. Do not copy their sample content or treat their boxes as a schema. -“Learn” stands for learning content, knowledge sharing, and teaching moments. +## Good fits -“Deck” stands for a complete presentation that can be taught, shared, edited, and reused. +- Courses, lessons, workshops, and classroom slides +- Internal training, onboarding, and enablement decks +- Research summaries, reports, and knowledge reviews +- Product explainers and methodology presentations +- Reference-image-driven presentations that still need editable objects -LearnDeck is not just a slide maker. It is a learning-deck partner that helps knowledge become a complete presentation with flow, clarity, and visual polish. +## Development + +Requirements: Node.js 18 or newer. Final visual preview generation also uses LibreOffice, Poppler (`pdftoppm`), and `sharp`. + +```bash +npm test +npm pack --dry-run +``` -## Vision +The smoke test covers direct HTML conversion, DOM-audit failures, layout guards, multiline text handling, and legacy JSON compatibility. -LearnDeck aims to make high-quality learning decks easier to create. +## Project links -It is for people who have ideas, notes, expertise, or teaching material, but do not want to spend most of their time shaping structure and polishing pages. A good PPT should help the presenter speak clearly and help the audience understand easily. +- [Report an issue](https://github.com/LearnAIHubC/LearnDeck/issues) +- [LINUX DO](https://linux.do/) — a community for technology enthusiasts -LearnDeck exists to make that bridge lighter, faster, and more beautiful. +
-## 友情链接 +If LearnDeck helps you turn knowledge into clearer presentations, consider starring the repository. -- [LINUX DO](https://linux.do/) —— 新的理想型社区,技术爱好者的聚集地。 +
diff --git a/README.zh-CN.md b/README.zh-CN.md index 8a9dbf1..a872d62 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -44,7 +44,7 @@ LearnDeck 的交付结果更像是一套“学习型演示稿”,而不是普 - **更清楚的内容结构**:主题、章节、重点、案例和结论被整理得更有层次 - **更适合讲解的节奏**:页面顺序围绕学习者的理解路径展开 - **更有质感的视觉表达**:版式干净,重点突出,适合课堂、培训和汇报 -- **更多风格选择**:可选择清爽、学术、暖色、大胆、深色等方向 +- **按需求定制视觉**:根据受众、品牌、内容和参考图动态设计风格 - **更方便修改的结果**:后续可以继续调整文字、页面顺序和表达方式 - **更容易复用的材料**:同一套内容可以延展成课程、培训、分享或复盘 @@ -64,8 +64,29 @@ npx --yes github:LearnAIHubC/LearnDeck npx --yes github:LearnAIHubC/LearnDeck --force ``` +## HTML 直接生成 PPTX + +LearnDeck 默认的创作链路不需要中间 JSON,也不限制在固定版式列表里: + +`你的内容 + 视觉要求 → 模型逐页设计 HTML/CSS → 浏览器真实坐标 → 可编辑 PPTX` + +模型会根据每页要表达的信息设计构图,再用共享的配色、字体、间距和视觉元素保持整套演示稿一致。现有 JSON 生成器仅作为需要确定性批量输出时的兼容选项保留。 + +模型生成 HTML 后,可直接转换: + +```bash +node scripts/html_to_ppt.js \ + --input templates/learn-deck-polished-style.html \ + --out-dir output \ + --name learn-deck-polished-style +``` + +内置 HTML 是一套七页构图样本库,不是必须照搬的顺序、内容模板或固定版式。生成时保留视觉语言,只选择并重构适合用户内容的页面。 + ## 展示模板 +- [查看七页精致浅色 HTML 构图样本库](templates/learn-deck-polished-style.html) +- [下载精致浅色可编辑 PPTX 风格模板](templates/learn-deck-polished-style.pptx) - [下载中文可编辑 PPTX 模板](templates/learn-deck-showcase-zh.pptx) - [下载英文可编辑 PPTX 模板](templates/learn-deck-showcase-en.pptx) diff --git a/SKILL.md b/SKILL.md index d5101b4..a9f061b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,56 +1,58 @@ --- name: learn-deck -description: Generate editable learning PPTX decks by first creating HTML slides, or convert local HTML slide decks and single-page HTML presentations into editable PPTX files by rendering in a browser, reading DOM element positions, extracting inline SVG assets, and rebuilding text, shapes, gradients, bullets, and icons as separated PowerPoint objects. Use when a user asks to generate PPT/PPTX from a topic, outline, course material, study notes, or structured content, or asks for pixel-level/browser-positioned HTML-to-PPT/PPTX conversion with editable/separate elements instead of one full-slide screenshot. +description: Design custom HTML slide decks from a user's content and visual direction, then convert the browser-rendered slides into editable PPTX objects. Use for new PPT/PPTX generation, reference-image-driven presentation styling, or converting local HTML/URLs to editable PowerPoint while preserving separate text, shapes, gradients, and icons. --- # LearnDeck -## Quick Start +## Core Rule -This skill has two main commands. +For a new presentation, author the final slide HTML directly. Do not create a slide-plan JSON and do not choose from a fixed layout enum unless the user explicitly requests the legacy deterministic generator. -### 1. Generate PPT +The default pipeline is: -Use this when the user asks to create a new PPT/PPTX from a topic, outline, notes, or source material. +`user content + visual direction -> model-designed HTML/CSS -> browser DOM audit -> editable PPTX -> final render QA` -1. Create a slide plan JSON using the shape in `examples/slides.example.json`. -2. Run: +Shared design tokens should make the deck coherent, but every slide's composition should follow its own message. Do not force all slides into repeated cover/content/steps/compare/summary templates. -```bash -node /path/to/learn-deck/scripts/generate_ppt.js \ - --slides /path/to/slides.json \ - --out-dir /path/to/output \ - --name deck-name \ - --style clean \ - --no-preview -``` +## Quick Start -This command first writes `/.html`, then calls the HTML-to-PPT converter. +### Create a New PPT -### 2. HTML to PPT +1. Understand the audience, purpose, content hierarchy, and requested visual direction. If the user provides a reference image, inspect it and reuse its visual language without copying its composition blindly. +2. Write one self-contained HTML file. Use `templates/learn-deck-polished-style.html` as a converter-compatible style template when that visual direction fits the request. +3. Give every slide a `.slide` root and design its DOM/CSS directly around that slide's content. +4. Run the browser DOM preflight and fix every reported issue: -Use this when the user already has a local HTML file or URL: +```bash +node /path/to/learn-deck/scripts/html_to_ppt.js \ + --input /path/to/deck.html \ + --dom-audit-only +``` + +5. Convert only after the DOM audit reports zero issues: ```bash node /path/to/learn-deck/scripts/html_to_ppt.js \ - --input /path/to/source.html \ + --input /path/to/deck.html \ --out-dir /path/to/output \ --name deck-name ``` -If Codex workspace dependencies are available, prefer the bundled runtime: +This produces `/-editable.pptx` and preserves the source HTML for revisions. + +### Convert Existing HTML + +Use the same command for a local HTML file or URL: ```bash -NODE_PATH="$NODE_MODULES:/Users/$USER/node_modules" "$NODE_BIN" \ - /path/to/learn-deck/scripts/generate_ppt.js \ - --slides /path/to/slides.json \ +node /path/to/learn-deck/scripts/html_to_ppt.js \ + --input /path/to/source.html \ --out-dir /path/to/output \ - --name deck-name \ - --style clean \ - --no-preview + --name deck-name ``` -or: +If Codex workspace dependencies are available, call `load_workspace_dependencies` first and run with the returned Node.js paths: ```bash NODE_PATH="$NODE_MODULES:/Users/$USER/node_modules" "$NODE_BIN" \ @@ -60,77 +62,135 @@ NODE_PATH="$NODE_MODULES:/Users/$USER/node_modules" "$NODE_BIN" \ --name deck-name ``` -When running in Codex Desktop, call `load_workspace_dependencies` first and set: +## Direct HTML Workflow + +### 1. Establish the Art Direction + +- Derive the style from the user's words, audience, brand, and references. Do not reduce the choice to a fixed list of named themes. +- Define a small set of CSS variables for the palette, type scale, spacing, borders, radii, and shadows. +- Match reference images by visual grammar: density, whitespace, hierarchy, color behavior, geometry, and motif. Adapt the layout to the new content. +- If style is unspecified, infer a suitable direction. Ask one short question only when the missing choice would materially change the result. + +### 2. Design the Narrative and Each Slide + +- Give each slide one clear communication goal. +- Split content instead of shrinking text or overcrowding a page. +- Choose each composition from the content itself: a strong statement, diagram, comparison, timeline, data story, annotated visual, card system, or another bespoke arrangement. +- Vary composition and scale across the deck while keeping shared tokens consistent. +- Avoid repeating the same grid on consecutive slides unless repetition is meaningful. + +### Style Template Contract + +- A style template defines visual grammar only: palette relationships, typography, spacing rhythm, corner radii, border treatment, shadows, and decorative motifs. +- The user's content determines the narrative, slide count, DOM structure, composition, number of cards, charts, diagrams, and visual emphasis. +- Never copy the template's sample text into a customer deck. +- Never force new content into the template's existing boxes or preserve its card count when the content calls for another composition. +- It is acceptable to resize, remove, add, or rearrange components while retaining the same visual family. +- Use `templates/learn-deck-polished-style.html` for the polished light aesthetic shown in the bundled showcase. Treat its seven slides as a composition library, not a required sequence or fixed page set. Select and reshape only the specimens that fit the user's content. + +### 3. Author Converter-Compatible HTML + +- Use a fixed `1280px × 720px` canvas for every `.slide` by default. +- Make the HTML self-contained. Prefer local/system fonts, CSS shapes, and inline SVG. +- Use normal DOM elements for every object that should remain independently editable. +- Keep intended text boxes as semantic text elements such as `h1`, `h2`, `p`, `li`, or `span`. +- Flexbox, grid, and absolute positioning are all acceptable; browser coordinates are authoritative. +- Use simple backgrounds, borders, rounded rectangles, ellipses, linear gradients, restrained shadows, and inline SVG icons. +- Do not use canvas, video, CSS filters, masks, blend modes, or transform-based rotation for content that must survive as editable PPT objects. +- The current converter does not rebuild `` elements or CSS `url(...)` backgrounds. Use inline SVG/CSS visuals, or extend the converter before relying on raster images. +- Do not use a large radial gradient as the primary slide background. The converter approximates it as a literal editable ellipse, which creates an awkward object boundary in PowerPoint. Prefer a full-canvas two-color linear-gradient rectangle for a soft atmospheric background. +- Treat PowerPoint selection ergonomics as part of the design. Large background objects should either match the full slide bounds or stay as small corner accents; do not place a giant selectable decorative shape behind the title or body copy. +- Keep optional decorative shapes visually quiet and below roughly one quarter of the slide area. They must not compete with the main message or create misleading selection boxes during editing. + +### 4. Mandatory Layout Safety Checks + +- Set a content budget before fixing a card or panel height. Test the longest real title and body copy, including CJK text where relevant. +- No text may cross a card, panel, footer, or slide boundary. Preserve at least `10px` of visible bottom padding inside compact cards. +- If content does not fit, shorten the copy, enlarge the container, or redesign the composition. Do not hide overflow and do not solve it by making important text illegibly small. +- Use inline SVG with an explicit `viewBox` for small icons. Do not use Unicode symbols, emoji, or font glyphs such as `□` or `◇` inside icon containers; their metrics shift across Chrome, PowerPoint, and LibreOffice. Plain arrows are acceptable only as inline text separators when exact icon centering is not required. +- Center icon artwork with explicit dimensions and `display: flex; align-items: center; justify-content: center`. Keep the SVG smaller than its colored container so it has even internal padding. +- Reserve separate layout slots for controls, logos, labels, titles, and page markers. Use grid or flex columns with an explicit gap of at least `8px`; never separate independent objects with repeated spaces, ` `, transparent text, or negative margins. +- For window-like chrome, keep the control-dot cluster and the window title in separate sibling elements. The title must start after the full control cluster plus the safety gap. +- Add `data-layout-guard="no-overlap"` to critical rows whose direct children must never intersect. Add `data-layout-guard="contain"` to cards and panels whose direct children must remain inside their bounds. Combine them as `data-layout-guard="contain no-overlap"` when both rules apply. +- Add `text-contain` to circles, pills, compact cards, and other text-bearing shapes whose text must remain inside the padded content area. Ellipse-like shapes receive an additional shape-aware text check. +- Add `single-line` to titles, labels, chips, and chart annotations that must never wrap. Do not add it to intentionally multiline copy. +- Add `text-center` to short labels that must be geometrically centered inside a circle, pill, or compact card. +- Add `icon-center` to an icon container that must contain exactly one centered inline SVG. +- Add `chart-labels` to a chart-series container. Mark each direct series child with `data-chart-series` and its visible label with `data-chart-label`. +- Use `data-layout-allow-overflow` only for an intentional decorative element that extends past the slide edge. Never use it for text, charts, cards, or content. Do not add `data-layout-ignore` merely to silence an audit failure. +- The converter treats layout-guard failures as blocking errors. Fix the HTML structure or spacing; do not remove the guard merely to make conversion pass. +- Run `--dom-audit-only` after every meaningful HTML revision. It checks slide overflow and text overflow automatically, then enforces the declared overlap, containment, wrapping, centering, icon, and chart-label contracts in Chromium without generating screenshots. +- Browser success is not enough: PowerPoint font metrics can change wrapping and baseline positions. Render the converted PPTX and inspect every slide at full size. +- The final QA pass must have zero unintended text overflow, clipping, overlap, icon drift, unexpected wrapping, or objects crossing neighboring cards. Fix the HTML and reconvert until clean. + +### 5. Mandatory Browser DOM Preflight + +1. Run `scripts/html_to_ppt.js --input --dom-audit-only` before creating the PPTX. +2. Read every reported slide number, issue type, element label, edge, box, wrap count, center drift, or missing chart label. +3. Fix the original HTML and rerun the preflight until `domAudit.issues` is `0`. +4. Do not convert a deck that fails preflight, and do not remove guards or add overflow exceptions simply to make the command pass. + +The DOM preflight replaces repeated draft screenshots. It does not replace the final PPTX render because PowerPoint and LibreOffice can use different font metrics and object reconstruction from Chromium. + +### 6. Mandatory Final Visual QA and Revision Loop + +1. Run `scripts/html_to_ppt.js` on the authored HTML. +2. Render the resulting PPTX into full-size slide images and a contact sheet. Do not judge only from the source HTML or assume a successful conversion is visually correct: -- `NODE_BIN` to the returned Node.js executable. -- `NODE_MODULES` to the returned Node.js packages directory. - -## Workflow +```bash +node /path/to/learn-deck/scripts/render_pptx_preview.js \ + --input /path/to/output/deck-name-editable.pptx \ + --out-dir /path/to/output/deck-name-qa +``` -### Generate PPT +3. **Pass 1 — inspect the contact sheet first.** Open `contact-sheet.png` with an image-viewing tool. Review the deck as one sequence for narrative flow, pacing, composition variety, repeated silhouettes, density balance, consistent margins/title hierarchy/color/footer/page markers, and unexpectedly empty or overloaded slides. +4. **Pass 2 — inspect every slide at full size.** Open every PNG under `slides/`; the contact sheet does not replace this pass. Check title wrapping, text clipping or overflow, unintended overlap, icon centering, fine alignment, contrast, chart labels and data, and decorative objects that are too large or misleading when selected in PowerPoint. Pay special attention to the first and last characters of centered multiline labels inside circles, pills, and compact cards; line-break conversion can reveal clipping that is invisible in the HTML. +5. If either pass finds a problem, edit the original HTML, not an intermediate JSON or the rendered preview. Reconvert the PPTX, rerender all previews, and repeat both passes. Do not patch only the affected PNG, and do not reuse a stale contact sheet. +6. Deliver only after the latest contact sheet and every latest full-size slide have been inspected and there is zero unintended overflow, clipping, overlap, wrapping, icon drift, visual imbalance, or accidental layout repetition. If an intentional exception remains, disclose it explicitly. -1. Ask one short style question when the user has not specified a style and the request is not urgent. Offer choices such as `clean`, `academic`, `warm`, `bold`, or `dark`; if the user says they do not care, choose based on audience and topic. -2. Turn the user's topic/content into a concise `slides.json` plan. Include `"style": ""` in the plan or pass `--style`. -3. Keep each slide focused; split long content into more slides rather than crowding one slide. -4. Use `layout` values `cover`, `content`, `steps`, `compare`, or `summary` when useful. -5. Run `scripts/generate_ppt.js`; it writes HTML and then runs the converter. Use `--no-preview` by default for direct delivery. -6. Do not run extra validation by default. Directly report the generated `.html` and `.pptx` paths to the user/customer. +This final QA loop is mandatory for a final deck, even when the browser DOM audit passes. The DOM audit catches HTML geometry and semantic guard failures; the final render catches PowerPoint font-metric changes, reconstruction differences, weak hierarchy, poor rhythm, and composition problems. -### Revision Loop +## What the Converter Preserves -When the user/customer asks for adjustments after delivery: +The converter: -1. Edit the original generated HTML file rather than starting over. -2. Run `scripts/html_to_ppt.js` on that same HTML file with the same `--name`/output directory. -3. Return the regenerated PPTX path. Only validate or preview when the user explicitly asks or a conversion error occurs. +1. Loads the HTML in Chromium/Chrome through Playwright and waits for fonts. +2. Detects `.slide` roots, or another selector passed with `--slide-selector`. +3. Automatically audits slide/text overflow and enforces explicit containment, overlap, wrapping, centering, icon, and chart-label guards before reading visible DOM bounding boxes, computed styles, text runs, pseudo bullets, and inline SVG children. +4. Rebuilds editable text boxes and native rectangle, rounded-rectangle, ellipse, border, shadow, and gradient objects. +5. Saves inline SVG sources and PNG compatibility assets beside the PPTX. -### HTML to PPT +The result favors editable, separated elements over screenshot-perfect reproduction. -1. Load the HTML in Chromium/Chrome through Playwright. -2. Wait for network idle and `document.fonts.ready`. -3. Detect slides with `.slide` by default; pass `--slide-selector` for another selector. -4. Read every visible DOM element's bounding box, computed styles, text runs, pseudo bullets, and SVG children relative to its slide. -5. Save each inline SVG to `/-assets/svg/` and a PNG preview to `/-assets/png/`. -6. Create a PPTX with: - - Editable text boxes for headings, paragraphs, spans, list items, and labels. - - Editable rectangle/rounded-rectangle/ellipse shapes for backgrounds, pills, cards, borders, and bullets. - - Separate image objects for extracted SVG icons, using PNG in the PPTX for compatibility while preserving source SVGs on disk. - - Native OOXML linear gradients where possible. -7. Deliver the generated PPTX path directly. Do not run extra validation by default; validate only when explicitly requested or when troubleshooting a failed conversion. +## Legacy JSON Generator -## Script Options +`scripts/generate_ppt.js` remains available for backwards compatibility and deterministic batch output. It accepts `examples/slides.example.json` and renders a small fixed layout set. Do not use it for normal creative generation, reference-driven styling, or requests for custom composition. ```bash node scripts/generate_ppt.js --help node scripts/html_to_ppt.js --help-converter ``` -Important HTML-to-PPT options: +## Important Options -- `--input `: Required. Local HTML file or HTTP(S) URL. -- `--out-dir `: Required. Output directory. -- `--name `: Output basename. Defaults to input filename. -- `--style `: For `generate_ppt.js`, choose `clean`, `academic`, `warm`, `bold`, or `dark`. +- `--input `: Required local HTML file or HTTP(S) URL. +- `--out-dir `: Required output directory. +- `--name `: Output basename. Defaults to the input filename. - `--slide-selector `: Defaults to `.slide`. - `--chrome `: Use a specific Chrome/Edge/Chromium executable. -- `--ppt-width ` and `--ppt-height `: Defaults to 13.333 x 7.5. -- `--keep-raw`: Keep the intermediate unpatched PPTX. -- `--no-preview`: Skip LibreOffice PDF/PNG preview generation. +- `--ppt-width ` and `--ppt-height `: Defaults to 13.333 × 7.5. +- `--font-face `: Font used for editable PPT text. Use `PingFang SC` for Chinese or another installed font requested by the user. +- `--dom-audit-only`: Run the Chromium DOM audit and exit without requiring `--out-dir` or writing a PPTX. +- `--keep-raw`: Keep the intermediate PPTX before gradient patches. +- `--no-preview`: Skip the LibreOffice preview attempt. Do not use this for a final deck unless an equivalent full-slide render-and-inspect step is performed separately. -## Bundled Showcase Templates +`scripts/render_pptx_preview.js` requires LibreOffice, Poppler (`pdftoppm`), and `sharp`. It produces a PDF, full-size slide PNGs, and `contact-sheet.png` for the mandatory two-pass QA loop. -LearnDeck includes two editable four-slide showcase templates: +## Bundled Style Template +- `templates/learn-deck-polished-style.html`: seven-slide converter-compatible composition library for the polished light LearnDeck aesthetic. +- `templates/learn-deck-polished-style.pptx`: editable rendered reference for checking the expected result. - `templates/learn-deck-showcase-zh.pptx` - `templates/learn-deck-showcase-en.pptx` -Use them as visual references when the user asks for the LearnDeck showcase style, or clone and revise them when a four-slide knowledge-to-PPT overview matches the request. - -## Notes - -- `generate_ppt.js` expects an agent-authored slide plan JSON; it is intentionally deterministic and does not call an LLM by itself. -- This skill prioritizes editable separated elements over screenshot-perfect fidelity. -- Browser coordinates are authoritative. Avoid hand-calculating flex/grid positions when the script can read them. -- PowerPoint and LibreOffice may lay out CJK fonts slightly differently from Chrome. Use installed system fonts such as `PingFang SC` or pass `--font-face`. -- Complex CSS effects are approximated: radial backgrounds become translucent ellipses, box shadows become approximate PowerPoint shadows, and CSS filters are ignored. -- If SVG support in PowerPoint is important, keep the saved SVG assets and replace the generated PNG icon objects manually or adapt the script. PptxGenJS needs PNG previews for SVG in Node, so PNG is the compatibility default. +Use these to study visual quality and converter-compatible construction. They provide style, not user content or fixed layouts. diff --git a/agents/openai.yaml b/agents/openai.yaml index c305c39..da2da81 100644 --- a/agents/openai.yaml +++ b/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "LearnDeck" short_description: "Generate learning decks from HTML into editable PPTX" - default_prompt: "Use $learn-deck to generate a PPT from my topic by creating HTML first, then converting it into an editable PPTX." + default_prompt: "Use $learn-deck to design a custom HTML presentation from my content and visual direction, convert it to editable PPTX, and verify every slide has no overflow or overlap." diff --git a/assets/learn-deck-cover-en.png b/assets/learn-deck-cover-en.png index e7d43a0..bd08883 100644 Binary files a/assets/learn-deck-cover-en.png and b/assets/learn-deck-cover-en.png differ diff --git a/assets/learn-deck-polished-overview.png b/assets/learn-deck-polished-overview.png new file mode 100644 index 0000000..6058a09 Binary files /dev/null and b/assets/learn-deck-polished-overview.png differ diff --git a/bin/install.mjs b/bin/install.mjs index 3149ff9..d1e94be 100755 --- a/bin/install.mjs +++ b/bin/install.mjs @@ -94,9 +94,6 @@ function install(opts) { fs.mkdirSync(dest, { recursive: true }); for (const name of [ "SKILL.md", - "README.md", - "README.en.md", - "README.zh-CN.md", "LICENSE.txt", "agents", "assets", diff --git a/package.json b/package.json index 819549f..7bbb457 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,13 @@ { "name": "@learnaihubc/learn-deck", - "version": "0.1.1", + "version": "0.4.0", "description": "Let AI create PPTs in one click and turn knowledge into polished, editable learning decks.", "bin": { "learn-deck": "bin/install.mjs" }, + "scripts": { + "test": "node scripts/smoke_test.js" + }, "files": [ "SKILL.md", "README.md", diff --git a/scripts/generate_ppt.js b/scripts/generate_ppt.js index e6552c3..9c82e33 100755 --- a/scripts/generate_ppt.js +++ b/scripts/generate_ppt.js @@ -198,7 +198,7 @@ function renderSlide(slide, index, total) { if (layout === "cover") { return `
-
+
${escapeHtml(kicker)}

${escapeHtml(title)}

${escapeHtml(subtitle)}

@@ -216,7 +216,7 @@ function renderSlide(slide, index, total) { if (layout === "steps") { return `
-
+
${escapeHtml(kicker)}

${escapeHtml(title)}

${escapeHtml(subtitle)}

@@ -227,7 +227,7 @@ function renderSlide(slide, index, total) { if (layout === "compare") { return `
-
+
${escapeHtml(kicker)}

${escapeHtml(title)}

${escapeHtml(subtitle)}

@@ -238,7 +238,7 @@ function renderSlide(slide, index, total) { if (layout === "summary") { return `
-
+
${escapeHtml(kicker)}

${escapeHtml(title)}

${escapeHtml(subtitle)}

@@ -249,7 +249,7 @@ function renderSlide(slide, index, total) { } return `
-
+
${escapeHtml(kicker)}

${escapeHtml(title)}

${escapeHtml(subtitle)}

diff --git a/scripts/html_to_editable_pptx.js b/scripts/html_to_editable_pptx.js index 66333bc..3943ed2 100755 --- a/scripts/html_to_editable_pptx.js +++ b/scripts/html_to_editable_pptx.js @@ -35,7 +35,7 @@ const { module: JSZip } = requireFromCandidates("jszip", [path.dirname(pptxResol function usage(exitCode = 0) { console.log(`Usage: - html_to_editable_pptx.js --input --out-dir [options] + html_to_editable_pptx.js --input [--out-dir ] [options] Options: --input Required local HTML file or http(s) URL @@ -48,6 +48,7 @@ Options: --font-face PPT font face, defaults to PingFang SC --viewport-width Browser viewport width, defaults to first slide width or 1280 --viewport-height Browser viewport height, defaults to first slide height or 720 + --dom-audit-only Validate the rendered DOM and exit without creating a PPTX --keep-raw Keep intermediate raw PPTX before gradient patches --no-preview Skip LibreOffice/Quick Look preview attempt --help Show this help @@ -81,12 +82,13 @@ function parseArgs(argv) { else if (a === "--font-face") out.fontFace = next(); else if (a === "--viewport-width") out.viewportWidth = Number(next()); else if (a === "--viewport-height") out.viewportHeight = Number(next()); + else if (a === "--dom-audit-only") out.domAuditOnly = true; else if (a === "--keep-raw") out.keepRaw = true; else if (a === "--no-preview") out.preview = false; else throw new Error(`Unknown argument: ${a}`); } if (!out.input) throw new Error("--input is required"); - if (!out.outDir) throw new Error("--out-dir is required"); + if (!out.outDir && !out.domAuditOnly) throw new Error("--out-dir is required unless --dom-audit-only is used"); if (!Number.isFinite(out.pptWidth) || !Number.isFinite(out.pptHeight)) throw new Error("--ppt-width/--ppt-height must be numbers"); return out; } @@ -270,6 +272,8 @@ async function collectDom(page, opts) { boxShadow: s.boxShadow, letterSpacing: s.letterSpacing, textAlign: s.textAlign, + justifyContent: s.justifyContent, + alignItems: s.alignItems, opacity: s.opacity, paddingLeft: s.paddingLeft, paddingRight: s.paddingRight, @@ -363,13 +367,265 @@ async function collectDom(page, opts) { return clone.outerHTML; } + function elementLabel(el) { + if (el.id) return `#${el.id}`; + const cls = typeof el.className === "string" ? el.className.trim().split(/\s+/).filter(Boolean).join(".") : ""; + return `${el.tagName.toLowerCase()}${cls ? `.${cls}` : ""}`; + } + + function ignored(el) { + return Boolean(el.closest("[data-layout-ignore]")); + } + + function plainRect(rect) { + return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }; + } + + function relativeRect(rect, rootRect) { + return { x: rect.left - rootRect.left, y: rect.top - rootRect.top, w: rect.width, h: rect.height }; + } + + function innerRect(el) { + const rect = el.getBoundingClientRect(); + const s = getComputedStyle(el); + const left = rect.left + (parseFloat(s.borderLeftWidth) || 0) + (parseFloat(s.paddingLeft) || 0); + const right = rect.right - (parseFloat(s.borderRightWidth) || 0) - (parseFloat(s.paddingRight) || 0); + const top = rect.top + (parseFloat(s.borderTopWidth) || 0) + (parseFloat(s.paddingTop) || 0); + const bottom = rect.bottom - (parseFloat(s.borderBottomWidth) || 0) - (parseFloat(s.paddingBottom) || 0); + return { left, right, top, bottom, width: Math.max(0, right - left), height: Math.max(0, bottom - top) }; + } + + function outsideEdges(rect, parent, tolerance) { + const edges = []; + if (rect.left < parent.left - tolerance) edges.push("left"); + if (rect.top < parent.top - tolerance) edges.push("top"); + if (rect.right > parent.right + tolerance) edges.push("right"); + if (rect.bottom > parent.bottom + tolerance) edges.push("bottom"); + return edges; + } + + function textOutsideEdges(rect, parent) { + const horizontalTolerance = 1.5; + const verticalTolerance = Math.max(2, Math.min(14, rect.height * 0.16)); + const edges = []; + if (rect.left < parent.left - horizontalTolerance) edges.push("left"); + if (rect.right > parent.right + horizontalTolerance) edges.push("right"); + if (rect.top < parent.top - verticalTolerance) edges.push("top"); + if (rect.bottom > parent.bottom + verticalTolerance) edges.push("bottom"); + return edges; + } + + function textLineRects(el) { + const range = document.createRange(); + range.selectNodeContents(el); + const rects = [...range.getClientRects()] + .filter((rect) => rect.width > 0.25 && rect.height > 0.25) + .map(plainRect) + .sort((a, b) => a.top - b.top || a.left - b.left); + const lines = []; + for (const rect of rects) { + const line = lines.find((candidate) => Math.min(candidate.bottom, rect.bottom) - Math.max(candidate.top, rect.top) > 0.75); + if (!line) { + lines.push({ ...rect }); + continue; + } + line.left = Math.min(line.left, rect.left); + line.top = Math.min(line.top, rect.top); + line.right = Math.max(line.right, rect.right); + line.bottom = Math.max(line.bottom, rect.bottom); + line.width = line.right - line.left; + line.height = line.bottom - line.top; + } + return lines; + } + + function layoutGuardIssues(root, rootRect) { + const tolerance = 0.75; + const issues = []; + const modeCounts = {}; + const all = [...root.querySelectorAll("*")].filter((el) => visible(el) && !ignored(el)); + + for (const el of all) { + if (!el.closest("[data-layout-allow-overflow]")) { + const box = el.getBoundingClientRect(); + const edges = outsideEdges(box, rootRect, tolerance); + if (edges.length) { + issues.push({ + type: "slide-overflow", + element: elementLabel(el), + edges, + slideBox: { x: 0, y: 0, w: rootRect.width, h: rootRect.height }, + elementBox: relativeRect(box, rootRect), + }); + } + } + + if (!textCandidate(el)) continue; + const lines = textLineRects(el); + const content = innerRect(el); + const overflowing = lines.find((line) => textOutsideEdges(line, content).length); + const computed = getComputedStyle(el); + const clippedX = computed.overflowX !== "visible" && el.scrollWidth > el.clientWidth + 1; + const clippedY = computed.overflowY !== "visible" && el.scrollHeight > el.clientHeight + 1; + const scrollOverflow = el.clientWidth > 0 && el.clientHeight > 0 && (clippedX || clippedY); + if (overflowing || scrollOverflow) { + issues.push({ + type: "text-overflow", + element: elementLabel(el), + edges: overflowing ? textOutsideEdges(overflowing, content) : ["scroll-size"], + contentBox: relativeRect(content, rootRect), + textBox: overflowing ? relativeRect(overflowing, rootRect) : undefined, + scrollSize: scrollOverflow ? { width: el.scrollWidth, height: el.scrollHeight, clientWidth: el.clientWidth, clientHeight: el.clientHeight } : undefined, + }); + } + } + + const guards = [...root.querySelectorAll("[data-layout-guard]")].filter(visible); + for (const guard of guards) { + const modes = new Set((guard.getAttribute("data-layout-guard") || "").split(/\s+/).filter(Boolean)); + for (const mode of modes) modeCounts[mode] = (modeCounts[mode] || 0) + 1; + const parent = guard.getBoundingClientRect(); + const children = [...guard.children].filter(visible).map((el) => ({ el, box: el.getBoundingClientRect() })); + if (modes.has("contain")) { + for (const child of children) { + const outside = []; + if (child.box.left < parent.left - tolerance) outside.push("left"); + if (child.box.top < parent.top - tolerance) outside.push("top"); + if (child.box.right > parent.right + tolerance) outside.push("right"); + if (child.box.bottom > parent.bottom + tolerance) outside.push("bottom"); + if (outside.length) { + issues.push({ + type: "contain", + guard: elementLabel(guard), + child: elementLabel(child.el), + edges: outside, + guardBox: relBox(guard, rootRect), + childBox: relBox(child.el, rootRect), + }); + } + } + } + if (modes.has("no-overlap")) { + for (let i = 0; i < children.length; i++) { + for (let j = i + 1; j < children.length; j++) { + const a = children[i]; + const b = children[j]; + const overlapW = Math.min(a.box.right, b.box.right) - Math.max(a.box.left, b.box.left); + const overlapH = Math.min(a.box.bottom, b.box.bottom) - Math.max(a.box.top, b.box.top); + if (overlapW > tolerance && overlapH > tolerance) { + issues.push({ + type: "overlap", + guard: elementLabel(guard), + first: elementLabel(a.el), + second: elementLabel(b.el), + overlap: { w: overlapW, h: overlapH }, + firstBox: relBox(a.el, rootRect), + secondBox: relBox(b.el, rootRect), + }); + } + } + } + } + if (modes.has("text-contain") || modes.has("single-line") || modes.has("text-center")) { + const lines = textLineRects(guard); + const content = innerRect(guard); + if (modes.has("text-contain")) { + for (const line of lines) { + const edges = textOutsideEdges(line, content); + if (edges.length) { + issues.push({ + type: "guarded-text-overflow", + guard: elementLabel(guard), + edges, + guardBox: relativeRect(content, rootRect), + textBox: relativeRect(line, rootRect), + }); + break; + } + } + + const radius = parseFloat(getComputedStyle(guard).borderTopLeftRadius) || 0; + const ellipseLike = Math.max(parent.width, parent.height) / Math.max(1, Math.min(parent.width, parent.height)) < 1.2 + && radius >= Math.min(parent.width, parent.height) * 0.45; + if (ellipseLike) { + const cx = parent.left + parent.width / 2; + const cy = parent.top + parent.height / 2; + const rx = parent.width / 2; + const ry = parent.height / 2; + for (const line of lines) { + const dy = Math.abs((line.top + line.bottom) / 2 - cy); + const available = dy < ry ? rx * Math.sqrt(Math.max(0, 1 - (dy * dy) / (ry * ry))) : 0; + if (line.left < cx - available - 1.5 || line.right > cx + available + 1.5) { + issues.push({ + type: "ellipse-text-overflow", + guard: elementLabel(guard), + guardBox: relativeRect(parent, rootRect), + textBox: relativeRect(line, rootRect), + }); + break; + } + } + } + } + if (modes.has("single-line") && lines.length > 1) { + issues.push({ type: "unexpected-wrap", guard: elementLabel(guard), lines: lines.length, guardBox: relBox(guard, rootRect) }); + } + if (modes.has("text-center") && lines.length) { + const horizontalTolerance = Math.max(2.5, Math.min(8, content.width * 0.03)); + const offCenterLine = lines.find((line) => Math.abs((line.left + line.right) / 2 - (content.left + content.right) / 2) > horizontalTolerance); + const textTop = Math.min(...lines.map((line) => line.top)); + const textBottom = Math.max(...lines.map((line) => line.bottom)); + const dx = offCenterLine ? (offCenterLine.left + offCenterLine.right) / 2 - (content.left + content.right) / 2 : 0; + const dy = (textTop + textBottom) / 2 - (content.top + content.bottom) / 2; + if (offCenterLine || Math.abs(dy) > Math.max(3, content.height * 0.06)) { + issues.push({ type: "text-center-drift", guard: elementLabel(guard), drift: { x: dx, y: dy }, guardBox: relativeRect(content, rootRect) }); + } + } + } + if (modes.has("icon-center")) { + const icons = [...guard.querySelectorAll("svg")].filter(visible); + if (icons.length !== 1) { + issues.push({ type: "icon-count", guard: elementLabel(guard), expected: 1, actual: icons.length }); + } else { + const content = innerRect(guard); + const icon = icons[0].getBoundingClientRect(); + const dx = (icon.left + icon.right) / 2 - (content.left + content.right) / 2; + const dy = (icon.top + icon.bottom) / 2 - (content.top + content.bottom) / 2; + if (Math.abs(dx) > 2 || Math.abs(dy) > 2) { + issues.push({ type: "icon-center-drift", guard: elementLabel(guard), drift: { x: dx, y: dy }, iconBox: relativeRect(icon, rootRect), guardBox: relativeRect(content, rootRect) }); + } + } + } + if (modes.has("chart-labels")) { + const series = [...guard.querySelectorAll(":scope > [data-chart-series]")].filter(visible); + if (!series.length) issues.push({ type: "chart-series-missing", guard: elementLabel(guard) }); + for (const item of series) { + const label = item.querySelector("[data-chart-label]"); + if (!label || !visible(label) || !(label.innerText || label.textContent || "").trim()) { + issues.push({ type: "chart-label-missing", guard: elementLabel(guard), series: elementLabel(item) }); + continue; + } + const seriesBox = item.getBoundingClientRect(); + const labelBox = label.getBoundingClientRect(); + const edges = outsideEdges(labelBox, seriesBox, tolerance); + if (edges.length) issues.push({ type: "chart-label-overflow", guard: elementLabel(guard), series: elementLabel(item), label: elementLabel(label), edges }); + } + } + } + return { count: guards.length, modeCounts, issues }; + } + return roots.map((root, slideIndex) => { const rootRect = root.getBoundingClientRect(); const all = [...root.querySelectorAll("*")].filter(visible); + const guardResult = layoutGuardIssues(root, rootRect); return { slideIndex, box: { x: 0, y: 0, w: rootRect.width, h: rootRect.height }, rootStyle: style(root), + layoutGuardCount: guardResult.count, + layoutGuardModes: guardResult.modeCounts, + layoutIssues: guardResult.issues, elements: all.map((el, idx) => { const tc = textCandidate(el); const parentTc = el.parentElement && root.contains(el.parentElement) && textCandidate(el.parentElement); @@ -427,10 +683,21 @@ function addText(slide, name, item, box, ctx, opts) { const style = item.style || {}; const color = rgbParts(style.color); const fallbackColor = opts.fallbackColor || "1E293B"; - const runs = (item.runs || []).map((r) => { + const horizontalAlign = style.textAlign === "center" || style.justifyContent === "center" + ? "center" + : style.textAlign === "right" || style.justifyContent === "flex-end" + ? "right" + : "left"; + const verticalAlign = style.alignItems === "center" ? "mid" : style.alignItems === "flex-end" ? "bottom" : "top"; + const runs = []; + for (const r of item.runs || []) { + if (r.text === "\n") { + if (runs.length) runs[runs.length - 1].options.breakLine = true; + continue; + } const rs = r.style || style; const c = rgbParts(rs.color); - return { + runs.push({ text: r.text, options: { fontFace: opts.fontFace, @@ -441,8 +708,8 @@ function addText(slide, name, item, box, ctx, opts) { charSpacing: pxNum(rs.letterSpacing, 0) ? ctx.pt(pxNum(rs.letterSpacing, 0)) : undefined, breakLine: false, }, - }; - }); + }); + } slide.addText(runs.length ? runs : item.text, { objectName: name, ...ctx.pos(box), @@ -453,12 +720,12 @@ function addText(slide, name, item, box, ctx, opts) { bold: Number(style.fontWeight || 400) >= 600, italic: style.fontStyle === "italic", charSpacing: pxNum(style.letterSpacing, 0) ? ctx.pt(pxNum(style.letterSpacing, 0)) : undefined, - align: style.textAlign === "center" ? "center" : style.textAlign === "right" ? "right" : "left", + align: horizontalAlign, lineSpacing: style.lineHeight && style.lineHeight !== "normal" ? ctx.pt(pxNum(style.lineHeight)) : undefined, margin: 0, fit: "none", breakLine: false, - valign: "top", + valign: verticalAlign, }); } @@ -466,7 +733,23 @@ function expandedTextBox(item, slideBox) { const b = { ...(item.contentBox || item.box) }; const size = pxNum(item.style && item.style.fontSize, 12); const extra = Math.max(8, Math.min(28, size * 1.4)); - b.w = Math.min(slideBox.w - b.x, b.w + extra); + const style = item.style || {}; + const align = style.textAlign === "center" || style.justifyContent === "center" + ? "center" + : style.textAlign === "right" || style.justifyContent === "flex-end" + ? "right" + : "left"; + if (align === "center") { + const grow = Math.min(extra / 2, b.x, slideBox.w - b.x - b.w); + b.x -= grow; + b.w += grow * 2; + } else if (align === "right") { + const grow = Math.min(extra, b.x); + b.x -= grow; + b.w += grow; + } else { + b.w = Math.min(slideBox.w - b.x, b.w + extra); + } b.h = Math.min(slideBox.h - b.y, b.h + Math.max(2, size * 0.25)); return b; } @@ -670,12 +953,6 @@ function tryPreview(pptxPath, outDir, baseName) { async function main() { const opts = parseArgs(process.argv); const baseName = safeName(opts.name || defaultName(opts.input)); - const outDir = path.resolve(opts.outDir); - const assetDir = path.join(outDir, `${baseName}-assets`); - const rawPath = path.join(outDir, `${baseName}-editable.raw.pptx`); - const outPath = path.join(outDir, `${baseName}-editable.pptx`); - fs.mkdirSync(outDir, { recursive: true }); - fs.rmSync(assetDir, { recursive: true, force: true }); const launchOptions = { headless: true }; const browserPath = findBrowser(opts.chrome); @@ -699,6 +976,33 @@ async function main() { const slideData = await collectDom(page, opts); await browser.close(); if (!slideData.length) throw new Error("No slide/body content found."); + const layoutIssues = slideData.flatMap((slide) => (slide.layoutIssues || []).map((issue) => ({ slide: slide.slideIndex + 1, ...issue }))); + if (layoutIssues.length) { + const details = layoutIssues.slice(0, 20).map((issue) => `slide ${issue.slide}: ${JSON.stringify(issue)}`).join("\n"); + const suffix = layoutIssues.length > 20 ? `\n... ${layoutIssues.length - 20} more issue(s)` : ""; + throw new Error(`HTML DOM audit failed with ${layoutIssues.length} issue(s):\n${details}${suffix}`); + } + + const guardModes = {}; + for (const slide of slideData) { + for (const [mode, count] of Object.entries(slide.layoutGuardModes || {})) guardModes[mode] = (guardModes[mode] || 0) + count; + } + const domAudit = { + issues: 0, + guardElements: slideData.reduce((n, slide) => n + (slide.layoutGuardCount || 0), 0), + guardModes, + }; + if (opts.domAuditOnly) { + console.log(JSON.stringify({ mode: "dom-audit-only", input: opts.input, slides: slideData.length, domAudit }, null, 2)); + return; + } + + const outDir = path.resolve(opts.outDir); + const assetDir = path.join(outDir, `${baseName}-assets`); + const rawPath = path.join(outDir, `${baseName}-editable.raw.pptx`); + const outPath = path.join(outDir, `${baseName}-editable.pptx`); + fs.mkdirSync(outDir, { recursive: true }); + fs.rmSync(assetDir, { recursive: true, force: true }); const svgAssets = await saveSvgAssets(slideData, assetDir); const pptx = new pptxgen(); @@ -734,7 +1038,9 @@ async function main() { textElements: slideData.reduce((n, s) => n + s.elements.filter((e) => e.textCandidate).length, 0), svgImages: svgAssets.map.size, gradientPatches: gradientPatches.length, + layoutGuards: slideData.reduce((n, s) => n + (s.layoutGuardCount || 0), 0), }, + domAudit, previewDir, }; console.log(JSON.stringify(summary, null, 2)); diff --git a/scripts/html_to_ppt.js b/scripts/html_to_ppt.js index c9accf7..8aa6095 100755 --- a/scripts/html_to_ppt.js +++ b/scripts/html_to_ppt.js @@ -6,9 +6,10 @@ const { spawnSync } = require("child_process"); function usage() { console.log(`Usage: - html_to_ppt.js --input --out-dir [options] + html_to_ppt.js --input [--out-dir ] [options] This is a short alias for html_to_editable_pptx.js. It forwards all options. +Use --dom-audit-only to validate the rendered HTML without writing a PPTX. Run with --help-converter to see the full converter help. `); } diff --git a/scripts/render_pptx_preview.js b/scripts/render_pptx_preview.js new file mode 100755 index 0000000..4839526 --- /dev/null +++ b/scripts/render_pptx_preview.js @@ -0,0 +1,181 @@ +#!/usr/bin/env node +/* Render a PPTX as full-size slide PNGs plus a contact sheet for visual QA. */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const MODULE_ROOTS = [ + ...(process.env.NODE_PATH ? process.env.NODE_PATH.split(path.delimiter) : []), + path.join(os.homedir(), ".cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules"), + path.join(os.homedir(), "node_modules"), + path.join(process.cwd(), "node_modules"), +].filter(Boolean); + +function requirePackage(name) { + try { + return require(require.resolve(name, { paths: MODULE_ROOTS })); + } catch { + throw new Error(`Cannot find required package "${name}". Set NODE_PATH to a node_modules directory that contains it.`); + } +} + +const sharp = requirePackage("sharp"); + +function usage(exitCode = 0) { + console.log(`Usage: + render_pptx_preview.js --input [options] + +Options: + --input Required PPTX file + --out-dir Defaults to /-qa + --columns Contact-sheet columns, defaults to 4 + --thumb-width Contact-sheet thumbnail width, defaults to 360 + --dpi Full-size slide render resolution, defaults to 144 + --help Show this help +`); + process.exit(exitCode); +} + +function parseArgs(argv) { + const opts = { columns: 4, thumbWidth: 360, dpi: 144 }; + for (let i = 2; i < argv.length; i += 1) { + const arg = argv[i]; + const next = () => { + if (i + 1 >= argv.length) throw new Error(`Missing value for ${arg}`); + return argv[++i]; + }; + if (arg === "--help" || arg === "-h") usage(0); + else if (arg === "--input") opts.input = next(); + else if (arg === "--out-dir") opts.outDir = next(); + else if (arg === "--columns") opts.columns = Number(next()); + else if (arg === "--thumb-width") opts.thumbWidth = Number(next()); + else if (arg === "--dpi") opts.dpi = Number(next()); + else throw new Error(`Unknown argument: ${arg}`); + } + if (!opts.input) throw new Error("--input is required"); + for (const [name, value] of [["columns", opts.columns], ["thumb-width", opts.thumbWidth], ["dpi", opts.dpi]]) { + if (!Number.isInteger(value) || value <= 0) throw new Error(`--${name} must be a positive integer`); + } + return opts; +} + +function findExecutable(candidates, label) { + for (const candidate of candidates) { + if (candidate.includes(path.sep)) { + if (fs.existsSync(candidate)) return candidate; + continue; + } + try { + return execFileSync("which", [candidate], { encoding: "utf8" }).trim(); + } catch { + // Try the next candidate. + } + } + throw new Error(`${label} was not found. Install LibreOffice and Poppler, or add their executables to PATH.`); +} + +function naturalSort(files) { + return files.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" })); +} + +async function buildContactSheet(slideFiles, outputPath, columns, thumbWidth) { + const thumbs = []; + for (const file of slideFiles) { + const input = await sharp(file).resize({ width: thumbWidth }).png().toBuffer(); + const metadata = await sharp(input).metadata(); + thumbs.push({ input, width: metadata.width, height: metadata.height }); + } + + const gap = 24; + const outer = 32; + const labelHeight = 34; + const maxThumbHeight = Math.max(...thumbs.map((thumb) => thumb.height)); + const rows = Math.ceil(thumbs.length / columns); + const sheetWidth = outer * 2 + columns * thumbWidth + (columns - 1) * gap; + const sheetHeight = outer * 2 + rows * (maxThumbHeight + labelHeight) + (rows - 1) * gap; + const composites = []; + + for (let i = 0; i < thumbs.length; i += 1) { + const row = Math.floor(i / columns); + const column = i % columns; + const itemsInRow = Math.min(columns, thumbs.length - row * columns); + const rowOffset = ((columns - itemsInRow) * (thumbWidth + gap)) / 2; + const left = outer + rowOffset + column * (thumbWidth + gap); + const top = outer + row * (maxThumbHeight + labelHeight + gap); + const thumb = thumbs[i]; + composites.push({ + input: Buffer.from(``), + left: left - 2, + top: top - 2, + }); + composites.push({ input: thumb.input, left, top }); + composites.push({ + input: Buffer.from(`Slide ${i + 1}`), + left, + top: top + maxThumbHeight, + }); + } + + await sharp({ + create: { width: sheetWidth, height: sheetHeight, channels: 4, background: "#eef2f7" }, + }).composite(composites).png().toFile(outputPath); +} + +async function main() { + const opts = parseArgs(process.argv); + const input = path.resolve(opts.input); + if (!fs.existsSync(input)) throw new Error(`PPTX not found: ${input}`); + if (path.extname(input).toLowerCase() !== ".pptx") throw new Error(`Expected a .pptx file: ${input}`); + + const baseName = path.basename(input, path.extname(input)); + const outDir = path.resolve(opts.outDir || path.join(path.dirname(input), `${baseName}-qa`)); + const slidesDir = path.join(outDir, "slides"); + fs.mkdirSync(outDir, { recursive: true }); + fs.rmSync(slidesDir, { recursive: true, force: true }); + fs.mkdirSync(slidesDir, { recursive: true }); + + const soffice = findExecutable([ + "/opt/homebrew/bin/soffice", + "/Applications/LibreOffice.app/Contents/MacOS/soffice", + "soffice", + ], "LibreOffice"); + const pdftoppm = findExecutable(["pdftoppm"], "pdftoppm"); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "learn-deck-preview-")); + + try { + const pdfDir = path.join(tempDir, "pdf"); + const profileDir = path.join(tempDir, "lo-profile"); + fs.mkdirSync(pdfDir, { recursive: true }); + execFileSync(soffice, [ + `-env:UserInstallation=file://${profileDir}`, + "--headless", + "--convert-to", "pdf", + "--outdir", pdfDir, + input, + ], { stdio: "ignore" }); + + const converted = fs.readdirSync(pdfDir).find((file) => path.extname(file).toLowerCase() === ".pdf"); + if (!converted) throw new Error("LibreOffice did not produce a PDF preview."); + const pdfPath = path.join(outDir, `${baseName}.pdf`); + fs.copyFileSync(path.join(pdfDir, converted), pdfPath); + + execFileSync(pdftoppm, ["-png", "-r", String(opts.dpi), pdfPath, path.join(slidesDir, "slide")], { stdio: "ignore" }); + const slideFiles = naturalSort(fs.readdirSync(slidesDir) + .filter((file) => /^slide-\d+\.png$/i.test(file)) + .map((file) => path.join(slidesDir, file))); + if (!slideFiles.length) throw new Error("pdftoppm did not produce slide PNGs."); + + const contactSheet = path.join(outDir, "contact-sheet.png"); + await buildContactSheet(slideFiles, contactSheet, opts.columns, opts.thumbWidth); + console.log(JSON.stringify({ input, pdf: pdfPath, slides: slideFiles, contactSheet, slideCount: slideFiles.length }, null, 2)); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err.stack || err.message || String(err)); + process.exit(1); +}); diff --git a/scripts/smoke_test.js b/scripts/smoke_test.js new file mode 100755 index 0000000..c96d223 --- /dev/null +++ b/scripts/smoke_test.js @@ -0,0 +1,143 @@ +#!/usr/bin/env node +/* Verify direct HTML, DOM auditing, layout guards, and legacy JSON compatibility. */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const root = path.resolve(__dirname, ".."); +const workspace = fs.mkdtempSync(path.join(os.tmpdir(), "learn-deck-smoke-")); +let passed = false; + +function run(script, args, expectFailure = false) { + const result = spawnSync(process.execPath, [path.join(__dirname, script), ...args], { + cwd: root, + env: process.env, + encoding: "utf8", + timeout: 120000, + maxBuffer: 10 * 1024 * 1024, + }); + if (result.error) throw result.error; + const output = `${result.stdout || ""}${result.stderr || ""}`; + if (expectFailure ? result.status === 0 : result.status !== 0) { + throw new Error(`${script} ${expectFailure ? "unexpectedly succeeded" : "failed"} (status ${result.status})\n${output}`); + } + return output; +} + +function assertPptx(file) { + if (!fs.existsSync(file)) throw new Error(`Expected PPTX was not created: ${file}`); + const stat = fs.statSync(file); + if (stat.size < 10000) throw new Error(`PPTX is unexpectedly small (${stat.size} bytes): ${file}`); + const header = fs.readFileSync(file).subarray(0, 2).toString("ascii"); + if (header !== "PK") throw new Error(`PPTX is not a ZIP/OOXML package: ${file}`); +} + +function readZipMember(file, member) { + const result = spawnSync("unzip", ["-p", file, member], { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Could not read ${member} from ${file}\n${result.stderr || ""}`); + return result.stdout; +} + +function replaceRequired(source, before, after) { + if (!source.includes(before)) throw new Error(`Smoke-test fixture marker not found: ${before}`); + return source.replace(before, after); +} + +try { + const template = path.join(root, "templates", "learn-deck-polished-style.html"); + const directOut = path.join(workspace, "direct"); + fs.mkdirSync(directOut, { recursive: true }); + const directOutput = run("html_to_ppt.js", [ + "--input", template, + "--out-dir", directOut, + "--name", "direct", + "--font-face", "Arial", + "--no-preview", + ]); + const directPptx = path.join(directOut, "direct-editable.pptx"); + assertPptx(directPptx); + const expectedSlides = (fs.readFileSync(template, "utf8").match(/
", centerMarker); + const centerEnd = slide3Xml.indexOf("", centerMarker); + const centerXml = centerMarker >= 0 && centerStart >= 0 && centerEnd >= 0 + ? slide3Xml.slice(centerStart, centerEnd + "".length) + : ""; + if (!/Core<\/a:t>[\s\S]*?<\/a:p>[\s\S]*?idea<\/a:t>/.test(centerXml)) { + throw new Error("Centered multiline text was not written as separate PowerPoint paragraphs."); + } + + let broken = fs.readFileSync(template, "utf8"); + broken = replaceRequired(broken, "grid-template-columns: 33px minmax(0, 1fr);", "grid-template-columns: 1px minmax(0, 1fr);"); + broken = replaceRequired(broken, "column-gap: 10px;", "column-gap: 0;"); + broken = replaceRequired(broken, "height: 104px;", "height: 56px;"); + const brokenFile = path.join(workspace, "broken-layout.html"); + fs.writeFileSync(brokenFile, broken, "utf8"); + const guardOutput = run("html_to_ppt.js", [ + "--input", brokenFile, + "--out-dir", path.join(workspace, "broken"), + "--name", "broken", + "--no-preview", + ], true); + for (const marker of ["HTML DOM audit failed", '"type":"overlap"', '"type":"contain"']) { + if (!guardOutput.includes(marker)) throw new Error(`Layout-guard failure did not include ${marker}\n${guardOutput}`); + } + + const domAuditFixture = `
+
+
This label is clipped
+
This title should wrap unexpectedly
+
+
+
A very long label
+
`; + const domAuditFile = path.join(workspace, "broken-dom-audit.html"); + fs.writeFileSync(domAuditFile, domAuditFixture, "utf8"); + const domAuditFailure = run("html_to_ppt.js", ["--input", domAuditFile, "--dom-audit-only"], true); + for (const marker of ['"type":"slide-overflow"', '"type":"text-overflow"', '"type":"unexpected-wrap"', '"type":"icon-center-drift"', '"type":"chart-label-missing"', '"type":"ellipse-text-overflow"']) { + if (!domAuditFailure.includes(marker)) throw new Error(`DOM-audit failure did not include ${marker}\n${domAuditFailure}`); + } + + const legacyOut = path.join(workspace, "legacy"); + fs.mkdirSync(legacyOut, { recursive: true }); + run("generate_ppt.js", [ + "--slides", path.join(root, "examples", "slides.example.json"), + "--out-dir", legacyOut, + "--name", "legacy", + "--no-preview", + ]); + assertPptx(path.join(legacyOut, "legacy-editable.pptx")); + + passed = true; + console.log("LearnDeck smoke test passed: direct HTML, DOM audit, layout guards, multiline text, and legacy JSON."); +} catch (err) { + console.error(err.stack || err.message || String(err)); + console.error(`Smoke-test artifacts kept at: ${workspace}`); + process.exitCode = 1; +} finally { + if (passed) fs.rmSync(workspace, { recursive: true, force: true }); +} diff --git a/templates/learn-deck-polished-style.html b/templates/learn-deck-polished-style.html new file mode 100644 index 0000000..d11e4ca --- /dev/null +++ b/templates/learn-deck-polished-style.html @@ -0,0 +1,871 @@ + + + + + +LearnDeck polished style template + + + + +
+ +
LEARNDECK · AI PRESENTATION ENGINE
+
01 / 07
+ +

Turn knowledge into
polished, editable PPTs

+

Start with a topic, outline, or source material. Generate a complete presentation with clear structure, consistent visuals, and objects that remain easy to edit.

+ +
+
+
+

Clear structure

+

Titles, key points, and pacing stay easy to follow

+
+
+
+

Polished visuals

+

Consistent color, spacing, and hierarchy

+
+
+
+

Editable delivery

+

Text, shapes, and icons stay editable

+
+
+ +
+ Source content + HTML layout + Browser geometry + PPTX rebuild +
+ +
+ +
+
+
LearnDeck editable presentation preview
+
+ +
+
+
+
+
+ +
+
+
+ +
The delivery stays editable
+
+
+
+ +
+

Separate text boxes

+

Edit titles, body copy, and emphasis

+
+
+
+ +
+

Native shapes

+

Rearrange cards, blocks, and gradients

+
+
+
+ +
+

Independent icons

+

Move, resize, or replace visual assets

+
+
+
+ + +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
02 / 07
+

A strong deck begins with
one clear idea

+

Define what the audience should understand or decide before choosing page structures, charts, or decorative elements.

+
+
+
Audience

Write for the people viewing the deck.

+
Message

Give each slide one primary claim.

+
Rhythm

Vary density and composition intentionally.

+
+ + +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
03 / 07
+

Structure follows the learner's questions

+

Pair a concise reasoning sequence with one clear relationship map.

+
+
What is the core idea?

State it in language the audience can repeat.

+
What supports it?

Show only the evidence that changes understanding.

+
What follows next?

Make the implication or decision explicit.

+
+
+
+
Context

Why now

+
Evidence

What proves it

+
Example

How it works

+
Action

What changes

+
Core
idea
+
+ +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
04 / 07
+

Build momentum one step at a time

+

Use an alternating timeline when sequence and progression are the message.

+
+
+
STEP 01Frame

Define the communication job.

+
STEP 02Shape

Choose the evidence and narrative.

+
STEP 03Design

Give each claim the right visual form.

+
STEP 04Verify

Render, inspect, revise, and deliver.

+
+ +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
05 / 07
+

Choose the treatment that serves the message

A comparison page should clarify a choice, not merely duplicate two lists.

+
+
MODE AExplain
Lead with logic

Use definitions, evidence, and examples.

Keep density moderate

Let the audience build understanding.

Close with application

Show how the idea changes practice.

+
PURPOSEDENSITYENDING
+
MODE BPersuade
Lead with stakes

Make the consequence of inaction clear.

Prioritize decisive proof

Show evidence that changes the choice.

Close with a decision

Name the next action and owner.

+
+ +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
06 / 07
+

Use data to make the takeaway visible

+

Lead with the meaning of the number, then show the pattern behind it.

+
ILLUSTRATIVE DATA
68%

Faster comprehension

Replace this placeholder with verified evidence and a conclusion relevant to the audience.

+

Understanding improves with visual structure

Illustrative values — replace with sourced data.

A
B
C
D
+ +
+ +
+ +
LEARNDECK · POLISHED STYLE SYSTEM
+
07 / 07
+

End with a decision,
not a generic thank-you

+

Resolve the opening question and make the audience's next move unmistakable.

+
01Synthesize

Restate the central takeaway.

02Decide

Name the choice or implication.

03Act

Make ownership and timing clear.

+
CLEAR MESSAGE → EDITABLE DELIVERY
+ +
+ + diff --git a/templates/learn-deck-polished-style.pptx b/templates/learn-deck-polished-style.pptx new file mode 100644 index 0000000..8267346 Binary files /dev/null and b/templates/learn-deck-polished-style.pptx differ