Migrate nb entier

This commit is contained in:
2026-05-12 21:31:08 +02:00
parent 7e82f1c0a5
commit 0e635d124e
270 changed files with 7515 additions and 1310 deletions

View File

@@ -0,0 +1,82 @@
from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
TARGETS = [
ROOT / "backend/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01B_fractions",
ROOT / "Programme/contenus_pedagogiques/cycle_3/mathematiques/cm1/01_nombres_calcul_resolution/01B_fractions",
]
STYLE_RE = re.compile(r"\.([A-Za-z0-9_-]+)\s*\{([^}]*)\}")
FONT_RE = re.compile(r"font-size\s*:\s*([0-9.]+)px?")
FRACTION_GROUP_RE = re.compile(
r'(?P<prefix><g\b(?=[^>]*class="[^"]*\bfraction-g\b[^"]*")[^>]*>)(?P<body>.*?)(?P<suffix></g>)',
re.DOTALL,
)
TEXT_RE = re.compile(r"(<text\b[^>]*\by=\")[-0-9.]+(\"[^>]*>)")
LINE_Y_RE = re.compile(r"<line\b[^>]*\by1=\"(?P<y1>[-0-9.]+)\"[^>]*\by2=\"(?P<y2>[-0-9.]+)\"")
def parse_class_font_sizes(svg: str) -> dict[str, float]:
sizes: dict[str, float] = {}
for class_name, style in STYLE_RE.findall(svg):
match = FONT_RE.search(style)
if match:
sizes[class_name] = float(match.group(1))
return sizes
def group_font_size(body: str, class_sizes: dict[str, float]) -> float:
class_match = re.search(r'<text\b[^>]*class="([^"]+)"', body)
if class_match:
for class_name in class_match.group(1).split():
if class_name in class_sizes:
return class_sizes[class_name]
direct = re.search(r'<text\b[^>]*font-size="([0-9.]+)"', body)
if direct:
return float(direct.group(1))
return 36.0
def compact_group(match: re.Match[str], class_sizes: dict[str, float]) -> str:
body = match.group("body")
line_match = LINE_Y_RE.search(body)
if not line_match:
return match.group(0)
bar_y = (float(line_match.group("y1")) + float(line_match.group("y2"))) / 2
size = group_font_size(body, class_sizes)
numerator_y = bar_y - min(max(8.0, size * 0.38), size * 0.50)
denominator_y = bar_y + min(max(10.0, size * 0.48), size * 0.55)
replacements = [f"{numerator_y:g}", f"{denominator_y:g}"]
def replace_text_y(text_match: re.Match[str]) -> str:
if not replacements:
return text_match.group(0)
return f"{text_match.group(1)}{replacements.pop(0)}{text_match.group(2)}"
body = TEXT_RE.sub(replace_text_y, body, count=2)
return f"{match.group('prefix')}{body}{match.group('suffix')}"
def compact_svg(path: Path) -> None:
svg = path.read_text(encoding="utf-8")
class_sizes = parse_class_font_sizes(svg)
compacted = FRACTION_GROUP_RE.sub(lambda match: compact_group(match, class_sizes), svg)
if compacted != svg:
path.write_text(compacted, encoding="utf-8")
def main() -> None:
for target in TARGETS:
for path in sorted(target.rglob("*.svg")):
compact_svg(path)
print(f"compacted {path}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,243 @@
import html
import os
import re
import shutil
import stat
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
LESSON_RELATIVE = Path(
"contenus_pedagogiques/cycle_3/mathematiques/cm1/"
"01_nombres_calcul_resolution/01A_nombres_entiers"
)
MIRROR_RELATIVE = Path(
"Programme/contenus_pedagogiques/cycle_3/mathematiques/cm1/"
"01_nombres_calcul_resolution/01A_nombres_entiers"
)
LESSON_DIRS = [ROOT / "backend" / LESSON_RELATIVE, ROOT / MIRROR_RELATIVE]
SOURCE_BLOCK = (
"\n\n## Sources\n\n"
"- Source principale locale : `Programme/pdf_collecteur/state/synthese_cycle3_cm1_mathematiques.md`.\n"
"- Documents officiels de reference : PDF du programme de mathematiques cycle 3 conserves dans `Programme/Cycle 3/Math/`.\n"
"- Niveau : cycle 3, CM1, mathematiques, nombres/calcul/resolution, nombres entiers.\n"
)
def title_from_slug(slug: str) -> str:
parts = slug.split("_")
if parts and parts[0][:2].isdigit():
parts = parts[1:]
return " ".join(part.capitalize() for part in parts if part)
def svg_attr(attrs: str, name: str, default: float | None = None) -> float | None:
match = re.search(rf'\b{name}="(?P<value>[-\d.]+)"', attrs)
if match:
return float(match.group("value"))
return default
def extract_svg_sections(svg_path: Path) -> list[dict]:
text = svg_path.read_text(encoding="utf-8", errors="ignore")
sections: list[dict] = []
pattern = re.compile(
r'<g\s+transform="translate\((?P<x>[-\d.]+)\s+(?P<y>[-\d.]+)\)">\s*'
r'<rect\b(?P<attrs>[^>]*)/?>'
r'(?P<body>.*?)</g>',
re.DOTALL,
)
for match in pattern.finditer(text):
attrs = match.group("attrs")
rect_x = svg_attr(attrs, "x", 0)
rect_y = svg_attr(attrs, "y", 0)
width = svg_attr(attrs, "width")
height = svg_attr(attrs, "height")
if rect_x != 0 or rect_y != 0 or width is None or height is None:
continue
if width < 250 or height < 100:
continue
title_match = re.search(r'<text[^>]*>(?P<title>.*?)</text>', match.group("body"), re.DOTALL)
raw_title = re.sub(r"<[^>]+>", "", title_match.group("title") if title_match else "")
sections.append(
{
"index": len(sections),
"title": html.unescape(raw_title).strip() or f"Card {len(sections) + 1}",
"view_box": [
max(float(match.group("x")) - 28, 0),
max(float(match.group("y")) - 28, 0),
width + 56,
height + 56,
],
}
)
return sections
def render_svg_section(svg_path: Path, section: dict) -> str:
x, y, width, height = section["view_box"]
text = svg_path.read_text(encoding="utf-8", errors="ignore")
text = re.sub(r'\swidth="[^"]+"', f' width="{int(width)}"', text, count=1)
text = re.sub(r'\sheight="[^"]+"', f' height="{int(height)}"', text, count=1)
text = re.sub(r'\sviewBox="[^"]+"', f' viewBox="{x:g} {y:g} {width:g} {height:g}"', text, count=1)
return text
def move_file(source: Path, target: Path) -> None:
if not source.exists():
return
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
target.unlink()
shutil.move(str(source), str(target))
def ensure_source_block(path: Path) -> None:
if not path.exists():
return
text = path.read_text(encoding="utf-8", errors="ignore").rstrip()
if "## Sources" not in text and "# Sources" not in text:
path.write_text(text + SOURCE_BLOCK + "\n", encoding="utf-8")
def write_if_missing(path: Path, text: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if not path.exists():
path.write_text(text, encoding="utf-8")
def migrate_fiches_and_cards(lesson_dir: Path) -> list[str]:
old_svg_dir = lesson_dir / "svg"
old_card_dir = lesson_dir / "card_contexts"
fiche_slugs = sorted({path.stem for path in list(lesson_dir.glob("[0-9][0-9]_*.md")) + list(old_svg_dir.glob("*.svg"))})
for index, slug in enumerate(fiche_slugs, start=1):
fiche_dir = lesson_dir / "fiches" / slug
move_file(lesson_dir / f"{slug}.md", fiche_dir / "fiche.md")
move_file(old_svg_dir / f"{slug}.svg", fiche_dir / "fiche.svg")
ensure_source_block(fiche_dir / "fiche.md")
fiche_svg = fiche_dir / "fiche.svg"
if not fiche_svg.exists():
continue
card_dir = lesson_dir / "cards" / slug
legacy_context_dir = old_card_dir / slug
sections = extract_svg_sections(fiche_svg)
if not sections:
sections = [{"index": 0, "title": title_from_slug(slug), "view_box": [0, 0, 1600, 900]}]
for section in sections:
card_number = section["index"] + 1
target_md = card_dir / f"card_{card_number:02d}.md"
for legacy_name in [
legacy_context_dir / f"Fiche{index}Card{card_number}.md",
legacy_context_dir / f"card_{card_number:02d}.md",
]:
if legacy_name.exists():
move_file(legacy_name, target_md)
break
write_if_missing(
target_md,
(
f"# {section['title']}\n\n"
f"Contexte de card pour la fiche `{slug}`.\n\n"
"## Intention\n\n"
"Faire verbaliser le point cle de la zone affichee avant de passer a la suite.\n"
+ SOURCE_BLOCK
),
)
ensure_source_block(target_md)
target_svg = card_dir / f"card_{card_number:02d}.svg"
target_svg.write_text(render_svg_section(fiche_svg, section), encoding="utf-8")
return fiche_slugs
def migrate_adaptive_content(lesson_dir: Path) -> None:
kit_dir = lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif"
if not kit_dir.exists():
return
tests_dir = lesson_dir / "tests"
for md_path in sorted((kit_dir / "test_diagnostic").glob("*.md")):
if md_path.name == "README.md":
continue
move_file(md_path, tests_dir / md_path.name)
ensure_source_block(tests_dir / md_path.name)
for support_dir in [kit_dir / "svg" / "tests" / "questions", kit_dir / "svg" / "tests" / "reponses"]:
for svg_path in sorted(support_dir.glob("*.svg")):
move_file(svg_path, tests_dir / svg_path.name)
for md_name in ["00_principe_adaptatif.md", "NOTES_SESSION_2026-04-24.md"]:
move_file(kit_dir / md_name, tests_dir / md_name)
ensure_source_block(tests_dir / md_name)
parcours_dir = lesson_dir / "exercices" / "parcours_adaptes"
for exercise_md in sorted((kit_dir / "exercices").glob("*/exercices.md")):
target_dir = parcours_dir / exercise_md.parent.name
move_file(exercise_md, target_dir / "exercices.md")
ensure_source_block(target_dir / "exercices.md")
svg_path = kit_dir / "svg" / "exercices" / "profils" / f"{exercise_md.parent.name}.svg"
move_file(svg_path, target_dir / "exercices.svg")
def build_lesson_markdown(lesson_dir: Path) -> None:
parts = []
source_lesson = lesson_dir / "sources" / "lecon_originale.md"
kit_lesson = lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif" / "lecon" / "lecon.md"
if kit_lesson.exists():
source_lesson.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(kit_lesson), str(source_lesson))
ensure_source_block(source_lesson)
parts.append(source_lesson.read_text(encoding="utf-8", errors="ignore").strip())
readme = lesson_dir / "README.md"
if readme.exists():
parts.append(readme.read_text(encoding="utf-8", errors="ignore").strip())
for fiche_md in sorted((lesson_dir / "fiches").glob("*/fiche.md")):
parts.append(fiche_md.read_text(encoding="utf-8", errors="ignore").strip())
body = "\n\n---\n\n".join(part for part in parts if part)
text = (
"# Lecon globale - CM1 - Nombres entiers\n\n"
"Cette lecon globale sert de contexte general a Professeur TOP pour la lecon nombres entiers.\n"
+ SOURCE_BLOCK
+ "\n## Contenu consolide\n\n"
+ body
+ "\n"
)
(lesson_dir / "lecon.md").write_text(text, encoding="utf-8")
def cleanup_legacy_dirs(lesson_dir: Path) -> None:
def handle_remove_error(function, path, exc_info) -> None:
os.chmod(path, stat.S_IWRITE)
function(path)
for legacy_dir in [
lesson_dir / "svg",
lesson_dir / "card_contexts",
lesson_dir / "01_lecture_ecriture_nombres_entiers_kit_adaptatif",
]:
if legacy_dir.exists():
shutil.rmtree(legacy_dir, onerror=handle_remove_error)
def migrate_lesson(lesson_dir: Path) -> None:
if not lesson_dir.exists():
return
fiche_slugs = migrate_fiches_and_cards(lesson_dir)
migrate_adaptive_content(lesson_dir)
build_lesson_markdown(lesson_dir)
cleanup_legacy_dirs(lesson_dir)
print(f"migrated: {lesson_dir} ({len(fiche_slugs)} fiches)")
def main() -> None:
for lesson_dir in LESSON_DIRS:
migrate_lesson(lesson_dir)
if __name__ == "__main__":
main()

View File

@@ -45,9 +45,9 @@ def fraction(x: int, y: int, n: str | int, d: str | int, size: int = 52) -> str:
return (
f'<g transform="translate({x} {y})" class="math-expression fraction-g" '
f'data-math="fraction" data-box="-10 -48 {width + 20} 110">'
f'<text x="{cx:g}" y="-24" text-anchor="middle" class="frac">{escape(n_text)}</text>'
f'<text x="{cx:g}" y="-10" text-anchor="middle" class="frac">{escape(n_text)}</text>'
f'<line x1="0" y1="8" x2="{width}" y2="8" stroke="#074f4b" stroke-width="4" stroke-linecap="round" />'
f'<text x="{cx:g}" y="58" text-anchor="middle" class="frac">{escape(d_text)}</text>'
f'<text x="{cx:g}" y="28" text-anchor="middle" class="frac">{escape(d_text)}</text>'
"</g>"
)

View File

@@ -271,7 +271,8 @@ def validate_panel_containment(
oy += ty
if local_name(node.tag) == "g":
panel = next((panel_box_from_rect(child, ox, oy) for child in node if panel_box_from_rect(child, ox, oy)), None)
panel_children = [panel_box_from_rect(child, ox, oy) for child in node if panel_box_from_rect(child, ox, oy)]
panel = panel_children[0] if len(panel_children) == 1 else None
if panel:
safe = Box("panel", panel.label, panel.x1, panel.y1, panel.x2, panel.y2)
for box in collect_layout_boxes(node, styles, ox - tx, oy - ty):
@@ -330,6 +331,8 @@ def validate_fraction_group(path: Path, group: ET.Element, styles: dict[str, dic
# du dénominateur : c'est ce qui équilibre l'espace visible autour de la barre.
min_top_gap = max(8.0, num_size * 0.24)
min_bottom_gap = max(8.0, den_size * 0.30)
max_top_gap = max(14.0, num_size * 0.50)
max_bottom_gap = max(14.0, den_size * 0.55)
if bar_y - num_y < min_top_gap:
issues.append(
@@ -348,6 +351,23 @@ def validate_fraction_group(path: Path, group: ET.Element, styles: dict[str, dic
)
)
if bar_y - num_y > max_top_gap:
issues.append(
Issue(
"warning",
path,
f"Espace trop grand entre numerateur et barre ({bar_y - num_y:.1f}px, attendu <= {max_top_gap:.1f}px).",
)
)
if den_y - bar_y > max_bottom_gap:
issues.append(
Issue(
"warning",
path,
f"Espace trop grand entre barre et denominateur ({den_y - bar_y:.1f}px, attendu <= {max_bottom_gap:.1f}px).",
)
)
expected_width = max(
estimated_text_width(node_text(numerator), num_size),
estimated_text_width(node_text(denominator), den_size),