chore: initialize math-tasks project

This commit is contained in:
Oleg Proskurin 2026-02-17 17:47:10 +07:00
commit f7bb740d87
10 changed files with 3775 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
output/
.env

131
CLAUDE.md Normal file
View File

@ -0,0 +1,131 @@
# Math Tasks Generator
Printable math worksheet generator (A4 PDF) for children aged 79. Claude Code-driven workflow: user describes a task idea → Claude generates JSON config → creates HTML pages with Tailwind CSS → converts to PDF via Puppeteer.
**No template engine.** Claude Code generates fresh HTML pages directly from JSON task configs each time.
## Commands
```bash
npm run build:css # Build Tailwind CSS (minified)
npm run build:css:watch # Watch mode for CSS
npm run preview # Serve HTML at localhost:3000
npm run dev # CSS watch + preview server (concurrent)
npm run pdf -- <file> # Convert HTML file to PDF
```
Generate problems from a task config:
```bash
node src/scripts/generate-problems.mjs tasks/<task>.json
```
Generate images via Banatie API:
```bash
node src/scripts/banatie.mjs --type background --prompt "forest theme" --output assets/backgrounds/forest.png
node src/scripts/banatie.mjs --type icon --prompt "golden star" --output assets/icons/stars/star1.png
```
## Directory Structure
```
src/
styles/main.css — Tailwind source with A4/print styles
scripts/
generate-pdf.mjs — HTML → PDF via Puppeteer
generate-problems.mjs — JSON task → concrete problem list
banatie.mjs — Banatie API client for image generation
tasks/ — JSON task definition files
assets/
backgrounds/ — large background images per theme (~1200x1700px)
icons/ — icon sets in subfolders (128x128px transparent PNG)
output/
html/ — generated HTML (gitignored)
pdf/ — generated PDFs (gitignored)
css/ — built Tailwind CSS (gitignored)
```
## JSON Task Format
Each task is a JSON file in `tasks/` with this structure:
```json
{
"id": "multiply-2-3",
"title": "Умножение на 2 и 3",
"description": "Worksheet for practicing multiplication by 2 and 3",
"template": "{a} × {b} = ___",
"variables": {
"a": { "type": "range", "min": 1, "max": 10 },
"b": { "type": "set", "values": [2, 3] }
},
"problemCount": 20,
"layout": {
"columns": 2,
"problemsPerPage": 20
},
"labels": {
"title": "Умножение",
"subtitle": "Реши примеры",
"name": "Имя: _______________",
"date": "Дата: _______________"
},
"theme": {
"background": "assets/backgrounds/forest.png",
"icons": "assets/icons/stars/",
"iconReward": 1
}
}
```
### Fields
- **template** — problem template with `{variable}` placeholders
- **variables** — each variable is either `range` (min/max) or `set` (explicit values)
- **problemCount** — how many problems to generate
- **layout.columns** — 1 or 2 column layout
- **layout.problemsPerPage** — max problems per A4 page
- **labels** — all visible text (no hardcoded language)
- **theme.background** — path to background image
- **theme.icons** — path to icon directory (for collectible rewards)
- **theme.iconReward** — show an icon every N problems solved
## HTML Generation Guidelines
When generating HTML worksheets:
- **Page size:** A4 = 210mm × 297mm. Use the `.page-a4` class.
- **CSS:** Link to `../css/styles.css` (relative from `output/html/`)
- **Page breaks:** Use `break-after: page` between pages. Each `.page-a4` is one printed page.
- **Background images:** Use absolute paths or paths relative to HTML location. Apply via `.page-background` as a full-page positioned image.
- **Icons:** Inline small 128×128 images next to problems or as rewards.
- **Fonts:** Use system fonts or Google Fonts loaded via `<link>`.
- **Print-friendly:** Avoid shadows, gradients that don't print well. Test with `npm run pdf`.
- **Images in PDF:** Use local file paths (not URLs). Puppeteer resolves `file://` protocol.
- **Embed images** as base64 data URIs when possible for reliable PDF rendering.
## Banatie API
REST API for generating images.
- **Backgrounds:** ~1200×1700px, themed illustrations (forest, space, ocean, etc.)
- **Icons:** 128×128px, transparent PNG, simple collectible items (stars, gems, animals)
Configuration is in `src/scripts/banatie.mjs`. Set the `BANATIE_API_KEY` environment variable for authentication.
## PDF Generation
Puppeteer settings for A4 worksheets:
- Format: A4
- `printBackground: true` (required for background images)
- Margins: zero (CSS handles margins)
- `preferCSSPageSize: true`
## Workflow
1. User describes the math task idea to Claude
2. Claude creates/updates a JSON config in `tasks/`
3. Claude runs `node src/scripts/generate-problems.mjs tasks/<task>.json` to get concrete problems
4. Claude generates HTML file(s) in `output/html/` using the problems and Tailwind classes
5. Run `npm run build:css` to compile CSS
6. Run `npm run pdf -- output/html/<file>.html` to create PDF
7. Preview with `npm run preview` at localhost:3000

3258
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "math-tasks",
"version": "1.0.0",
"description": "Проект для генерации заданий по математике для детей 79 лет.",
"type": "module",
"main": "index.js",
"scripts": {
"build:css": "npx @tailwindcss/cli -i src/styles/main.css -o output/css/styles.css --minify",
"build:css:watch": "npx @tailwindcss/cli -i src/styles/main.css -o output/css/styles.css --watch",
"preview": "npx serve output/html --cors -l 3000",
"pdf": "node src/scripts/generate-pdf.mjs",
"dev": "concurrently \"npm run build:css:watch\" \"npm run preview\""
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"puppeteer": "^24.37.3"
},
"devDependencies": {
"@tailwindcss/cli": "^4.1.18",
"concurrently": "^9.2.1",
"serve": "^14.2.5"
}
}

66
src/scripts/banatie.mjs Normal file
View File

@ -0,0 +1,66 @@
import { writeFileSync, mkdirSync } from 'fs';
import { resolve, dirname } from 'path';
const API_ENDPOINT = process.env.BANATIE_API_URL || 'https://api.banatie.com/v1/generate';
const API_KEY = process.env.BANATIE_API_KEY || '';
const PRESETS = {
background: { width: 1200, height: 1700, format: 'png' },
icon: { width: 128, height: 128, format: 'png', transparent: true },
};
export async function generateImage({ type = 'icon', prompt, output }) {
if (!API_KEY) {
console.error('BANATIE_API_KEY environment variable is not set');
process.exit(1);
}
const preset = PRESETS[type];
if (!preset) {
console.error(`Unknown type: ${type}. Use "background" or "icon".`);
process.exit(1);
}
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`,
},
body: JSON.stringify({
prompt,
...preset,
}),
});
if (!response.ok) {
const body = await response.text();
console.error(`API error ${response.status}: ${body}`);
process.exit(1);
}
const buffer = Buffer.from(await response.arrayBuffer());
const outputPath = resolve(output);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, buffer);
console.log(`Image saved: ${outputPath}`);
return outputPath;
}
function parseArgs(args) {
const result = {};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--type') result.type = args[++i];
else if (args[i] === '--prompt') result.prompt = args[++i];
else if (args[i] === '--output') result.output = args[++i];
}
return result;
}
const args = parseArgs(process.argv.slice(2));
if (args.prompt && args.output) {
generateImage(args);
} else if (process.argv.length > 2) {
console.error('Usage: node banatie.mjs --type <background|icon> --prompt "<description>" --output <path>');
process.exit(1);
}

View File

@ -0,0 +1,44 @@
import puppeteer from 'puppeteer';
import { resolve, basename } from 'path';
import { existsSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
const OUTPUT_DIR = resolve(fileURLToPath(import.meta.url), '../../../output/pdf');
async function generatePdf(htmlPath) {
const absolutePath = resolve(htmlPath);
if (!existsSync(absolutePath)) {
console.error(`File not found: ${absolutePath}`);
process.exit(1);
}
mkdirSync(OUTPUT_DIR, { recursive: true });
const pdfName = basename(absolutePath, '.html') + '.pdf';
const pdfPath = resolve(OUTPUT_DIR, pdfName);
const browser = await puppeteer.launch({ headless: true });
const page = await browser.newPage();
await page.goto(`file://${absolutePath}`, { waitUntil: 'networkidle0' });
await page.pdf({
path: pdfPath,
format: 'A4',
printBackground: true,
margin: { top: 0, right: 0, bottom: 0, left: 0 },
preferCSSPageSize: true,
});
await browser.close();
console.log(`PDF generated: ${pdfPath}`);
return pdfPath;
}
const htmlFile = process.argv[2];
if (!htmlFile) {
console.error('Usage: node generate-pdf.mjs <html-file>');
process.exit(1);
}
generatePdf(htmlFile);

View File

@ -0,0 +1,87 @@
import { readFileSync, readdirSync } from 'fs';
import { resolve } from 'path';
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)];
}
function generateValue(varDef) {
if (varDef.type === 'range') {
return randomInt(varDef.min, varDef.max);
}
if (varDef.type === 'set') {
return pickRandom(varDef.values);
}
throw new Error(`Unknown variable type: ${varDef.type}`);
}
function formatProblem(template, values) {
return template.replace(/\{(\w+)\}/g, (_, key) => {
if (key in values) return values[key];
return `{${key}}`;
});
}
function getIcons(iconDir) {
if (!iconDir) return [];
try {
const resolved = resolve(iconDir);
return readdirSync(resolved)
.filter(f => /\.(png|jpg|jpeg|svg|webp)$/i.test(f))
.map(f => resolve(resolved, f));
} catch {
return [];
}
}
export function generateProblems(taskConfig) {
const { template, variables, problemCount = 20, theme = {} } = taskConfig;
const icons = getIcons(theme.icons);
const backgroundPath = theme.background ? resolve(theme.background) : null;
const iconReward = theme.iconReward || 1;
const problems = [];
const seen = new Set();
let attempts = 0;
const maxAttempts = problemCount * 10;
while (problems.length < problemCount && attempts < maxAttempts) {
attempts++;
const values = {};
for (const [name, def] of Object.entries(variables)) {
values[name] = generateValue(def);
}
const key = Object.values(values).join(',');
if (seen.has(key)) continue;
seen.add(key);
const text = formatProblem(template, values);
const problemIndex = problems.length;
const icon = icons.length > 0 && (problemIndex + 1) % iconReward === 0
? pickRandom(icons)
: null;
problems.push({ text, values, icon });
}
return {
id: taskConfig.id,
title: taskConfig.title,
labels: taskConfig.labels || {},
layout: taskConfig.layout || { columns: 2, problemsPerPage: 20 },
background: backgroundPath,
problems,
};
}
const taskFile = process.argv[2];
if (taskFile) {
const config = JSON.parse(readFileSync(resolve(taskFile), 'utf-8'));
const result = generateProblems(config);
console.log(JSON.stringify(result, null, 2));
}

78
src/styles/main.css Normal file
View File

@ -0,0 +1,78 @@
@import "tailwindcss";
@page {
size: A4;
margin: 0;
}
@utility page-a4 {
width: 210mm;
height: 297mm;
position: relative;
overflow: hidden;
margin: 0 auto;
background: white;
box-sizing: border-box;
}
@utility page-background {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
z-index: 0;
pointer-events: none;
}
@utility page-content {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
padding: 15mm;
box-sizing: border-box;
}
@utility problem-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
}
@utility problem-icon {
width: 32px;
height: 32px;
flex-shrink: 0;
}
@utility problem-text {
font-size: 1.25rem;
font-weight: 500;
}
@utility answer-line {
display: inline-block;
width: 3rem;
border-bottom: 2px solid currentColor;
text-align: center;
margin-left: 0.25rem;
}
@media print {
body {
margin: 0;
padding: 0;
}
.page-a4 {
break-after: page;
page-break-after: always;
}
.page-a4:last-child {
break-after: auto;
page-break-after: auto;
}
}

57
tasks.md Normal file
View File

@ -0,0 +1,57 @@
# Math Tasks Generator — Setup Steps
## Step 1: Initialize Node.js project
- [x] `npm init -y` with `"type": "module"`
- [x] Create `.gitignore` (node_modules/, output/)
## Step 2: Install dependencies
- [x] `puppeteer` — HTML to PDF conversion (headless Chrome)
- [x] `@tailwindcss/cli` — Tailwind CSS v4 build tool (dev)
- [x] `serve` — static file server for HTML preview (dev)
- [x] `concurrently` — run CSS watch + preview server together (dev)
## Step 3: Create directory structure
- [x] `src/styles/main.css` — Tailwind source with A4/print styles
- [x] `src/scripts/generate-pdf.mjs` — HTML → PDF via Puppeteer
- [x] `src/scripts/generate-problems.mjs` — JSON task → concrete problem list
- [x] `src/scripts/banatie.mjs` — Banatie API client for image generation
- [x] `tasks/` — JSON task definition files
- [x] `assets/backgrounds/` — large background images per theme
- [x] `assets/icons/` — icon sets in subfolders
- [x] `output/html/` — generated HTML (gitignored)
- [x] `output/pdf/` — generated PDFs (gitignored)
- [x] `output/css/` — built Tailwind CSS (gitignored)
## Step 4: Configure Tailwind CSS v4
- [x] `src/styles/main.css` with `@import "tailwindcss"`, `@page` rules for A4
- [x] Custom utility classes: `.page-a4`, `.page-background`, `.page-content`, `.problem-row`, `.problem-icon`, `.problem-text`, `.answer-line`
- [x] Build script: `npx @tailwindcss/cli -i src/styles/main.css -o output/css/styles.css`
## Step 5: Create PDF generation script
- [x] `src/scripts/generate-pdf.mjs` using Puppeteer
- [x] Accepts HTML file path, outputs PDF to `output/pdf/`
- [x] Settings: A4 format, `printBackground: true`, zero margins, `preferCSSPageSize: true`
## Step 6: Create problem generation utility
- [x] `src/scripts/generate-problems.mjs` — reads JSON task, generates randomized math problems
- [x] Exports function and works as CLI
- [x] Handles variable substitution, deduplication
## Step 7: Create example JSON task
- [x] `tasks/example-multiply.json` with sample multiplication task config
- [x] Demonstrates full JSON schema: template, variables, labels, theme refs
## Step 8: Set up npm scripts
- [x] `build:css` — build Tailwind CSS (minified)
- [x] `build:css:watch` — watch mode
- [x] `preview` — serve HTML at localhost:3000
- [x] `pdf` — convert HTML to PDF
- [x] `dev` — CSS watch + preview (concurrent)
## Step 9: Initialize git repo
- [ ] `git init`, initial commit
## Step 10: Configure Banatie API integration
- [x] `src/scripts/banatie.mjs` — API client for image generation
- [x] Documented in CLAUDE.md
- [x] Standard sizes: backgrounds 1200×1700px, icons 128×128px transparent PNG

View File

@ -0,0 +1,26 @@
{
"id": "multiply-2-3",
"title": "Умножение на 2 и 3",
"description": "Worksheet for practicing multiplication by 2 and 3",
"template": "{a} × {b} = ___",
"variables": {
"a": { "type": "range", "min": 1, "max": 10 },
"b": { "type": "set", "values": [2, 3] }
},
"problemCount": 20,
"layout": {
"columns": 2,
"problemsPerPage": 20
},
"labels": {
"title": "Умножение",
"subtitle": "Реши примеры",
"name": "Имя: _______________",
"date": "Дата: _______________"
},
"theme": {
"background": null,
"icons": null,
"iconReward": 5
}
}