@aabadin/didactic-ppt-skill-package 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,38 @@
1
+ # didactic-ppt-skill-package
2
+
3
+ Installs the `didactic-powerpoint-pedagogico` OpenCode skill into:
4
+
5
+ - `~/.config/opencode/skills/didactic-powerpoint-pedagogico/`
6
+
7
+ ## Example workflow
8
+
9
+ 1. Install with `npx --yes @aabadin/didactic-ppt-skill-package`.
10
+ 2. Restart OpenCode.
11
+ 3. Ask for a didactic `.pptx` from educational source text.
12
+
13
+ Example prompt:
14
+
15
+ ```text
16
+ Create a didactic PowerPoint from this educational source text and save it as a .pptx.
17
+ ```
18
+
19
+ ## Local generation example
20
+
21
+ ```bash
22
+ node .\skills\didactic-powerpoint-pedagogico\scripts\cli.js .\tests\fixtures\flow-input.txt .\output\flujo-didactico.pptx
23
+ ```
24
+
25
+ ## Local package install test
26
+
27
+ ```bash
28
+ npx --yes .\didactic-ppt-skill-package
29
+ ```
30
+
31
+ ## Publish to npm
32
+
33
+ ```bash
34
+ npm login
35
+ npm publish
36
+ ```
37
+
38
+ The package is already configured with `publishConfig.access = public`, which is required for initial publication of a public scoped package such as `@aabadin/didactic-ppt-skill-package`.
package/bin/install.js ADDED
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const skillName = 'didactic-powerpoint-pedagogico';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const sourceDir = path.join(__dirname, '..', 'skills', skillName);
12
+ const targetDir = path.join(os.homedir(), '.config', 'opencode', 'skills', skillName);
13
+
14
+ function installSkillDependencies(directory) {
15
+ const command = process.platform === 'win32'
16
+ ? process.env.ComSpec ?? 'cmd.exe'
17
+ : 'npm';
18
+ const args = process.platform === 'win32'
19
+ ? ['/d', '/s', '/c', 'npm install --omit=dev']
20
+ : ['install', '--omit=dev'];
21
+ const result = spawnSync(command, args, {
22
+ cwd: directory,
23
+ stdio: 'inherit'
24
+ });
25
+
26
+ if (result.error) {
27
+ console.error(`Failed to install ${skillName} dependencies: ${result.error.message}`);
28
+ process.exit(1);
29
+ }
30
+
31
+ if (result.status !== 0) {
32
+ process.exit(result.status ?? 1);
33
+ }
34
+ }
35
+
36
+ if (!fs.existsSync(sourceDir)) {
37
+ console.error(`Skill source not found: ${sourceDir}`);
38
+ process.exitCode = 1;
39
+ } else {
40
+ fs.mkdirSync(targetDir, { recursive: true });
41
+ fs.cpSync(sourceDir, targetDir, { recursive: true, force: true });
42
+ installSkillDependencies(targetDir);
43
+
44
+ console.log(`Installed ${skillName} to ${targetDir}`);
45
+ console.log('Restart OpenCode so new sessions can detect the skill.');
46
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@aabadin/didactic-ppt-skill-package",
3
+ "version": "0.1.0",
4
+ "description": "Install the didactic-powerpoint-pedagogico OpenCode skill into the global skills directory.",
5
+ "type": "module",
6
+ "bin": {
7
+ "didactic-ppt-skill-package": "bin/install.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test",
11
+ "pack:check": "npm pack --dry-run"
12
+ },
13
+ "dependencies": {
14
+ "pptxgenjs": "^3.12.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "files": [
23
+ "bin",
24
+ "skills",
25
+ "README.md"
26
+ ],
27
+ "keywords": [
28
+ "opencode",
29
+ "skill",
30
+ "powerpoint",
31
+ "pptx",
32
+ "didactic",
33
+ "education"
34
+ ],
35
+ "author": "aabadin",
36
+ "license": "UNLICENSED"
37
+ }
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: didactic-powerpoint-pedagogico
3
+ description: Use when the user asks to create, redesign, or generate a didactic presentation, educational slide deck, teaching slides, or a .pptx from source text. Use even if the user mainly asks for more pedagogical, more legible, more projection-safe, or more structured teaching slides.
4
+ ---
5
+
6
+ # Didactic PowerPoint Pedagogico
7
+
8
+ ## When to Use
9
+
10
+ Use this skill when the output should be a didactic `.pptx` presentation driven by explicit pedagogical structure rather than decorative slide design.
11
+
12
+ Consult `references/pedagogical-rules.md` for the pedagogical constraints and `templates/didactic-theme.json` for the packaged typography, color, and grid defaults.
13
+
14
+ ## Workflow
15
+
16
+ 1. Parse the source text into one idea per slide.
17
+ 2. Convert each slide into an assertion-evidence structure.
18
+ 3. Select one deterministic diagram family: `flow`, `network`, `segment`, `stack`, or `join`.
19
+ 4. Build an intermediate slide spec.
20
+ 5. Validate typography, fit, contrast, and object-count rules.
21
+ 6. Generate the `.pptx`.
22
+
23
+ ## Hard Rules
24
+
25
+ - Use only sans-serif typography.
26
+ - Never shrink body text below `16 pt`.
27
+ - Keep titles within two lines.
28
+ - Keep slides within six independent visual objects.
29
+ - Split or simplify slides instead of allowing overflow.
30
+
31
+ ## Output Contract
32
+
33
+ - Produce a `.pptx` file.
34
+ - Report validation adjustments when content was shortened, split, or simplified.
35
+ - Prefer segmentation over illegible compression.
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "didactic-powerpoint-pedagogico",
3
+ "private": true,
4
+ "type": "module",
5
+ "dependencies": {
6
+ "pptxgenjs": "^3.12.0"
7
+ }
8
+ }
@@ -0,0 +1,10 @@
1
+ # Pedagogical Rules
2
+
3
+ - Assertion-evidence titles only
4
+ - One central idea per slide
5
+ - Max six independent objects
6
+ - Deterministic diagram mapping
7
+ - Grid 4x3 plus whitespace preservation
8
+ - Left-aligned sans-serif typography
9
+ - Minimum contrast ratio 4.5:1
10
+ - Bullet avoidance and overflow prevention
@@ -0,0 +1,77 @@
1
+ const DIAGRAM_PATTERNS = {
2
+ flow: [
3
+ /\bprimero\b/i,
4
+ /\bluego\b/i,
5
+ /\bdespu(?:e|é)s\b/i,
6
+ /\bfinalmente\b/i,
7
+ /\bpasos?\b/i,
8
+ /\bsecuencia\b/i
9
+ ],
10
+ network: [
11
+ /\bniveles\b/i,
12
+ /\bjer(?:a|á)rqu(?:i|í)(?:a|as|co|ca|cos|cas)\b/i,
13
+ /\bdepende(?:n)?\b/i,
14
+ /\bincluye(?:n)?\b/i,
15
+ /\bramas\b/i,
16
+ /\bsistema\b/i
17
+ ],
18
+ segment: [/\btipos\b/i, /\bcategor(?:i|í)as\b/i, /\bgrupos\b/i],
19
+ stack: [/\bcapas\b/i, /\bniveles superpuestos\b/i, /\bestratos\b/i],
20
+ join: [/\bcomparte(?:n)?\b/i, /\bconverge(?:n)?\b/i, /\buni(?:o|ó)n\b/i]
21
+ };
22
+
23
+ export function buildAssertionTitle(text) {
24
+ const trimmed = text.trim();
25
+ const fallbackTitle = 'Esta idea central debe explicarse con evidencia.';
26
+
27
+ if (!trimmed) {
28
+ return fallbackTitle;
29
+ }
30
+
31
+ const boundaryMatch = trimmed.match(/[:;\n]|[.!?]/);
32
+ if (boundaryMatch) {
33
+ const boundaryIndex = boundaryMatch.index;
34
+ const boundary = boundaryMatch[0];
35
+ const sliceEnd = /[.!?]/.test(boundary) ? boundaryIndex + 1 : boundaryIndex;
36
+ const title = trimmed.slice(0, sliceEnd).trim();
37
+
38
+ if (title) {
39
+ return title;
40
+ }
41
+ }
42
+
43
+ return trimmed || fallbackTitle;
44
+ }
45
+
46
+ function inferDiagramType(text) {
47
+ for (const [diagramType, patterns] of Object.entries(DIAGRAM_PATTERNS)) {
48
+ if (patterns.some((pattern) => pattern.test(text))) {
49
+ return diagramType;
50
+ }
51
+ }
52
+
53
+ return 'join';
54
+ }
55
+
56
+ export function buildSlideSpec(sourceText) {
57
+ const trimmedText = sourceText.trim();
58
+
59
+ return {
60
+ theme: 'didactic-default',
61
+ slides: [
62
+ {
63
+ assertionTitle: buildAssertionTitle(trimmedText),
64
+ contentStructure: 'auto',
65
+ diagramType: inferDiagramType(trimmedText),
66
+ canvas: 'grid-4x3',
67
+ elements: [
68
+ {
69
+ type: 'shape',
70
+ role: 'concept',
71
+ text: trimmedText
72
+ }
73
+ ]
74
+ }
75
+ ]
76
+ };
77
+ }
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env node
2
+ import { readFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import process from 'node:process';
5
+
6
+ import { buildSlideSpec } from './build-slide-spec.js';
7
+ import { generatePptx } from './generate-pptx.js';
8
+
9
+ const inputPath = process.argv[2];
10
+ const outputPath = process.argv[3] ?? path.resolve('didactic-presentation.pptx');
11
+
12
+ if (!inputPath) {
13
+ console.error('Usage: node cli.js <input.txt> [output.pptx]');
14
+ process.exitCode = 1;
15
+ } else {
16
+ try {
17
+ const sourceText = await readFile(inputPath, 'utf8');
18
+ const spec = buildSlideSpec(sourceText);
19
+ await generatePptx(spec, outputPath);
20
+ console.log(`Generated ${outputPath}`);
21
+ } catch (error) {
22
+ console.error(error.message);
23
+ process.exitCode = 1;
24
+ }
25
+ }
@@ -0,0 +1,36 @@
1
+ export const MIN_FONT_SIZE = 16;
2
+
3
+ function estimateCapacity(box, fontSize) {
4
+ const width = Math.max(0, Number(box?.width) || 0);
5
+ const height = Math.max(0, Number(box?.height) || 0);
6
+
7
+ if (width === 0 || height === 0) {
8
+ return 0;
9
+ }
10
+
11
+ const charsPerLine = Math.max(1, Math.floor(width / (fontSize * 0.6)));
12
+ const lineCount = Math.max(1, Math.floor(height / (fontSize * 1.2)));
13
+ return charsPerLine * lineCount;
14
+ }
15
+
16
+ export function fitText(text, box) {
17
+ const normalizedText = String(text ?? '').trim();
18
+ const baseFontSize = Number(box?.fontSize) || Math.floor((Number(box?.height) || 0) / 3);
19
+ const fontSize = Math.max(MIN_FONT_SIZE, baseFontSize);
20
+ const capacity = estimateCapacity(box, fontSize);
21
+
22
+ if (capacity === 0 || normalizedText.length <= capacity) {
23
+ return { text: normalizedText, fontSize, overflow: false, truncated: false };
24
+ }
25
+
26
+ const compressedText = capacity <= 3
27
+ ? normalizedText.slice(0, capacity)
28
+ : `${normalizedText.slice(0, capacity - 3).trimEnd()}...`;
29
+
30
+ return {
31
+ text: compressedText,
32
+ fontSize,
33
+ overflow: false,
34
+ truncated: true
35
+ };
36
+ }
@@ -0,0 +1,108 @@
1
+ import PptxGenJS from 'pptxgenjs';
2
+ import { mkdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import theme from '../templates/didactic-theme.json' with { type: 'json' };
5
+
6
+ import { fitText } from './fit-text.js';
7
+ import { validateSlide } from './validate-layout.js';
8
+
9
+ const BACKGROUND_COLOR = theme.colors.background;
10
+ const TEXT_COLOR = theme.colors.text;
11
+ const BODY_FILL_COLOR = theme.colors.surface;
12
+ const BODY_LINE_COLOR = theme.colors.surfaceLine;
13
+ const TITLE_FONT_SIZE = theme.titleFontSize;
14
+ const BODY_FONT_SIZE = theme.bodyFontSize;
15
+ const FONT_FACE = theme.fontFace;
16
+ const BODY_TEXT_BOX = Object.freeze({
17
+ width: 396,
18
+ height: 86.4,
19
+ fontSize: BODY_FONT_SIZE,
20
+ color: TEXT_COLOR,
21
+ backgroundColor: BODY_FILL_COLOR
22
+ });
23
+
24
+ function getBodyText(slideSpec) {
25
+ return slideSpec.elements?.[0]?.text ?? '';
26
+ }
27
+
28
+ function prepareSlideForRendering(slideSpec) {
29
+ const elements = Array.isArray(slideSpec?.elements) ? slideSpec.elements : [];
30
+ const [firstElement, ...restElements] = elements;
31
+
32
+ if (!firstElement) {
33
+ return { ...slideSpec, elements };
34
+ }
35
+
36
+ const bodyElement = {
37
+ ...BODY_TEXT_BOX,
38
+ ...firstElement
39
+ };
40
+ const fittedText = fitText(bodyElement.text, {
41
+ width: bodyElement.width,
42
+ height: bodyElement.height,
43
+ fontSize: bodyElement.fontSize
44
+ });
45
+
46
+ return {
47
+ ...slideSpec,
48
+ elements: [
49
+ {
50
+ ...bodyElement,
51
+ ...fittedText
52
+ },
53
+ ...restElements
54
+ ]
55
+ };
56
+ }
57
+
58
+ export async function generatePptx(spec, outputPath) {
59
+ const pptx = new PptxGenJS();
60
+ pptx.layout = 'LAYOUT_WIDE';
61
+ const validationIssues = [];
62
+
63
+ for (const [slideIndex, slideSpec] of (spec.slides ?? []).entries()) {
64
+ const preparedSlide = prepareSlideForRendering(slideSpec);
65
+ const issues = validateSlide(preparedSlide);
66
+
67
+ if (issues.length > 0) {
68
+ validationIssues.push({ slideIndex, issues });
69
+ }
70
+
71
+ const slide = pptx.addSlide();
72
+
73
+ slide.background = { color: BACKGROUND_COLOR };
74
+
75
+ slide.addText(preparedSlide.assertionTitle ?? '', {
76
+ x: 0.4,
77
+ y: 0.3,
78
+ w: 12,
79
+ h: 0.8,
80
+ fontFace: FONT_FACE,
81
+ fontSize: TITLE_FONT_SIZE,
82
+ bold: true,
83
+ color: TEXT_COLOR,
84
+ align: 'left',
85
+ margin: 0
86
+ });
87
+
88
+ slide.addText(getBodyText(preparedSlide), {
89
+ shape: pptx.ShapeType.rect,
90
+ x: 0.8,
91
+ y: 1.8,
92
+ w: 5.5,
93
+ h: 1.2,
94
+ fontFace: FONT_FACE,
95
+ fontSize: BODY_FONT_SIZE,
96
+ color: TEXT_COLOR,
97
+ fill: { color: BODY_FILL_COLOR },
98
+ line: { color: BODY_LINE_COLOR },
99
+ valign: 'mid',
100
+ margin: 0.12
101
+ });
102
+ }
103
+
104
+ await mkdir(path.dirname(outputPath), { recursive: true });
105
+ await pptx.writeFile({ fileName: outputPath });
106
+
107
+ return { outputPath, validationIssues };
108
+ }
@@ -0,0 +1,86 @@
1
+ const MAX_OBJECTS_PER_SLIDE = 6;
2
+ const MAX_ASSERTION_TITLE_LENGTH = 90;
3
+ const MIN_FONT_SIZE = 16;
4
+ const MIN_CONTRAST_RATIO = 4.5;
5
+
6
+ function parseHexColor(color) {
7
+ if (typeof color !== 'string') {
8
+ return null;
9
+ }
10
+
11
+ const normalized = color.trim().replace(/^#/, '');
12
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
13
+ return null;
14
+ }
15
+
16
+ return {
17
+ r: parseInt(normalized.slice(0, 2), 16),
18
+ g: parseInt(normalized.slice(2, 4), 16),
19
+ b: parseInt(normalized.slice(4, 6), 16)
20
+ };
21
+ }
22
+
23
+ function channelToLinear(channel) {
24
+ const normalized = channel / 255;
25
+ return normalized <= 0.04045
26
+ ? normalized / 12.92
27
+ : ((normalized + 0.055) / 1.055) ** 2.4;
28
+ }
29
+
30
+ function contrastRatio(foreground, background) {
31
+ const fg = parseHexColor(foreground);
32
+ const bg = parseHexColor(background);
33
+
34
+ if (!fg || !bg) {
35
+ return null;
36
+ }
37
+
38
+ const fgLuminance = 0.2126 * channelToLinear(fg.r)
39
+ + 0.7152 * channelToLinear(fg.g)
40
+ + 0.0722 * channelToLinear(fg.b);
41
+ const bgLuminance = 0.2126 * channelToLinear(bg.r)
42
+ + 0.7152 * channelToLinear(bg.g)
43
+ + 0.0722 * channelToLinear(bg.b);
44
+
45
+ const lighter = Math.max(fgLuminance, bgLuminance);
46
+ const darker = Math.min(fgLuminance, bgLuminance);
47
+ return (lighter + 0.05) / (darker + 0.05);
48
+ }
49
+
50
+ export function validateSlide(slide) {
51
+ const issues = [];
52
+ const elementCount = Array.isArray(slide?.elements) ? slide.elements.length : 0;
53
+ const assertionTitle = String(slide?.assertionTitle ?? '').trim();
54
+
55
+ // Task 4 scope only: this validator checks metadata-level count, title, fit, and simple contrast hints.
56
+ // Broader rendering/layout integration is deferred to later generation tasks.
57
+
58
+ if (elementCount + 1 > MAX_OBJECTS_PER_SLIDE) {
59
+ issues.push('Slide has too many objects for the didactic layout.');
60
+ }
61
+
62
+ if (assertionTitle.length > MAX_ASSERTION_TITLE_LENGTH) {
63
+ issues.push('Assertion title is too long for the didactic layout.');
64
+ }
65
+
66
+ for (const element of Array.isArray(slide?.elements) ? slide.elements : []) {
67
+ if (typeof element?.fontSize === 'number' && element.fontSize < MIN_FONT_SIZE) {
68
+ issues.push('Text box font size is below the didactic minimum.');
69
+ }
70
+
71
+ if (element?.overflow === true) {
72
+ issues.push('Text box is marked as overflowing.');
73
+ }
74
+
75
+ if (element?.truncated === true) {
76
+ issues.push('Text box content was truncated to fit the didactic layout.');
77
+ }
78
+
79
+ const ratio = contrastRatio(element?.color, element?.backgroundColor);
80
+ if (typeof ratio === 'number' && ratio < MIN_CONTRAST_RATIO) {
81
+ issues.push('Text contrast is below 4.5:1 for the didactic layout.');
82
+ }
83
+ }
84
+
85
+ return issues;
86
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "fontFace": "Arial",
3
+ "titleFontSize": 36,
4
+ "bodyFontSize": 24,
5
+ "minFontSize": 16,
6
+ "colors": {
7
+ "background": "F7F7F7",
8
+ "text": "1F1F1F",
9
+ "primary": "2F4F6B",
10
+ "secondary": "4E5F70",
11
+ "accent": "9F4E18",
12
+ "surface": "DDE6ED",
13
+ "surfaceLine": "355C7D"
14
+ },
15
+ "grid": {
16
+ "columns": 4,
17
+ "rows": 3,
18
+ "margin": 0.4
19
+ }
20
+ }