#!/usr/bin/env python3
"""
Shared helpers for 2nd Grade Math curriculum generators.
Usage in generate_weekN.py:
from math_helpers import *
def monday():
s1 = '<p class="note">...</p>' + b('2em')
s2 = sec(1, "Title", s1)
return page(1, "Monday", "Topic", s1 + s2 + s3 + ...)
days = [
("Monday", "Topic", monday),
("Tuesday", "Topic", tuesday),
...
]
generate(WEEK, days)
"""
import os
import glob
import math
from weasyprint import HTML
# ═══════════════════════════════════════════════════
# Color Palette
# ═══════════════════════════════════════════════════
PRIMARY = "#3f51b5"
PRIMARY_BG = "#e8eaf6"
PRIMARY_BORDER = "#c5cae9"
RED = "#ef5350"
BLUE = "#42a5f5"
GREEN = "#66bb6a"
YELLOW = "#ffca28"
ORANGE = "#ffa726"
PURPLE = "#ab47bc"
PINK = "#e91e63"
TEAL = "#26c6da"
GOLD = "#ffc107"
# ═══════════════════════════════════════════════════
# Base CSS — union of all week-specific styles
# ═══════════════════════════════════════════════════
CSS = r"""
@page { size: 8.5in 11in; margin: 0.5in; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Helvetica Neue', Arial, sans-serif; color: #222; font-size: 14px; line-height: 1.7; }
page { width: 7.5in; }
page + page { page-break-before: always; }
/* ── Header ── */
.head { display: flex; align-items: baseline; gap: 10px; padding-bottom: 4px; border-bottom: 3px solid #3f51b5; margin-bottom: 6px; }
.head .week { font-size: 18px; font-weight: 700; color: #3f51b5; letter-spacing: -0.5px; }
.head .day { font-size: 14px; font-weight: 600; color: #3f51b5; background: #e8eaf6; padding: 2px 10px; border-radius: 3px; }
.head .topic { font-size: 12px; color: #888; margin-left: auto; font-style: italic; }
/* ── Section ── */
.sec { margin-bottom: 8px; }
.sec.pb { break-after: avoid; page-break-after: avoid; }
.sec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.sec-n { font-size: 9px; font-weight: 700; color: #fff; background: #3f51b5; width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; flex-shrink: 0; }
.sec-t { font-size: 13px; font-weight: 600; color: #333; }
.note { font-size: 10.5px; color: #999; font-style: italic; margin-bottom: 4px; }
/* ── Math grid ── */
.mgrid { display: grid; gap: 8px 0; }
.mgrid.c2 { grid-template-columns: 1fr 1fr; }
.mgrid.c3 { grid-template-columns: 1fr 1fr 1fr; }
.mgrid.c4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
/* ── Blanks ── */
.b { display: inline-block; border-bottom: 1.5px solid #333; min-width: 8em; text-align: center; }
.bw { display: inline-block; border-bottom: 1.5px solid #333; min-width: 18em; text-align: center; }
.bl { display: inline-block; border-bottom: 1.5px solid #333; min-width: 22em; text-align: center; }
/* ── Boxes ── */
.abox { border: 1.5px solid #e0e0e0; border-radius: 4px; padding: 8px 10px; margin-bottom: 5px; }
.dbox { border: 1.5px dashed #ccc; border-radius: 4px; min-height: 80px; background: #fafafa; margin-bottom: 8px; }
.draw-box { border: 1.5px dashed #bbb; border-radius: 6px; min-height: 80px; background: #fafafa; margin: 8px 0; }
.starbox { border: 2px solid #ffc107; border-radius: 6px; padding: 10px 12px; margin-bottom: 8px; background: #fffde7; }
.selfcheck { border: 1.5px solid #4caf50; border-radius: 4px; padding: 6px 10px; margin-bottom: 5px; background: #f1f8e9; }
/* ── Table ── */
.tbl { width: 100%; border-collapse: collapse; margin-bottom: 8px; font-size: 12px; }
.tbl th { background: #f5f5f5; color: #333; padding: 5px 8px; text-align: center; border-bottom: 2px solid #3f51b5; font-weight: 600; font-size: 11.5px; }
.tbl td { padding: 5px 8px; border-bottom: 1px solid #eee; text-align: center; }
/* ── Word problem ── */
.wp { border-left: 3px solid #3f51b5; padding-left: 8px; margin-bottom: 8px; }
/* ── Number chart ── */
.chart { display: grid; grid-template-columns: repeat(10, 1fr); font-family: 'Courier New', monospace; font-size: 11px; text-align: center; line-height: 1; }
.chart .c { border: 1px solid #ddd; padding: 3px 1px; }
.chart .c.hl { background: #f5f5f5; }
.chart .c.miss { border: 1.5px dashed #3f51b5; min-height: 16px; background: #fafafa; }
.chart .c.hdr { background: #3f51b5; color: #fff; font-weight: 600; border-color: #3f51b5; }
/* ── Ordering boxes ── */
.orow { display: flex; gap: 3px; align-items: center; margin-bottom: 3px; font-family: 'Courier New', monospace; font-size: 14px; }
.obox { border: 1.5px solid #333; min-width: 2.2em; min-height: 1.4em; text-align: center; display: inline-block; }
/* ── Number bonds ── */
.bond { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; font-family: 'Courier New', monospace; font-size: 14px; }
.bcirc { border: 2px solid #3f51b5; border-radius: 50%; width: 38px; height: 38px; display: flex; align-items: center; justify-content: center; }
/* ── Skip counting ── */
.skrow { font-family: 'Courier New', monospace; font-size: 16px; display: flex; gap: 3px; align-items: center; flex-wrap: wrap; margin-bottom: 4px; }
.skn { display: inline-block; min-width: 2.5em; text-align: center; }
.skip-num { display: inline-block; width: 34px; height: 34px; line-height: 34px; text-align: center; border: 1.5px solid #3f51b5; border-radius: 50%; font-size: 14px; font-weight: 600; margin: 2px; }
.skip-blank { display: inline-block; width: 34px; height: 34px; line-height: 34px; text-align: center; border: 1.5px dashed #333; border-radius: 50%; font-size: 14px; margin: 2px; background: #fafafa; }
/* ── Star self-check ── */
.star-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
.stars { letter-spacing: 2px; color: #ccc; font-size: 16px; }
/* ── Ruler ── */
.ruler-container { position: relative; margin: 6px 0; }
.ruler { border: 1px solid #333; border-top: none; height: 28px; position: relative; background: #fffef0; }
.ruler-label { font-size: 9px; color: #666; text-align: center; margin-top: 2px; }
.ruler-marks { position: relative; height: 28px; }
.ruler-mark { position: absolute; bottom: 0; width: 1px; background: #333; }
.ruler-mark.major { height: 16px; }
.ruler-mark.mid { height: 12px; }
.ruler-mark.minor { height: 8px; }
.ruler-num { position: absolute; font-size: 9px; color: #333; transform: translateX(-50%); }
.measure-line { border-bottom: 2px solid #333; margin: 8px 0; position: relative; }
.obj-label { font-size: 11px; font-weight: 600; color: #333; display: inline-block; margin-bottom: 2px; }
.comparison { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.comparison .val { font-family: 'Courier New', monospace; font-size: 13px; font-weight: 600; }
/* ── Multiplication ── */
.group-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 4px 0; }
.group-circle { width: 12px; height: 12px; display: inline-block; vertical-align: middle; }
.array-svg { width: 120px; height: auto; margin: 4px 0; }
.eqgroup-box { border: 2px solid #e0e0e0; border-radius: 8px; padding: 6px; display: inline-flex; gap: 3px; align-items: center; margin: 4px; min-width: 60px; justify-content: center; background: #fafafa; }
.mult-sign { font-size: 16px; font-weight: 700; color: #3f51b5; }
.div-sign { font-size: 16px; font-weight: 700; color: #3f51b5; }
.highlight { color: #3f51b5; font-weight: 700; }
.times-table { border: 2px solid #3f51b5; border-radius: 8px; padding: 8px; margin: 4px 0; background: #f5f5f5; }
.times-table h4 { font-size: 14px; color: #3f51b5; margin-bottom: 4px; }
.times-row { display: flex; align-items: center; gap: 6px; padding: 2px 0; font-size: 13px; }
.times-row .eq { color: #999; }
.chart-cell { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 28px; font-size: 11px; text-align: center; }
.chart-cell.header { font-weight: 700; background: #3f51b5; color: #fff; border-radius: 3px; }
.chart-cell.data { border: 1px solid #e0e0e0; }
.chart-cell.data.filled { background: #e8eaf6; font-weight: 600; color: #3f51b5; }
.chart-cell.data.empty { background: #fafafa; color: #ccc; }
/* ── Fluency grids ── */
.fluency-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 4px; }
.fluency-item { border: 1.5px solid #e0e0e0; border-radius: 4px; padding: 4px; text-align: center; font-size: 12px; }
.fluency-item .answer { margin-top: 2px; }
/* ── Scavenger / certificates ── */
.scavenger-card { border: 2px solid #ff9800; border-radius: 8px; padding: 8px 10px; margin-bottom: 6px; background: #fff3e0; }
.scavenger-card .clue { font-size: 11px; color: #e65100; font-weight: 600; }
.scavenger-card .answer-line { margin-top: 4px; }
.cert-border { border: 4px double #3f51b5; border-radius: 12px; padding: 20px; text-align: center; background: linear-gradient(to bottom, #fffde7, #fff); }
.cert-title { font-size: 24px; font-weight: 700; color: #3f51b5; margin-bottom: 4px; }
.cert-subtitle { font-size: 14px; color: #555; margin-bottom: 8px; }
.cert-name { font-size: 18px; border-bottom: 2px solid #333; display: inline-block; min-width: 200px; margin: 8px 0; }
.cert-body { font-size: 12px; color: #555; margin: 8px 0; line-height: 1.6; }
.mixed-problem { border: 1.5px solid #e0e0e0; border-radius: 4px; padding: 6px 10px; margin-bottom: 4px; }
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; }
.badge { border: 2px solid; border-radius: 6px; padding: 4px 8px; font-size: 10px; font-weight: 600; }
.cat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.cat-item { border: 1.5px solid #e0e0e0; border-radius: 4px; padding: 5px; text-align: center; font-size: 11px; }
.cat-svg { width: 100px; height: 100px; }
/* ── Work line ── */
.work-line { border-bottom: 1px solid #ddd; height: 24px; margin-bottom: 3px; }
"""
# Teacher guide specific CSS additions
TEACHER_CSS = r"""
""" + CSS + r"""
/* Teacher guide overrides */
.tg-head { display: flex; align-items: baseline; gap: 10px; padding-bottom: 4px; border-bottom: 3px solid #4caf50; margin-bottom: 6px; }
.tg-head .week { font-size: 18px; font-weight: 700; color: #4caf50; letter-spacing: -0.5px; }
.tg-head .day { font-size: 13px; font-weight: 600; color: #4caf50; background: #e8f5e9; padding: 2px 10px; border-radius: 3px; }
.tg-head .topic { font-size: 11.5px; color: #888; margin-left: auto; font-style: italic; }
.tg-sec { margin-bottom: 5px; }
.tg-sec-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
.tg-sec-n { font-size: 10px; font-weight: 700; color: #fff; background: #4caf50; width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; border-radius: 3px; flex-shrink: 0; }
.tg-sec-t { font-size: 12.5px; font-weight: 600; color: #333; }
.tg-answer { color: #4caf50; font-weight: 700; font-size: 13px; }
.tg-label { font-size: 10px; color: #999; }
.tg-note { font-size: 10.5px; color: #999; font-style: italic; margin-bottom: 4px; }
.tg-mgrid { display: grid; gap: 3px 0; }
.tg-mgrid.c2 { grid-template-columns: 1fr 1fr; }
.tg-mgrid.c3 { grid-template-columns: 1fr 1fr 1fr; }
.tg-mgrid.c4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
.tg-mgrid.c5 { grid-template-columns: repeat(5, 1fr); }
.tg-problem { font-family: 'Courier New', monospace; font-size: 12px; margin-bottom: 2px; }
.tg-problem .ans { color: #4caf50; font-weight: 700; }
.tg-box { border: 1px solid #c8e6c9; border-radius: 4px; padding: 4px 8px; margin-bottom: 3px; background: #f9fbe7; }
.tg-section-title { font-size: 14px; font-weight: 700; color: #4caf50; margin-bottom: 4px; margin-top: 6px; }
"""
# ═══════════════════════════════════════════════════
# Core HTML Helpers
# ═══════════════════════════════════════════════════
def page(week_num, day, topic, body):
"""Build a complete HTML page for a student worksheet."""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>{CSS}</style></head><body>
<page>
{hdr(week_num, day, topic)}
{body}
</page>
</body></html>"""
def page_multi(week_num, day, topic, *page_bodies):
"""Build multi-page HTML with multiple <page> blocks."""
pages = ""
for i, body in enumerate(page_bodies):
if i == 0:
pages = f"<page>\n{hdr(week_num, day, topic)}\n\n{body}\n\n</page>"
else:
pages += f"\n<page>\n\n{body}\n\n</page>"
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>{CSS}</style></head><body>
{pages}
</body></html>"""
def hdr(week_num, day, topic):
"""Page header with week number, day, and topic."""
return (f'<div class="head">'
f'<span class="week">Week {week_num}</span>'
f'<span class="day">{day}</span>'
f'<span class="topic">{topic}</span>'
f'</div>')
def sec(n, title, body, pb=False):
"""Numbered section. Set pb=True for page break before."""
cls = 'sec pb' if pb else 'sec'
return (f'<div class="{cls}">'
f'<div class="sec-head"><span class="sec-n">{n}</span><span class="sec-t">{title}</span></div>'
f'{body}</div>')
def b(w="3em"):
"""Short answer blank — larger defaults for 7-year-old handwriting."""
return f'<span class="b" style="min-width:{w}"></span>'
def bw(w="10em"):
"""Wide answer blank."""
return f'<span class="bw" style="min-width:{w}"></span>'
def bl(w="14em"):
"""Long answer blank (sentences/paragraphs)."""
return f'<span class="bl" style="min-width:{w}"></span>'
def generate(week_dir, days):
"""
Generate student PDFs for all days.
days: list of (day_name, topic, html_func)
or (day_name, topic, html_func, teacher_func)
Example:
days = [
("Monday", "Counting", monday, monday_tg),
("Tuesday", "Place Value", tuesday, tuesday_tg),
...
]
"""
os.makedirs(week_dir, exist_ok=True)
for f in glob.glob(os.path.join(week_dir, "*.pdf")):
os.remove(f)
for day_name, topic, html_func, *rest in days:
html = html_func()
filename = f"W{day_name.capitalize()[0]}{day_name[1:]}_{'_'.join(topic.split())}"
# Sanitize filename
filename = "".join(c for c in filename if c.isalnum() or c in "_ -")
filename = filename.replace(" ", "_").replace("--", "_").replace("-_", "_")
path = os.path.join(week_dir, f"Week_{day_name[0]}{day_name[1:]}_{topic.replace(' ', '_')}.pdf")
HTML(string=html).write_pdf(path)
print(f"Created: {path}")
# Teacher guide
if rest:
teacher_func = rest[0]
tg_html = teacher_func()
tg_path = os.path.join(week_dir, f"Teacher_Guide_Week_{day_name[0]}{day_name[1:]}_{topic.replace(' ', '_')}.pdf")
HTML(string=tg_html).write_pdf(tg_path)
print(f"Created: {tg_path}")
def generate_simple(week_dir, days, filenames=None):
"""
Simple generate — uses provided filenames or auto-generates.
days: list of (day_name, topic, html_func)
filenames: optional list of (student_filename, teacher_filename) tuples
or just (filename,) if no teacher guide
"""
os.makedirs(week_dir, exist_ok=True)
for f in glob.glob(os.path.join(week_dir, "*.pdf")):
os.remove(f)
for i, (day_name, topic, func, *rest) in enumerate(days):
html = func()
if filenames and i < len(filenames):
fname = filenames[i][0]
else:
fname = f"Week_{day_name[0]}{day_name[1:]}_{topic.replace(' ', '_')}"
path = os.path.join(week_dir, f"{fname}.pdf")
HTML(string=html).write_pdf(path)
print(f"Created: {path}")
# Teacher guide
if rest:
tg_html = rest[0]()
if filenames and i < len(filenames) and len(filenames[i]) > 1:
tg_fname = filenames[i][1]
else:
tg_fname = f"Teacher_Guide_{fname}"
tg_path = os.path.join(week_dir, f"{tg_fname}.pdf")
HTML(string=tg_html).write_pdf(tg_path)
print(f"Created: {tg_path}")
# ═══════════════════════════════════════════════════
# SVG Generators
# ═══════════════════════════════════════════════════
def circle_svg(color, r=6, stroke="#333", stroke_width=1.5):
"""Generate a small circle SVG for equal groups."""
return f'<svg viewBox="0 0 24 24" width="12" height="12"><circle cx="12" cy="12" r="{r}" fill="{color}" stroke="{stroke}" stroke-width="{stroke_width}"/></svg>'
def eq_group_svg(count, color=BLUE, label=""):
"""Generate an equal group box with circles."""
circles = ''.join([f'<span class="group-circle">{circle_svg(color)}</span>' for _ in range(count)])
return f'<div class="eqgroup-box">{circles}</div>'
def array_svg(rows, cols, color=BLUE, width="120"):
"""Generate an SVG array grid of dots."""
cell = 14
gap = 2
svg_w = cols * (cell + gap) + gap
svg_h = rows * (cell + gap) + gap
svg = f'<svg class="array-svg" viewBox="0 0 {svg_w} {svg_h}" style="width:{width}px; height:auto;" width="{width}" height="auto">\n'
for r in range(rows):
for c in range(cols):
x = gap + c * (cell + gap)
y = gap + r * (cell + gap)
svg += f'<circle cx="{x+cell/2}" cy="{y+cell/2}" r="{cell/2-1}" fill="{color}" stroke="#333" stroke-width="1"/>\n'
svg += '</svg>'
return svg
def shape_svg(shape_type, color=BLUE, size=40):
"""Generate a shape SVG."""
if shape_type == "square":
return f'<svg viewBox="0 0 40 40" width="{size}" height="{size}"><rect x="4" y="4" width="32" height="32" rx="2" fill="{color}" stroke="#333" stroke-width="1.5" opacity="0.6"/></svg>'
elif shape_type == "circle":
return f'<svg viewBox="0 0 40 40" width="{size}" height="{size}"><circle cx="20" cy="20" r="16" fill="{color}" stroke="#333" stroke-width="1.5" opacity="0.6"/></svg>'
elif shape_type == "triangle":
return f'<svg viewBox="0 0 40 40" width="{size}" height="{size}"><polygon points="20,4 36,36 4,36" fill="{color}" stroke="#333" stroke-width="1.5" opacity="0.6"/></svg>'
elif shape_type == "rectangle":
return f'<svg viewBox="0 0 50 30" width="{size+10}" height="{size}"><rect x="2" y="2" width="46" height="26" rx="2" fill="{color}" stroke="#333" stroke-width="1.5" opacity="0.6"/></svg>'
elif shape_type == "star":
pts = "20,2 25,14 38,14 27,22 31,36 20,27 9,36 13,22 2,14 15,14"
return f'<svg viewBox="0 0 40 40" width="{size}" height="{size}"><polygon points="{pts}" fill="{color}" stroke="#333" stroke-width="1.5" opacity="0.6"/></svg>'
return ''
def clock_svg(hour, minute, size=50):
"""Generate a simple analog clock SVG."""
h_angle = (hour % 12) * 30 + minute * 0.5
m_angle = minute * 6
def hand_coords(angle, length, cx=25, cy=25):
rad = math.radians(angle - 90)
return f"{cx},{cy} {cx + length * math.cos(rad)},{cy + length * math.sin(rad)}"
h_line = hand_coords(h_angle, 12)
m_line = hand_coords(m_angle, 18)
return f'''<svg viewBox="0 0 50 50" width="{size}" height="{size}">
<circle cx="25" cy="25" r="22" fill="#fff" stroke="#333" stroke-width="1.5"/>
<line x1="25" y1="3" x2="25" y2="7" stroke="#333" stroke-width="1.5"/>
<line x1="25" y1="43" x2="25" y2="47" stroke="#333" stroke-width="1.5"/>
<line x1="3" y1="25" x2="7" y2="25" stroke="#333" stroke-width="1.5"/>
<line x1="43" y1="25" x2="47" y2="25" stroke="#333" stroke-width="1.5"/>
<line {h_line} stroke="#333" stroke-width="2.5" stroke-linecap="round"/>
<line {m_line} stroke="#e53935" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="25" cy="25" r="2" fill="#333"/>
</svg>'''
def fraction_svg(numerator, denominator, color=BLUE, size=40):
"""Generate a fraction circle SVG (pie chart style)."""
angle_per = 360 / denominator
paths = ''
for i in range(numerator):
start = angle_per * i - 90
end = angle_per * (i + 1) - 90
x1 = 20 + 18 * math.cos(math.radians(start))
y1 = 20 + 18 * math.sin(math.radians(start))
x2 = 20 + 18 * math.cos(math.radians(end))
y2 = 20 + 18 * math.sin(math.radians(end))
large = 1 if angle_per > 180 else 0
paths += f'<path d="M 20 20 L {x1:.1f} {y1:.1f} A 18 18 0 {large} 1 {x2:.1f} {y2:.1f} Z" fill="{color}" stroke="#333" stroke-width="0.8" opacity="0.7"/>'
for i in range(denominator):
start = angle_per * i - 90
x1 = 20 + 18 * math.cos(math.radians(start))
y1 = 20 + 18 * math.sin(math.radians(start))
paths += f'<line x1="20" y1="20" x2="{x1:.1f}" y2="{y1:.1f}" stroke="#333" stroke-width="0.6"/>'
paths += f'<circle cx="20" cy="20" r="18" fill="none" stroke="#333" stroke-width="1"/>'
return f'<svg viewBox="0 0 40 40" width="{size}" height="{size}">{paths}</svg>'
def fraction_bar_svg(numerator, denominator, color=BLUE, width=100, height=20):
"""Generate a fraction bar SVG (rectangular bar divided into parts)."""
part_w = width / denominator
bars = ''
for i in range(denominator):
fill = color if i < numerator else '#f5f5f5'
bars += f'<rect x="{i * part_w}" y="0" width="{part_w}" height="{height}" fill="{fill}" stroke="#333" stroke-width="0.5"/>'
return f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}">{bars}</svg>'
def coin_svg(coin_type, size=20):
"""Generate a coin SVG."""
coins = {
'penny': ('#b77', 'P', '#795548'),
'nickel': ('#c0c0c0', 'N', '#9e9e9e'),
'dime': ('#e0e0e0', 'D', '#bdbdbd'),
'quarter': ('#90a4ae', 'Q', '#78909c'),
}
bg, label, stroke = coins.get(coin_type, ('#ccc', '?', '#999'))
return f'<svg viewBox="0 0 20 20" width="{size}" height="{size}"><circle cx="10" cy="10" r="9" fill="{bg}" stroke="{stroke}" stroke-width="1"/><text x="10" y="14" text-anchor="middle" font-size="8" font-weight="700" fill="#fff">{label}</text></svg>'
def grid_svg(rows, cols, cell=14, gap=1, filled_cells=None, color=BLUE):
"""Generate a square grid SVG for area/perimeter."""
total_w = cols * cell + (cols - 1) * gap
total_h = rows * cell + (rows - 1) * gap
cells = ''
for r in range(rows):
for c in range(cols):
x = c * (cell + gap)
y = r * (cell + gap)
fill = color if filled_cells and (r, c) in filled_cells else '#f5f5f5'
cells += f'<rect x="{x}" y="{y}" width="{cell}" height="{cell}" fill="{fill}" stroke="#333" stroke-width="0.5"/>'
return f'<svg viewBox="0 0 {total_w} {total_h}" width="{total_w + 2}" height="{total_h + 2}">{cells}</svg>'
def bar_graph_svg(data, colors, labels=None, width=200, height=100):
"""Generate a simple bar graph SVG."""
n = len(data)
bar_w = min(20, (width - (n + 1) * 6) // n)
gap = (width - n * bar_w) // (n + 1)
max_val = max(data) if data else 1
bars = ''
for i, (val, color) in enumerate(zip(data, colors)):
x = gap + i * (bar_w + gap)
bar_h = int(val / max_val * (height - 25))
y = height - 12 - bar_h
bars += f'<rect x="{x}" y="{y}" width="{bar_w}" height="{bar_h}" fill="{color}" stroke="#333" stroke-width="0.8" rx="2"/>'
bars += f'<text x="{x + bar_w//2}" y="{y - 2}" text-anchor="middle" font-size="7" fill="#333">{val}</text>'
if labels and i < len(labels):
bars += f'<text x="{x + bar_w//2}" y="{height - 2}" text-anchor="middle" font-size="6" fill="#666">{labels[i]}</text>'
return f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}">{bars}<line x1="0" y1="{height-10}" x2="{width}" y2="{height-10}" stroke="#333" stroke-width="1"/></svg>'
def star_svg(color=GOLD, size=16):
"""Generate a star SVG."""
pts = "8,1 10,6 16,6 11,10 13,16 8,12 3,16 5,10 0,6 6,6"
return f'<svg viewBox="0 0 16 16" width="{size}" height="{size}"><polygon points="{pts}" fill="{color}" stroke="#333" stroke-width="0.8"/></svg>'
def trophy_svg(size=32):
"""Generate a trophy SVG."""
return f'''<svg viewBox="0 0 32 32" width="{size}" height="{size}">
<polygon points="10,4 22,4 20,16 12,16" fill="#ffc107" stroke="#333" stroke-width="1"/>
<rect x="11" y="16" width="10" height="3" rx="1" fill="#ff9800" stroke="#333" stroke-width="0.8"/>
<rect x="13" y="19" width="6" height="3" fill="#8d6e63" stroke="#333" stroke-width="0.8"/>
<rect x="10" y="22" width="12" height="3" rx="1" fill="#795548" stroke="#333" stroke-width="0.8"/>
<circle cx="16" cy="10" r="2" fill="#fff" stroke="#333" stroke-width="0.5"/>
</svg>'''
def medal_svg(letter, color=BLUE, size=30):
"""Generate a medal SVG."""
return f'''<svg viewBox="0 0 30 30" width="{size}" height="{size}">
<polygon points="15,0 18,10 28,10 20,16 23,26 15,20 7,26 10,16 2,10 12,10" fill="{color}" stroke="#333" stroke-width="1.2"/>
<text x="15" y="18" text-anchor="middle" font-size="10" font-weight="700" fill="#fff">{letter}</text>
</svg>'''
def check_svg(size=14):
"""Generate a checkmark SVG."""
return f'<svg viewBox="0 0 16 16" width="{size}" height="{size}"><circle cx="8" cy="8" r="7" fill="#4caf50" stroke="#333" stroke-width="0.8"/><text x="8" y="12" text-anchor="middle" font-size="10" fill="#fff">✓</text></svg>'
# ═══════════════════════════════════════════════════
# Multiplication Helpers
# ═══════════════════════════════════════════════════
def times_table_html(factor, color=BLUE):
"""Generate a mini times table for a given factor."""
rows = []
for i in range(11):
rows.append(f'<div class="times-row"><span>{factor} <span class="mult-sign">×</span> {i}</span><span class="eq">=</span><span class="highlight">{factor * i}</span></div>')
return f'<div class="times-table" style="border-color:{color};"><h4 style="color:{color}">The {factor}s</h4>{"".join(rows)}</div>'
def skip_count_html(numbers, blanks_indices, color=BLUE):
"""Generate skip counting circles with blanks at specified indices."""
parts = []
for i, n in enumerate(numbers):
if i in blanks_indices:
parts.append('<span class="skip-blank"></span>')
else:
parts.append(f'<span class="skip-num" style="border-color:{color}; color:{color};">{n}</span>')
return ''.join(parts)
def mini_chart_html(facts, max_factor=5):
"""Generate a mini multiplication chart with some facts filled."""
html = '<table style="border-collapse:collapse; margin:4px 0;">\n'
html += '<tr><td class="chart-cell header">×</td>'
for c in range(max_factor + 1):
html += f'<td class="chart-cell header">{c}</td>'
html += '</tr>\n'
for r in range(max_factor + 1):
html += f'<tr><td class="chart-cell header">{r}</td>'
for c in range(max_factor + 1):
if (r, c) in facts:
html += f'<td class="chart-cell data filled">{r * c}</td>'
else:
html += '<td class="chart-cell data empty"> </td>'
html += '</tr>\n'
html += '</table>'
return html
# ═══════════════════════════════════════════════════
# Ruler Helpers
# ═══════════════════════════════════════════════════
def ruler_inches(length_inches=12):
"""Generate an inch ruler with CSS positioning."""
total_width = length_inches * 48
marks = ''
for i in range(length_inches * 4):
pos = i * 12
inch_val = i // 4
if i % 4 == 0:
marks += f'<div class="ruler-mark major" style="left:{pos}px"></div>'
if inch_val > 0:
marks += f'<div class="ruler-num" style="left:{pos}px">{inch_val}</div>'
elif i % 2 == 0:
marks += f'<div class="ruler-mark mid" style="left:{pos}px"></div>'
else:
marks += f'<div class="ruler-mark minor" style="left:{pos}px"></div>'
return (f'<div class="ruler-container">'
f'<div class="ruler" style="width:{total_width}px">'
f'<div class="ruler-marks">{marks}</div>'
f'</div>'
f'<div class="ruler-label">inches</div>'
f'</div>')
def ruler_cm(length_cm=15):
"""Generate a centimeter ruler with CSS positioning."""
total_width = length_cm * 24
marks = ''
for i in range(length_cm * 2):
pos = i * 12
cm_val = i // 2
if i % 2 == 0:
marks += f'<div class="ruler-mark major" style="left:{pos}px"></div>'
if cm_val > 0:
marks += f'<div class="ruler-num" style="left:{pos}px">{cm_val}</div>'
else:
marks += f'<div class="ruler-mark mid" style="left:{pos}px"></div>'
return (f'<div class="ruler-container">'
f'<div class="ruler" style="width:{total_width}px">'
f'<div class="ruler-marks">{marks}</div>'
f'</div>'
f'<div class="ruler-label">centimeters</div>'
f'</div>')
def measure_line(px_length, label="", unit="inches"):
"""Generate a measurement line with label and blank."""
return (f'<div style="display:flex; align-items:center; gap:6px; margin-bottom:4px;">'
f'<div class="measure-line" style="width:{px_length}px"></div>'
f'{label if label else ""} '
f'<span class="b" style="min-width:2em"></span> {unit}'
f'</div>')
def measure_line_cm(px_length, label=""):
"""Generate a measurement line in centimeters."""
return (f'<div style="display:flex; align-items:center; gap:6px; margin-bottom:4px;">'
f'<div class="measure-line" style="width:{px_length}px"></div>'
f'{label if label else ""} '
f'<span class="b" style="min-width:2em"></span> cm'
f'</div>')
# ═══════════════════════════════════════════════════
# Teacher Guide Helpers
# ═══════════════════════════════════════════════════
def tg_page(day, topic, body):
"""Build a complete teacher guide HTML page."""
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>{TEACHER_CSS}</style></head><body>
<page>
<div class="tg-head">
<span class="week">Teacher Guide</span>
<span class="day">{day}</span>
<span class="topic">{topic}</span>
</div>
{body}
</page>
</body></html>"""
def tg_page_multi(day, topic, *page_bodies):
"""Build multi-page teacher guide."""
pages = ""
for i, body in enumerate(page_bodies):
header = ""
if i == 0:
header = '<div class="tg-head"><span class="week">Teacher Guide</span><span class="day">{}</span><span class="topic">{}</span></div>'.format(day, topic)
pages += f"<page>\n{header}\n\n{body}\n\n</page>"
return f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>{TEACHER_CSS}</style></head><body>
{pages}
</body></html>"""
def tg_sec(n, title, answers):
"""Teacher guide section with numbered answers."""
items = ""
for i, ans in enumerate(answers, 1):
items += f'<div class="tg-problem">{i}. <span class="ans">{ans}</span></div>\n'
return (f'<div class="tg-sec">'
f'<div class="tg-sec-head"><span class="tg-sec-n">{n}</span><span class="tg-sec-t">{title}</span></div>'
f'<div class="tg-box">{items}</div></div>')
def tg_grid_answers(n, title, answers, cols=2):
"""Teacher guide section with answers in a grid."""
grid_class = f"tg-mgrid.c{cols}"
items = "".join(f'<div class="tg-box" style="text-align:center"><span class="tg-answer">{a}</span></div>' for a in answers)
return (f'<div class="tg-sec">'
f'<div class="tg-sec-head"><span class="tg-sec-n">{n}</span><span class="tg-sec-t">{title}</span></div>'
f'<div class="{grid_class}">{items}</div></div>')
def tg_note(text):
"""Teacher note."""
return f'<p class="tg-note">{text}</p>'
def tg_section_title(text):
"""Section divider title."""
return f'<div class="tg-section-title">{text}</div>'
# ═══════════════════════════════════════════════════
# Column Math Helpers (vertical format)
# ═══════════════════════════════════════════════════
def col_add(a, b_val, digits=2, ans_w=None):
"""
Vertical column addition (no answer line - student writes underneath).
digits: 2 for 2-digit, 3 for 3-digit numbers
"""
width = 50 if digits == 2 else 60
return (f'<div class="colbox" style="display:inline-block; width:{width}px;">'
f'<div style="text-align:right; padding:4px 0;"><span style="font-size:17px; font-family:monospace;">{a}</span></div>'
f'<div style="text-align:right; padding:4px 0;"><span style="font-size:17px; font-family:monospace;">+ {b_val}</span></div>'
f'<div style="border-top:2px solid #333; margin:3px 0;"></div>'
f'<div style="height:3.5em;"></div></div>')
def col_sub(a, b_val, digits=2, ans_w=None):
"""
Vertical column subtraction (no answer line - student writes underneath).
digits: 2 for 2-digit, 3 for 3-digit numbers
"""
width = 50 if digits == 2 else 60
return (f'<div class="colbox" style="display:inline-block; width:{width}px;">'
f'<div style="text-align:right; padding:4px 0;"><span style="font-size:17px; font-family:monospace;">{a}</span></div>'
f'<div style="text-align:right; padding:4px 0;"><span style="font-size:17px; font-family:monospace;">- {b_val}</span></div>'
f'<div style="border-top:2px solid #333; margin:3px 0;"></div>'
f'<div style="height:3.5em;"></div></div>')
def col_add_inline(a, b_val, digits=2):
"""Inline horizontal addition for grid display."""
blank_size = "4.5em" if digits == 2 else "5.5em"
return f'<span style="font-family:monospace; font-size:15px;">{a} + {b_val} = </span>' + b(blank_size)
def col_sub_inline(a, b_val, digits=2):
"""Inline horizontal subtraction for grid display."""
blank_size = "4.5em" if digits == 2 else "5.5em"
return f'<span style="font-family:monospace; font-size:15px;">{a} - {b_val} = </span>' + b(blank_size)