diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a81cf847..e96b480c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines. +## [0.1.49](https://github.com/CodingBlackFemales/wordpress/compare/v0.1.48...v0.1.49) (2026-05-11) + ## [0.1.48](https://github.com/CodingBlackFemales/wordpress/compare/v0.1.47...v0.1.48) (2026-05-11) diff --git a/package-lock.json b/package-lock.json index 6254676b8..410fe8070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cbf-wordpress", - "version": "0.1.48", + "version": "0.1.49", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cbf-wordpress", - "version": "0.1.48", + "version": "0.1.49", "devDependencies": { "@commitlint/cli": "^19.0", "@commitlint/config-conventional": "^19.0", diff --git a/package.json b/package.json index 6bd962ebc..b6762969f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cbf-wordpress", - "version": "0.1.48", + "version": "0.1.49", "description": "[![Packagist](https://img.shields.io/packagist/v/roots/bedrock.svg?style=flat-square)](https://packagist.org/packages/roots/bedrock) [![Build Status](https://img.shields.io/travis/roots/bedrock.svg?style=flat-square)](https://travis-ci.org/roots/bedrock)", "devDependencies": { "@commitlint/cli": "^19.0", diff --git a/scripts/sync-export-rsync.sh b/scripts/sync-export-rsync.sh new file mode 100755 index 000000000..81cbe0c26 --- /dev/null +++ b/scripts/sync-export-rsync.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Upload CSV files and media/ to a remote user's export dir via rsync over SSH. +# +# Connection settings are taken from flags first, then environment variables, +# then built-in fallback defaults. This lets import-slides.sh feed the target +# derived from wp-cli.yml (single source of truth) instead of editing this file. +# +# Usage (from the directory that contains your CSVs and media/): +# ./scripts/sync-export-rsync.sh +# ./scripts/sync-export-rsync.sh --dry-run +# ./scripts/sync-export-rsync.sh --host 1.2.3.4 --user bob --port 65002 ./MyLesson.csv ./media +# +# Flags (all optional): +# --user SSH user (env SSH_USER) +# --host SSH host (env SSH_HOST) +# --port SSH port (env SSH_PORT, default 22) +# --identity SSH private key (env SSH_IDENTITY) +# --remote-dir Dir under remote ~ (env REMOTE_EXPORT, default "export") +# --dry-run Preview without transferring +# [paths...] Explicit sources; default: *.csv in cwd + ./media if present +# +# Remote layout: ~//. Uses rsync --update (-u). + +set -euo pipefail + +# --- fallback defaults (overridden by env vars or flags) --- +SSH_USER="${SSH_USER:-u544495502}" +SSH_HOST="${SSH_HOST:-82.29.186.233}" +SSH_PORT="${SSH_PORT:-65002}" +# Leave empty to use ssh default identity; otherwise path to a private key. +SSH_IDENTITY="${SSH_IDENTITY:-}" +# Directory name under the remote user's home (destination is ~/${REMOTE_EXPORT}/). +REMOTE_EXPORT="${REMOTE_EXPORT:-export}" +# --- end defaults --- + +dry_run=() +paths=() + +while [[ "$#" -gt 0 ]]; do + case "$1" in + --dry-run) dry_run=(--dry-run); shift ;; + --user) SSH_USER="$2"; shift 2 ;; + --host) SSH_HOST="$2"; shift 2 ;; + --port) SSH_PORT="$2"; shift 2 ;; + --identity) SSH_IDENTITY="$2"; shift 2 ;; + --remote-dir) REMOTE_EXPORT="$2"; shift 2 ;; + --user=*) SSH_USER="${1#*=}"; shift ;; + --host=*) SSH_HOST="${1#*=}"; shift ;; + --port=*) SSH_PORT="${1#*=}"; shift ;; + --identity=*) SSH_IDENTITY="${1#*=}"; shift ;; + --remote-dir=*) REMOTE_EXPORT="${1#*=}"; shift ;; + --) shift; while [[ "$#" -gt 0 ]]; do paths+=("$1"); shift; done ;; + -*) echo "Error: unknown option: $1" >&2; exit 1 ;; + *) paths+=("$1"); shift ;; + esac +done + +if [[ -z "${SSH_USER}" || -z "${SSH_HOST}" ]]; then + echo "Error: SSH user and host are required (set --user/--host or SSH_USER/SSH_HOST)." >&2 + exit 1 +fi + +if [[ -n "${SSH_IDENTITY}" && ! -f "${SSH_IDENTITY}" ]]; then + echo "Error: SSH_IDENTITY is set but file not found: ${SSH_IDENTITY}" >&2 + exit 1 +fi + +# rsync -e string (avoid spaces in SSH_IDENTITY paths or use ~/.ssh/config Host entries) +RSYNC_RSH="ssh" +if [[ -n "${SSH_PORT}" && "${SSH_PORT}" != "22" ]]; then + RSYNC_RSH+=" -p ${SSH_PORT}" +fi +if [[ -n "${SSH_IDENTITY}" ]]; then + RSYNC_RSH+=" -i ${SSH_IDENTITY}" +fi + +sources=() +if [[ "${#paths[@]}" -gt 0 ]]; then + for p in "${paths[@]}"; do + if [[ ! -e "$p" ]]; then + echo "Error: source does not exist: $p" >&2 + exit 1 + fi + # Preserve directory names on the remote side: "media" keeps ~/export/media, + # while "media/" would flatten contents into ~/export. + if [[ -d "$p" ]]; then + p="${p%/}" + fi + sources+=("$p") + done +else + shopt -s nullglob + for f in *.csv; do + sources+=("$f") + done + shopt -u nullglob + if [[ -d media ]]; then + sources+=(media) + fi + if [[ "${#sources[@]}" -eq 0 ]]; then + echo "Error: no arguments and no *.csv or ./media/ in the current directory." >&2 + echo " cd to your export folder or pass explicit paths." >&2 + exit 1 + fi +fi + +dest="${SSH_USER}@${SSH_HOST}:~/${REMOTE_EXPORT}/" + +if [[ "${#dry_run[@]}" -gt 0 ]]; then + echo "rsync (dry-run) → ${dest}" +else + echo "rsync → ${dest}" +fi +rsync -auvz -h \ + --exclude=".DS_Store" \ + --exclude="*/.DS_Store" \ + "${dry_run[@]}" \ + -e "${RSYNC_RSH}" \ + "${sources[@]}" \ + "$dest" diff --git a/tools/slides-to-learndash/.gitignore b/tools/slides-to-learndash/.gitignore new file mode 100644 index 000000000..a54826f04 --- /dev/null +++ b/tools/slides-to-learndash/.gitignore @@ -0,0 +1,13 @@ +.DS_Store +.venv/ +__pycache__/ +*.py[cod] +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.coverage +htmlcov/ +media/ +*.pptx +*.csv diff --git a/tools/slides-to-learndash/.gitrepo b/tools/slides-to-learndash/.gitrepo new file mode 100644 index 000000000..08aae5295 --- /dev/null +++ b/tools/slides-to-learndash/.gitrepo @@ -0,0 +1,12 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = /Users/gary/Dev/CodingBlackFemales/slides-to-learndash + branch = main + commit = 28b10e664db3915ec85409f5b443c8c8f7bf8b12 + parent = 06d1fe87d0bb3a87f531e42759b4eac89b973180 + method = merge + cmdver = 0.4.9 diff --git a/tools/slides-to-learndash/README.md b/tools/slides-to-learndash/README.md new file mode 100644 index 000000000..12889b9a0 --- /dev/null +++ b/tools/slides-to-learndash/README.md @@ -0,0 +1,126 @@ +# slides-to-learndash + +Convert a `.pptx` file (e.g. exported from Google Slides) into CSV for the [LearnDash Bulk Lessons Or Topics](https://github.com/serenichron/learndash-bulk-lessons-or-topics) WordPress plugin. Content is emitted as **WordPress block markup** on a single CSV line per row (compatible with the plugin’s CSV reader). + +## Setup + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` + +## List slide layout names + +Provide exactly one `.pptx` as a positional argument or with `--input` / `-i`: + +```bash +slides-to-learndash --list-layouts ./deck.pptx +slides-to-learndash --list-layouts --input ./deck.pptx +slides-to-learndash --list-layouts -i ./deck.pptx +``` + +Use this to choose a `--heading-layout` regex for topic mode. + +## Export: one merged lesson (`lesson-only`) + +**Default mode** is `lesson-only`. **Default output** is the same path as each input file with a `.csv` extension (e.g. `./deck.pptx` → `./deck.csv`). + +Minimal example: + +```bash +slides-to-learndash ./deck.pptx +``` + +Several decks in one run (each gets its own CSV next to the `.pptx`): + +```bash +slides-to-learndash ./a.pptx ./b.pptx +``` + +Optional **`--out` / `-o`** (single input only): override the output CSV path. With multiple inputs, omit `--out` (it is not supported). + +Legacy style (positional or `--input`, not both): + +```bash +slides-to-learndash --input ./deck.pptx --course-id 123 --out ./build.csv +slides-to-learndash ./deck.pptx --course-id 123 -o ./build.csv +``` + +Optional: `--lesson-title`, `--slide-headings` / `--no-slide-headings` (default: add each slide’s title as an **H2** before that slide’s body), `--skip-images`, `--media-dir`. + +Slide **1** is treated as the cover slide: its content is **not** included in `post_content` (the lesson/topic title can still come from it via `--lesson-title` or the first slide’s title). Slides are **not** separated with `wp:separator` blocks. + +When a slide title is emitted as an **H2**, the same title is **dropped** from the start of that slide’s body so it is not repeated as a paragraph. + +### Rich text → blocks + +Paragraph runs are mapped to HTML inside `wp:paragraph`, list items, and headings: **bold**, *italic*, underline, strikethrough, monospace/`code`, and combined emphasis. Bullet paragraphs become `wp:list` / `wp:list-item`. Paragraphs with larger font sizes (typical subtitle lines) become `wp:heading` level **3** or **4**. All-monospace paragraphs become `wp:code` (pre/code). Images are still `wp:image` with files under `media/`. + +## Export: lesson + topics (`lesson-with-topics`) + +Slides whose **`slide_layout.name`** matches the regex (Python `re.fullmatch` on the trimmed name) start a new **topic**. All slides **before** the first match are merged into the **lesson** row only. + +Writes two files next to the output base path (default: same directory as the input, stem from the input basename): + +- `{stem}_lesson.csv` +- `{stem}_topics.csv` + +Example: + +```bash +slides-to-learndash ./deck.pptx \ + --mode lesson-with-topics \ + --course-id 123 \ + --heading-layout 'SECTION_HEADER|Title - Top_1' +``` + +With an explicit base path (single file only): + +```bash +slides-to-learndash ./deck.pptx \ + --mode lesson-with-topics \ + --course-id 123 \ + -o ./build.csv \ + --heading-layout 'SECTION_HEADER|Title - Top_1' +``` + +(`./build.csv` supplies the stem `build`, so outputs are `build_lesson.csv` and `build_topics.csv` next to `./build.csv`.) + +### Import order + +1. Import **`_lesson.csv`** with the bulk plugin, content type **Lessons**, action **Create**. +2. Note the new lesson **post ID** in WordPress. +3. Edit **`_topics.csv`**: set the `lesson_id` column on each topic row to that ID. +4. Import **`_topics.csv`** with content type **Topics**, action **Create**. + +**Media folders:** by default, images go under `media//` beside the output CSV (e.g. `media/Introduction_to_Git/slide_001_img_01.png` in HTML), so different decks do not share one flat `media/` directory. With **multiple** inputs and a shared **`--media-dir`**, each export uses a subfolder named after that CSV stem under that directory so files do not overwrite each other. Override layout with **`--media-dir`** (paths in the CSV are relative to the CSV’s directory when possible). Or use **`--skip-images`**. + +## Upload export to server (rsync) + +The script [scripts/sync-export-rsync.sh](scripts/sync-export-rsync.sh) uploads CSV files and the `media/` tree to **`~/export`** on a remote host over SSH using **rsync** (`-a` archive, **`-u` / `--update`** so the receiver is not overwritten when its copy is newer, **`-z`** compression). It does **not** use `--delete`. + +1. Edit the configuration block at the top of the script: `SSH_USER`, `SSH_HOST`, optional `SSH_PORT` (default `22`), optional `SSH_IDENTITY`, and `REMOTE_EXPORT` (default `export`, i.e. `~/export` on the server). +2. Ensure the remote directory exists once, e.g. `ssh youruser@yourhost 'mkdir -p ~/export'`. +3. From the folder that contains your generated `*.csv` and `media/`: + +```bash +chmod +x scripts/sync-export-rsync.sh +cd /path/to/that/folder +/path/to/slides-to-learndash/scripts/sync-export-rsync.sh --dry-run +/path/to/slides-to-learndash/scripts/sync-export-rsync.sh +``` + +With **no path arguments**, the script syncs all **`*.csv` in the current directory** and **`./media`** if present. You can pass explicit paths instead, e.g. `./MyLesson.csv ./media`. Basenames and the `media/…` subtree are mirrored under `~/export/` on the server. + +Avoid committing real credentials in the script if the repository is shared; use placeholders or a private fork. + +## Requirements for export + +| Input | Required when | +|------|----------------| +| One or more `.pptx` paths (positional) **or** `--input` / `-i` (single file) | Always (except help). Do not pass both. | +| `--mode` | Optional; defaults to **`lesson-only`**. | +| `--course-id` | Optional (empty in CSV if omitted). | +| `--out` / `-o` | Optional; default is `.csv`. **Not allowed** with multiple positional inputs. | +| `--heading-layout` | **`lesson-with-topics` only** (regex). | diff --git a/tools/slides-to-learndash/pyproject.toml b/tools/slides-to-learndash/pyproject.toml new file mode 100644 index 000000000..b9c3318a4 --- /dev/null +++ b/tools/slides-to-learndash/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "slides-to-learndash" +version = "0.1.0" +description = "Convert PPTX decks to LearnDash bulk-import CSV (WIP)" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "python-pptx>=0.6.21", + "typer>=0.9.0", +] + +[project.scripts] +slides-to-learndash = "slides_to_learndash.cli:app" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["slides_to_learndash"] diff --git a/tools/slides-to-learndash/slides_to_learndash/__init__.py b/tools/slides-to-learndash/slides_to_learndash/__init__.py new file mode 100644 index 000000000..5ff40d438 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/__init__.py @@ -0,0 +1,3 @@ +"""PPTX → LearnDash CSV tooling.""" + +__version__ = "0.1.0" diff --git a/tools/slides-to-learndash/slides_to_learndash/blocks.py b/tools/slides-to-learndash/slides_to_learndash/blocks.py new file mode 100644 index 000000000..4cc066955 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/blocks.py @@ -0,0 +1,220 @@ +"""WordPress block serialization (single-line HTML for CSV).""" + +from __future__ import annotations + +import html +import json +import re +from typing import Iterable + +# Block delimiter comments use JSON with double quotes — escape for embedding in attributes if needed. +_WS = re.compile(r"\s+") + + +def _esc(s: str) -> str: + return html.escape(s, quote=False) + + +def paragraph_block(text: str) -> str: + t = _esc(text.strip()) + if not t: + return "" + return f'

{t}

' + + +def paragraph_block_html(inner_html: str) -> str: + """Paragraph block with trusted inner HTML (bold, italic, code, etc.).""" + inner = inner_html.strip() + if not inner: + return "" + return f'

{inner}

' + + +def heading_block(text: str, level: int = 2) -> str: + t = _esc(text.strip()) + if not t: + return "" + if level < 1: + level = 1 + if level > 6: + level = 6 + cls = "wp-block-heading" + return ( + f'' + f'{t}' + f"" + ) + + +def heading_block_html(inner_html: str, level: int) -> str: + """Heading with trusted inner HTML.""" + inner = inner_html.strip() + if not inner: + return "" + if level < 1: + level = 1 + if level > 6: + level = 6 + cls = "wp-block-heading" + return ( + f'' + f'{inner}' + f"" + ) + + +def list_block(items: list[str]) -> str: + if not items: + return "" + parts = ["
    "] + for item in items: + ti = _esc(item.strip()) + if not ti: + continue + parts.append("") + parts.append(f"
  • {ti}
  • ") + parts.append("") + parts.append("
") + return "".join(parts) + + +def list_block_html(items: list[str]) -> str: + """List items with trusted inner HTML per
  • .""" + if not items: + return "" + parts = ['
      '] + for item in items: + li = item.strip() + if not li: + continue + parts.append("") + parts.append(f"
    • {li}
    • ") + parts.append("") + parts.append("
    ") + return "".join(parts) + + +def code_block_plain(code_text: str) -> str: + """wp:code block from plain source (escaped). + + Newlines are encoded as so they survive the single-line whitespace + normalisation applied to the full post_content before CSV export. + WordPress faithfully preserves inside
     on import.
    +    """
    +    raw = code_text.replace("\r\n", "\n").replace("\r", "\n").strip()
    +    if not raw:
    +        return ""
    +    esc = html.escape(raw).replace("\n", "
    ")
    +    return (
    +        ""
    +        '
    '
    +        f"{esc}"
    +        "
    " + "" + ) + + +def image_block(relative_src: str, alt: str = "") -> str: + """relative_src is URL path fragment for wp-content or relative file path as stored in CSV.""" + alt_a = _esc(alt) + # wp:image with minimal attrs (classic block format) + return ( + f'' + f'
    ' + f'{alt_a}' + f"
    " + f"" + ) + + +def separator_block() -> str: + return ( + '' + '
    ' + "" + ) + + +def _flex_basis_for_column_width(width: str) -> str | None: + """Match core Column block save() (column/save.js): inline flex-basis from width attr.""" + w = width.strip() + if not w or not re.search(r"\d", w): + return None + flex_basis = w + if w.endswith("%"): + mult = 10**12 + n = round(float(w[:-1]) * mult) / mult + # Match JS number + "%" stringification (no spurious .0 on whole numbers). + flex_basis = f"{n:g}%" + return flex_basis + + +def _column_wrapper_open(width: str) -> str: + """Opening
    for core/column: class + flex-basis style when width is set.""" + fb = _flex_basis_for_column_width(width) + if fb is None: + return '
    ' + # Core serializes React style {{ flexBasis }} as flex-basis in the saved markup. + return f'
    ' + + +def _equal_column_widths(n: int) -> list[str]: + """Width attributes for n equal columns (sum ~100%; matches core presets).""" + if n <= 0: + return [] + if n == 1: + return ["100%"] + if n == 2: + return ["50%", "50%"] + if n == 3: + return ["33.33%", "33.33%", "33.34%"] + p = 100.0 / n + return [f"{p:.2f}%" for _ in range(n)] + + +def columns_block_html(column_inner_html: list[str], widths_percent: list[str]) -> str: + """Core Columns block: one row, N column inner HTML fragments (already block markup).""" + cols = [c.strip() for c in column_inner_html if c and c.strip()] + if len(cols) < 2: + return join_blocks(column_inner_html) + w = list(widths_percent) + # Empty columns are dropped above; do not slice preset widths — that can yield e.g. two + # "33.33%" from a 3-column preset (invalid, does not sum to 100%) and Gutenberg flags the block. + if len(w) != len(cols): + w = _equal_column_widths(len(cols)) + parts: list[str] = [ + "", + '
    ', + ] + for inner, width in zip(cols, w, strict=True): + attr = json.dumps({"width": width}, separators=(",", ":")) + parts.append(f"") + parts.append(_column_wrapper_open(width)) + parts.append(inner) + parts.append("
    ") + parts.append("
    ") + return "".join(parts) + + +def join_blocks(parts: Iterable[str]) -> str: + """Concatenate block strings; drop empties; normalize whitespace to single line.""" + out: list[str] = [] + for p in parts: + p = p.strip() + if p: + out.append(p) + joined = "".join(out) + return _WS.sub(" ", joined).strip() + + +def merge_slide_sections(sections: list[str], *, slide_separator: bool = False) -> str: + """Join per-slide HTML sections; optionally insert wp:separator between slides (off by default).""" + merged: list[str] = [] + for i, sec in enumerate(sections): + sec = sec.strip() + if not sec: + continue + if slide_separator and i > 0: + merged.append(separator_block()) + merged.append(sec) + return join_blocks(merged) diff --git a/tools/slides-to-learndash/slides_to_learndash/cli.py b/tools/slides-to-learndash/slides_to_learndash/cli.py new file mode 100644 index 000000000..796b44a15 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/cli.py @@ -0,0 +1,335 @@ +"""CLI for slides-to-learndash.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Annotated, Optional + +import typer + +from slides_to_learndash.export_csv import write_csv +from slides_to_learndash.extract import load_presentation +from slides_to_learndash.pipeline import build_slides, run_lesson_only, run_lesson_with_topics + +app = typer.Typer(add_completion=False, no_args_is_help=True) + + +def _list_layouts(input_path: Path) -> None: + from pptx import Presentation + + prs = Presentation(str(input_path)) + typer.echo(f"File: {input_path}") + typer.echo(f"Slides: {len(prs.slides)}\n") + typer.echo(f"{'#':>4} slide_layout.name") + typer.echo("-" * 48) + seen: list[str] = [] + for idx, slide in enumerate(prs.slides, start=1): + name = slide.slide_layout.name + typer.echo(f"{idx:4d} {name}") + if name not in seen: + seen.append(name) + typer.echo("\nDistinct layout names (first-seen order):") + for n in seen: + typer.echo(f" {n}") + + +def _safe_dir_segment(name: str) -> str: + """Filesystem-safe single path segment for per-export media folders.""" + s = name.strip() + if not s: + return "export" + s = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", s) + s = re.sub(r"\s+", "_", s).strip("._") + return (s[:80] if s else "export") or "export" + + +def _default_media_dir(out_csv: Path) -> Path: + """media// next to output CSV so each export has its own folder.""" + return out_csv.parent / "media" / _safe_dir_segment(out_csv.stem) + + +def _media_url_prefix(out_csv: Path, media_dir: Path) -> str: + """Relative path from CSV directory to media dir, posix (matches src= in post HTML).""" + try: + rel = media_dir.resolve().relative_to(out_csv.parent.resolve()) + return str(rel).replace("\\", "/") + except ValueError: + return media_dir.name + + +def _resolve_media_dir( + out: Path, + media_dir: Optional[Path], + skip_images: bool, + multi_input: bool, +) -> tuple[Optional[Path], str]: + """Return (mdir or None, media_prefix for build_slides).""" + if skip_images: + return None, "media" + if media_dir is not None: + base = media_dir.resolve() + mdir = base / _safe_dir_segment(out.stem) if multi_input else base + mdir.mkdir(parents=True, exist_ok=True) + return mdir, _media_url_prefix(out, mdir) + mdir = _default_media_dir(out) + mdir.mkdir(parents=True, exist_ok=True) + return mdir, _media_url_prefix(out, mdir) + + +def _export_lesson_only( + input_path: Path, + out: Path, + course_id: str, + section_heading_layout: Optional[str], + media_dir: Optional[Path], + skip_images: bool, + lesson_title: Optional[str], + slide_headings: bool, + multi_input: bool, +) -> None: + out = out.resolve() + mdir, media_prefix = _resolve_media_dir(out, media_dir, skip_images, multi_input) + + prs = load_presentation(input_path) + slides = build_slides( + prs, + media_dir=mdir, + skip_images=skip_images, + media_url_prefix=media_prefix, + ) + + rows = run_lesson_only( + slides, + course_id=course_id, + lesson_title=lesson_title, + include_slide_headings=slide_headings, + section_heading_layout_regex=section_heading_layout, + ) + write_csv(out, rows) + typer.secho(f"Wrote {out} ({len(rows)} row(s)).", fg=typer.colors.GREEN) + if mdir: + typer.secho(f"Media: {mdir}") + + +def _export_lesson_with_topics( + input_path: Path, + out: Path, + course_id: str, + heading_layout_regex: str, + media_dir: Optional[Path], + skip_images: bool, + lesson_title: Optional[str], + slide_headings: bool, + multi_input: bool, +) -> None: + out = out.resolve() + mdir, media_prefix = _resolve_media_dir(out, media_dir, skip_images, multi_input) + + prs = load_presentation(input_path) + slides = build_slides( + prs, + media_dir=mdir, + skip_images=skip_images, + media_url_prefix=media_prefix, + ) + + lesson_rows, topic_rows = run_lesson_with_topics( + slides, + course_id=course_id, + lesson_title=lesson_title, + heading_layout_regex=heading_layout_regex, + include_slide_headings=slide_headings, + ) + lesson_path = out.parent / f"{out.stem}_lesson.csv" + topics_path = out.parent / f"{out.stem}_topics.csv" + write_csv(lesson_path, lesson_rows) + typer.secho(f"Wrote {lesson_path} ({len(lesson_rows)} row(s)).", fg=typer.colors.GREEN) + write_csv(topics_path, topic_rows) + typer.secho(f"Wrote {topics_path} ({len(topic_rows)} row(s)).", fg=typer.colors.GREEN) + typer.secho( + "Import the lesson CSV with LearnDash Bulk → Content type Lessons, then fill lesson_id in the " + "topics CSV from the new lesson post ID and import topics with Content type Topics.", + fg=typer.colors.YELLOW, + ) + if mdir: + typer.secho(f"Media: {mdir}") + + +@app.command() +def main( + ctx: typer.Context, + pptx_files: Annotated[ + list[Path], + typer.Argument( + metavar="PPTX", + help="One or more .pptx files (optional if --input is set instead)", + exists=True, + dir_okay=False, + readable=True, + ), + ] = [], + input_path: Optional[Path] = typer.Option( + None, + "--input", + "-i", + exists=True, + dir_okay=False, + readable=True, + help="Path to a .pptx file (alternative to positional PPTX paths; do not combine both)", + ), + list_layouts: bool = typer.Option( + False, + "--list-layouts", + help="Print each slide index and slide_layout.name; provide exactly one .pptx", + ), + mode: str = typer.Option( + "lesson-only", + "--mode", + "-m", + help="lesson-only (default): one merged lesson row; lesson-with-topics: lesson + topic rows", + ), + course_id: Optional[str] = typer.Option( + None, + "--course-id", + help="LearnDash course_id column in the CSV (optional; leave unset for an empty value)", + ), + out: Optional[Path] = typer.Option( + None, + "--out", + "-o", + help="Output CSV path (.csv); default: same path as input with .csv extension. " + "Not allowed with multiple inputs. For lesson-with-topics, writes {stem}_lesson.csv and {stem}_topics.csv", + ), + heading_layout: Optional[str] = typer.Option( + None, + "--heading-layout", + help="Regex (Python) matched with re.fullmatch against slide_layout.name for topic boundaries", + ), + section_heading_layout: Optional[str] = typer.Option( + None, + "--section-heading-layout", + help="Lesson-only: regex (re.fullmatch) for section/divider slides — slide title as H2; all others H3. " + "Example: SECTION_HEADER", + ), + media_dir: Optional[Path] = typer.Option( + None, + "--media-dir", + help="Directory for extracted images (default: /media//)", + ), + skip_images: bool = typer.Option( + False, + "--skip-images", + help="Do not extract images from the PPTX", + ), + lesson_title: Optional[str] = typer.Option( + None, + "--lesson-title", + help="Override lesson post_title (default: first slide title or text)", + ), + slide_headings: bool = typer.Option( + True, + "--slide-headings/--no-slide-headings", + help="Before each slide's body, emit the slide title: H2 when the layout matches " + "--section-heading-layout (lesson-only) or the topic heading layout (lesson-with-topics), else H3 (default: on)", + ), +) -> None: + """PPTX → LearnDash bulk-import CSV.""" + positional = list(pptx_files) + from_option = [input_path] if input_path is not None else [] + + if positional and from_option: + typer.secho( + "Error: use either positional .pptx file(s) or --input, not both.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=2) + + inputs = positional if positional else from_option + + if list_layouts: + if len(inputs) == 0: + typer.secho( + "Error: provide exactly one .pptx (positional or --input) with --list-layouts.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=2) + if len(inputs) > 1: + typer.secho( + "Error: --list-layouts accepts only one .pptx file.", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=2) + p = inputs[0] + if p.suffix.lower() != ".pptx": + typer.secho("Error: file must be a .pptx.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + _list_layouts(p) + return + + if len(inputs) == 0: + typer.echo(ctx.get_help()) + raise typer.Exit(code=2) + + if len(inputs) > 1 and out is not None: + typer.secho( + "Error: --out is not supported with multiple input files (each output uses the input basename).", + fg=typer.colors.RED, + err=True, + ) + raise typer.Exit(code=2) + + for p in inputs: + if p.suffix.lower() != ".pptx": + typer.secho(f"Error: not a .pptx file: {p}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + + if mode not in ("lesson-only", "lesson-with-topics"): + typer.secho("Error: --mode must be lesson-only or lesson-with-topics.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + + if mode == "lesson-with-topics" and not (heading_layout and heading_layout.strip()): + typer.secho("Error: --heading-layout is required for lesson-with-topics.", fg=typer.colors.RED, err=True) + raise typer.Exit(code=2) + + cid = "" if course_id is None else str(course_id).strip() + multi_input = len(inputs) > 1 + topic_heading_regex = heading_layout.strip() if heading_layout else "" + + for input_path in inputs: + if out is not None and len(inputs) == 1: + out_path = out.resolve() + else: + out_path = input_path.with_suffix(".csv") + + if mode == "lesson-only": + _export_lesson_only( + input_path=input_path, + out=out_path, + course_id=cid, + section_heading_layout=section_heading_layout, + media_dir=media_dir, + skip_images=skip_images, + lesson_title=lesson_title, + slide_headings=slide_headings, + multi_input=multi_input, + ) + else: + _export_lesson_with_topics( + input_path=input_path, + out=out_path, + course_id=cid, + heading_layout_regex=topic_heading_regex, + media_dir=media_dir, + skip_images=skip_images, + lesson_title=lesson_title, + slide_headings=slide_headings, + multi_input=multi_input, + ) + + +if __name__ == "__main__": + app() diff --git a/tools/slides-to-learndash/slides_to_learndash/content_postprocess.py b/tools/slides-to-learndash/slides_to_learndash/content_postprocess.py new file mode 100644 index 000000000..c508fc833 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/content_postprocess.py @@ -0,0 +1,139 @@ +"""Post-process merged block HTML before CSV export.""" + +from __future__ import annotations + +import re + +from slides_to_learndash import blocks + +_COL_START = re.compile( + r'\s*
    ]*>', +) +_COL_END = "
    " + + +def _find_next_balanced_columns_block(html: str, start_at: int = 0) -> tuple[int, int] | None: + """Return [start, end) span of next wp:columns block at/after start_at.""" + start = html.find("", start_at) + if start == -1: + return None + depth = 0 + i = start + len("") + while True: + next_open = html.find("", i) + next_close = html.find("", i) + if next_close == -1: + return None + if next_open != -1 and next_open < next_close: + depth += 1 + i = next_open + len("") + continue + if depth == 0: + return (start, next_close + len("")) + depth -= 1 + i = next_close + len("") + + +def _split_column_inners(columns_inner: str) -> list[str]: + """Split merged column markup into inner HTML per column (matches blocks.columns_block_html).""" + inners: list[str] = [] + pos = 0 + while True: + m = _COL_START.search(columns_inner, pos) + if not m: + break + inner_start = m.end() + end = columns_inner.find(_COL_END, inner_start) + if end == -1: + break + inners.append(columns_inner[inner_start:end].strip()) + pos = end + len(_COL_END) + return inners + + +def _columns_inner(block: str) -> str | None: + """Return inner HTML between columns wrapper open and final /wp:columns in one block.""" + open_div = '
    ' + i = block.find(open_div) + if i == -1: + return None + inner_start = i + len(open_div) + close = block.rfind("
    ") + if close == -1 or close <= inner_start: + return None + return block[inner_start:close] + + +_LO_LABEL_PARA = re.compile( + r'^

    \s*(?:<(?:strong|b)\b[^>]*>)?\s*Learning\s+Objectives\s*(?:)?\s*

    ', + re.IGNORECASE | re.DOTALL, +) +_LO_LABEL_HEADING = re.compile( + r'^]*>\s*(?:<(?:strong|b)\b[^>]*>)?\s*Learning\s+Objectives\s*(?:)?\s*', + re.IGNORECASE | re.DOTALL, +) +_LEAD_OUTLINE_HEADING_BLOCK_AT_END = re.compile( + r']*>.*?\s*$', + re.IGNORECASE | re.DOTALL, +) + + +def _extract_lo_heading_and_list(objectives_column: str) -> tuple[str, str] | None: + """Return (heading block, list block) if column starts with LO label + list; else None.""" + s = objectives_column.strip() + m = _LO_LABEL_PARA.match(s) or _LO_LABEL_HEADING.match(s) + if not m: + return None + rest = s[m.end() :].lstrip() + if not rest.startswith(""): + return None + end_list = rest.find("") + if end_list == -1: + return None + end_list += len("") + list_block = rest[:end_list].strip() + heading = blocks.heading_block("Learning Objectives", 2) + return (heading, list_block) + + +def strip_session_outline_columns_keep_learning_objectives(html: str) -> str: + """Replace first matching outline-style columns row with full-width H2 + objectives list.""" + cursor = 0 + while True: + span = _find_next_balanced_columns_block(html, start_at=cursor) + if span is None: + return html + start, end = span + block = html[start:end] + if "Learning Objectives" not in block: + cursor = end + continue + inner = _columns_inner(block) + if inner is None: + cursor = end + continue + cols = _split_column_inners(inner) + if len(cols) < 2: + cursor = end + continue + + objectives_col: str | None = None + for c in cols: + if _extract_lo_heading_and_list(c) is not None: + objectives_col = c + break + if objectives_col is None: + cursor = end + continue + + extracted = _extract_lo_heading_and_list(objectives_col) + if extracted is None: + cursor = end + continue + heading_html, list_html = extracted + replacement = blocks.join_blocks([heading_html, list_html]) + prefix = html[:start] + # Remove an immediate leading H3 heading before the outline columns; it is + # redundant once LO is promoted to the top-level heading. + prefix = _LEAD_OUTLINE_HEADING_BLOCK_AT_END.sub("", prefix) + return prefix + replacement + html[end:] diff --git a/tools/slides-to-learndash/slides_to_learndash/export_csv.py b/tools/slides-to-learndash/slides_to_learndash/export_csv.py new file mode 100644 index 000000000..4eb43811c --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/export_csv.py @@ -0,0 +1,39 @@ +"""Write LearnDash bulk-plugin compatible CSV (single-line rows).""" + +from __future__ import annotations + +import csv +import io +from pathlib import Path +from typing import Any + +# Matches [bulk_template.csv](wordpress/.../bulk_template.csv) core columns used by create_content. +DEFAULT_HEADERS = [ + "ID", + "post_type", + "post_title", + "post_content", + "course_id", + "lesson_id", + "quiz_id", +] + + +def _row_dict_to_list(headers: list[str], row: dict[str, Any]) -> list[str]: + return [str(row.get(h, "") or "") for h in headers] + + +def write_csv( + path: Path, + rows: list[dict[str, Any]], + *, + headers: list[str] | None = None, +) -> None: + headers = headers or DEFAULT_HEADERS + path.parent.mkdir(parents=True, exist_ok=True) + buf = io.StringIO() + writer = csv.writer(buf, quoting=csv.QUOTE_MINIMAL, lineterminator="\n") + writer.writerow(headers) + for row in rows: + writer.writerow(_row_dict_to_list(headers, row)) + path.write_text(buf.getvalue(), encoding="utf-8") diff --git a/tools/slides-to-learndash/slides_to_learndash/extract.py b/tools/slides-to-learndash/slides_to_learndash/extract.py new file mode 100644 index 000000000..eb401bd76 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/extract.py @@ -0,0 +1,472 @@ +"""Extract structured text and images from python-pptx slides.""" + +from __future__ import annotations + +import html +from dataclasses import dataclass, field +from pathlib import Path +from typing import Union + +from pptx import Presentation +from pptx.enum.shapes import MSO_SHAPE_TYPE +from pptx.slide import Slide + +from slides_to_learndash import blocks +from slides_to_learndash.rich_text import ( + classify_paragraph, + paragraph_runs_to_html, + paragraph_to_code_plain, + plain_text_from_inner_html, + strip_bold_tags_from_inner_html, +) +from slides_to_learndash.slide_geometry import build_row_column_plan, collect_body_shape_boxes + +# Layout names that denote a section/divider slide (copyright footers appear here in many decks). +_SECTION_HEADER_MARKERS = frozenset({"SECTION_HEADER"}) + + +def _layout_looks_like_section_header(layout_name: str) -> bool: + n = layout_name.strip().upper().replace(" ", "_") + return any(m in n for m in _SECTION_HEADER_MARKERS) + + +def _is_copyright_segment(kind: str, payload: str) -> bool: + """Footer/legal lines that should not appear in lesson content.""" + if kind == "image": + return False + if kind == "code": + plain = payload.strip().lower() + else: + plain = plain_text_from_inner_html(payload).strip().lower() + if not plain: + return False + if "©" in plain or "(c)" in plain: + return True + if "all rights reserved" in plain: + return True + if "do not redistribute" in plain: + return True + if "copyright" in plain and ("reserved" in plain or "coding black females" in plain): + return True + return False + + +def _drop_copyright_segments( + segments: list[tuple[str, str]], *, layout_name: str +) -> list[tuple[str, str]]: + if not _layout_looks_like_section_header(layout_name): + return segments + return [s for s in segments if not _is_copyright_segment(s[0], s[1])] + + +@dataclass +class LinearBlock: + """Single horizontal band (full width): ordered segments.""" + + segments: list[tuple[str, str]] + + +@dataclass +class ColumnsBlock: + """One wp:columns row.""" + + widths: list[str] + columns: list[list[tuple[str, str]]] + + +SlideContentBlock = Union[LinearBlock, ColumnsBlock] + + +@dataclass +class SlideExtract: + index: int # 1-based + layout_name: str + title: str + content_blocks: list[SlideContentBlock] = field(default_factory=list) + """Row-ordered blocks: linear segments or multi-column segments.""" + image_paths: list[str] = field(default_factory=list) + + @property + def segments(self) -> list[tuple[str, str]]: + """Flatten all segments (for topic title fallback and image paths).""" + out: list[tuple[str, str]] = [] + for b in self.content_blocks: + if isinstance(b, LinearBlock): + out.extend(b.segments) + else: + for col in b.columns: + out.extend(col) + return out + + +def _shape_rich_segments(shape) -> list[tuple[str, str]]: + if not getattr(shape, "has_text_frame", False): + return [] + out: list[tuple[str, str]] = [] + # Track blank paragraphs between code paragraphs: a blank acts as a block separator. + blank_since_last_code = False + for paragraph in shape.text_frame.paragraphs: + kind = classify_paragraph(paragraph) + if kind is None: + blank_since_last_code = True + continue + if kind == "code": + text = paragraph_to_code_plain(paragraph) + # Merge into the previous code block only when it was truly adjacent + # (no intervening blank paragraph). + if out and out[-1][0] == "code" and not blank_since_last_code: + out[-1] = ("code", out[-1][1] + "\n" + text) + else: + out.append(("code", text)) + blank_since_last_code = False + continue + blank_since_last_code = False + if kind == "bullet": + inner = paragraph_runs_to_html(paragraph) + if not inner.strip(): + continue + out.append(("bullet", inner)) + elif kind == "h3": + inner = paragraph_runs_to_html(paragraph) + if not inner.strip(): + continue + out.append(("h3", inner)) + elif kind == "h4": + inner = paragraph_runs_to_html(paragraph) + if not inner.strip(): + continue + out.append(("h4", inner)) + else: + inner = paragraph_runs_to_html(paragraph) + if not inner.strip(): + continue + out.append(("p", inner)) + return out + + +def _segments_for_ordered_shapes( + ordered_shapes: list, + *, + media_dir: Path | None, + slide_index: int, + image_counter: list[int], + image_blob_cache: dict[bytes, str] | None = None, + media_url_prefix: str = "media", +) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for shape in ordered_shapes: + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and media_dir is not None: + rel = extract_picture( + shape, + media_dir=media_dir, + slide_index=slide_index, + image_counter=image_counter, + image_blob_cache=image_blob_cache, + media_url_prefix=media_url_prefix, + ) + if rel: + out.append(("image", rel)) + continue + out.extend(_shape_rich_segments(shape)) + return out + + +def _slide_title_text(slide: Slide) -> str: + if slide.shapes.title is not None: + t = (slide.shapes.title.text or "").strip() + if t: + return t + return "" + + +def extract_picture( + shape, + *, + media_dir: Path, + slide_index: int, + image_counter: list[int], + image_blob_cache: dict[bytes, str] | None = None, + media_url_prefix: str = "media", +) -> str | None: + if shape.shape_type != MSO_SHAPE_TYPE.PICTURE: + return None + image = shape.image + blob = image.blob + # Return the existing path if this exact image blob was already extracted. + if image_blob_cache is not None and blob in image_blob_cache: + return image_blob_cache[blob] + ext = (image.ext or "png").lower() + if ext not in ("png", "jpeg", "jpg", "gif", "webp", "tiff", "bmp"): + ext = "png" + image_counter[0] += 1 + n = image_counter[0] + fname = f"slide_{slide_index:03d}_img_{n:02d}.{ext}" + out = media_dir / fname + out.write_bytes(blob) + prefix = (media_url_prefix or "media").strip().strip("/") + rel = f"{prefix}/{fname}" + if image_blob_cache is not None: + image_blob_cache[blob] = rel + return rel + + +def _legacy_linear_blocks( + slide: Slide, + *, + title_shape, + media_dir: Path | None, + slide_index: int, + image_counter: list[int], + image_blob_cache: dict[bytes, str] | None = None, + media_url_prefix: str = "media", +) -> list[SlideContentBlock]: + """Enumerate body shapes in file order (single column).""" + segments: list[tuple[str, str]] = [] + for shape in slide.shapes: + if title_shape is not None and shape == title_shape: + continue + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE and media_dir is not None: + rel = extract_picture( + shape, + media_dir=media_dir, + slide_index=slide_index, + image_counter=image_counter, + image_blob_cache=image_blob_cache, + media_url_prefix=media_url_prefix, + ) + if rel: + segments.append(("image", rel)) + continue + segments.extend(_shape_rich_segments(shape)) + return [LinearBlock(segments)] if segments else [] + + +def _flatten_all_segments(content_blocks: list[SlideContentBlock]) -> list[tuple[str, str]]: + out: list[tuple[str, str]] = [] + for b in content_blocks: + if isinstance(b, LinearBlock): + out.extend(b.segments) + else: + for col in b.columns: + out.extend(col) + return out + + +def _apply_copyright_to_blocks( + blocks_: list[SlideContentBlock], *, layout_name: str +) -> list[SlideContentBlock]: + out: list[SlideContentBlock] = [] + for b in blocks_: + if isinstance(b, LinearBlock): + out.append(LinearBlock(_drop_copyright_segments(b.segments, layout_name=layout_name))) + else: + out.append( + ColumnsBlock( + b.widths, + [_drop_copyright_segments(c, layout_name=layout_name) for c in b.columns], + ) + ) + return out + + +def extract_slide( + slide: Slide, + slide_index: int, + *, + media_dir: Path | None, + image_counter: list[int], + image_blob_cache: dict[bytes, str] | None = None, + media_url_prefix: str = "media", +) -> SlideExtract: + layout_name = slide.slide_layout.name.strip() + title = _slide_title_text(slide) + title_shape = slide.shapes.title + + boxes = collect_body_shape_boxes(slide, title_shape=title_shape, media_dir=media_dir) + + if not boxes: + content_blocks = _legacy_linear_blocks( + slide, + title_shape=title_shape, + media_dir=media_dir, + slide_index=slide_index, + image_counter=image_counter, + image_blob_cache=image_blob_cache, + media_url_prefix=media_url_prefix, + ) + else: + plan = build_row_column_plan(boxes) + content_blocks = [] + for cols, widths in plan: + if len(cols) == 1: + shapes = [sb.shape for sb in cols[0]] + segs = _segments_for_ordered_shapes( + shapes, + media_dir=media_dir, + slide_index=slide_index, + image_counter=image_counter, + image_blob_cache=image_blob_cache, + media_url_prefix=media_url_prefix, + ) + content_blocks.append(LinearBlock(segs)) + else: + col_segments: list[list[tuple[str, str]]] = [] + for col in cols: + shapes = [sb.shape for sb in col] + col_segments.append( + _segments_for_ordered_shapes( + shapes, + media_dir=media_dir, + slide_index=slide_index, + image_counter=image_counter, + image_blob_cache=image_blob_cache, + media_url_prefix=media_url_prefix, + ) + ) + content_blocks.append(ColumnsBlock(widths, col_segments)) + + content_blocks = _apply_copyright_to_blocks(content_blocks, layout_name=layout_name) + + is_section_header = _layout_looks_like_section_header(layout_name) + if is_section_header: + # Section divider slides: only the slide title (header) is used elsewhere as a heading; + # omit body text and all images (e.g. footer brand icons). + content_blocks = [] + + flat = _flatten_all_segments(content_blocks) + if not flat and title: + if not is_section_header: + content_blocks = [LinearBlock([("p", html.escape(title))])] + flat = _flatten_all_segments(content_blocks) + + rel_images = [s[1] for s in flat if s[0] == "image"] + return SlideExtract( + index=slide_index, + layout_name=layout_name, + title=title, + content_blocks=content_blocks, + image_paths=rel_images, + ) + + +def _segment_plain_for_dedupe(kind: str, payload: str) -> str: + if kind == "image": + return "" + if kind == "code": + return " ".join(payload.strip().split()).casefold() + return plain_text_from_inner_html(payload).strip().casefold() + + +def dedupe_leading_title_duplicates( + segments: list[tuple[str, str]], + *, + slide_title: str, + include_slide_heading: bool, +) -> list[tuple[str, str]]: + """Remove leading body segments that repeat the slide title (already emitted as a heading).""" + if not include_slide_heading or not (slide_title or "").strip(): + return segments + title_norm = slide_title.strip().casefold() + out = list(segments) + while out: + k, pl = out[0] + if k == "image": + break + plain = _segment_plain_for_dedupe(k, pl) + if plain and plain == title_norm: + out.pop(0) + continue + break + return out + + +def segments_to_html_parts(segments: list[tuple[str, str]]) -> list[str]: + """Convert segments to block HTML fragments (preserves order).""" + parts: list[str] = [] + bullet_run: list[str] = [] + + def flush_bullets() -> None: + nonlocal bullet_run + if bullet_run: + parts.append(blocks.list_block_html(bullet_run)) + bullet_run = [] + + for kind, payload in segments: + if kind == "image": + flush_bullets() + parts.append(blocks.image_block(payload, alt="")) + elif kind == "bullet": + bullet_run.append(payload) + elif kind == "code": + flush_bullets() + parts.append(blocks.code_block_plain(payload)) + elif kind == "h3": + flush_bullets() + parts.append(blocks.heading_block_html(strip_bold_tags_from_inner_html(payload), 3)) + elif kind == "h4": + flush_bullets() + parts.append(blocks.heading_block_html(strip_bold_tags_from_inner_html(payload), 4)) + elif kind == "p": + flush_bullets() + parts.append(blocks.paragraph_block_html(payload)) + flush_bullets() + + return [p for p in parts if p] + + +def slide_content_blocks_to_html( + content_blocks: list[SlideContentBlock], + *, + slide_title: str, + include_slide_heading: bool, +) -> list[str]: + """Render row-ordered blocks to HTML fragments.""" + out: list[str] = [] + for b in content_blocks: + if isinstance(b, LinearBlock): + segs = dedupe_leading_title_duplicates( + b.segments, + slide_title=slide_title, + include_slide_heading=include_slide_heading, + ) + out.extend(segments_to_html_parts(segs)) + else: + col_htmls: list[str] = [] + for col in b.columns: + segs = dedupe_leading_title_duplicates( + col, + slide_title=slide_title, + include_slide_heading=include_slide_heading, + ) + col_htmls.append(blocks.join_blocks(segments_to_html_parts(segs))) + out.append(blocks.columns_block_html(col_htmls, b.widths)) + return [p for p in out if p] + + +def slide_to_post_html( + sl: SlideExtract, + *, + include_slide_heading: bool = False, + slide_heading_level: int = 3, +) -> str: + """Build block HTML for one slide's content.""" + if slide_heading_level < 1: + slide_heading_level = 1 + if slide_heading_level > 6: + slide_heading_level = 6 + parts: list[str] = [] + if include_slide_heading: + label = sl.title or f"Slide {sl.index}" + parts.append(blocks.heading_block(label, level=slide_heading_level)) + parts.extend( + slide_content_blocks_to_html( + sl.content_blocks, + slide_title=sl.title, + include_slide_heading=include_slide_heading, + ) + ) + return blocks.join_blocks(parts) + + +def load_presentation(path: Path) -> Presentation: + return Presentation(str(path)) diff --git a/tools/slides-to-learndash/slides_to_learndash/pipeline.py b/tools/slides-to-learndash/slides_to_learndash/pipeline.py new file mode 100644 index 000000000..72913dbd9 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/pipeline.py @@ -0,0 +1,237 @@ +"""Build lesson/topic rows from PPTX extraction.""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Any, Literal + +from pptx import Presentation + +from slides_to_learndash import blocks +from slides_to_learndash.content_postprocess import strip_session_outline_columns_keep_learning_objectives +from slides_to_learndash.extract import SlideExtract, extract_slide, slide_to_post_html +from slides_to_learndash.slide_merge import ( + include_slide_heading_vs_previous, + merge_and_coalesce_slides, +) +from slides_to_learndash.slide_visibility import slide_is_hidden + +Mode = Literal["lesson-only", "lesson-with-topics"] + + +def _compile_layout_pattern(pattern: str) -> re.Pattern[str]: + try: + return re.compile(pattern) + except re.error as e: + raise ValueError(f"Invalid --heading-layout regex: {e}") from e + + +def _is_heading(sl: SlideExtract, rx: re.Pattern[str]) -> bool: + return rx.fullmatch(sl.layout_name.strip()) is not None + + +def _looks_like_section_layout(layout_name: str) -> bool: + """Heuristic fallback for section/divider slides in lesson-only mode.""" + n = layout_name.strip().upper() + return n.startswith("SECTION_") + + +def _lesson_title_from_deck(slides: list[SlideExtract], explicit: str | None) -> str: + if explicit and explicit.strip(): + return explicit.strip() + if slides: + if slides[0].title: + return slides[0].title + # first non-empty text segment + for kind, text in slides[0].segments: + if kind != "image" and text.strip(): + return text.strip()[:200] + return "Imported lesson" + + +def _topic_title(sl: SlideExtract, fallback: str) -> str: + if sl.title.strip(): + return sl.title.strip() + for kind, text in sl.segments: + if kind != "image" and text.strip(): + return text.strip()[:200] + return fallback + + +def build_slides( + prs: Presentation, + *, + media_dir: Path | None, + skip_images: bool, + media_url_prefix: str = "media", +) -> list[SlideExtract]: + counter = [0] + blob_cache: dict[bytes, str] = {} + mdir = None if skip_images else media_dir + prefix = media_url_prefix if mdir is not None else "media" + out: list[SlideExtract] = [] + for i, slide in enumerate(prs.slides, start=1): + if slide_is_hidden(slide): + continue + out.append( + extract_slide( + slide, + i, + media_dir=mdir, + image_counter=counter, + image_blob_cache=blob_cache, + media_url_prefix=prefix, + ) + ) + return out + + +def _slides_after_cover(slides: list[SlideExtract]) -> list[SlideExtract]: + """Skip slide 1 (cover); it is not included in post_content.""" + return slides[1:] if len(slides) > 1 else [] + + +def run_lesson_only( + slides: list[SlideExtract], + *, + course_id: str, + lesson_title: str | None, + include_slide_headings: bool, + section_heading_layout_regex: str | None, +) -> list[dict[str, Any]]: + content_slides = merge_and_coalesce_slides(_slides_after_cover(slides)) + rx = ( + _compile_layout_pattern(section_heading_layout_regex.strip()) + if section_heading_layout_regex and section_heading_layout_regex.strip() + else None + ) + sections: list[str] = [] + prev: SlideExtract | None = None + for s in content_slides: + # Section/divider slides → H2; other content slides → H3 (cover is already skipped). + is_section = ( + rx.fullmatch(s.layout_name.strip()) is not None + if rx is not None + else _looks_like_section_layout(s.layout_name) + ) + lvl = 2 if is_section else 3 + sections.append( + slide_to_post_html( + s, + include_slide_heading=include_slide_heading_vs_previous( + s, prev, include_slide_headings=include_slide_headings + ), + slide_heading_level=lvl, + ) + ) + prev = s + body = blocks.merge_slide_sections(sections, slide_separator=False) + body = strip_session_outline_columns_keep_learning_objectives(body) + title = _lesson_title_from_deck(slides, lesson_title) + return [ + { + "ID": "", + "post_type": "sfwd-lessons", + "post_title": title, + "post_content": body, + "course_id": course_id, + "lesson_id": "", + "quiz_id": "", + } + ] + + +def run_lesson_with_topics( + slides: list[SlideExtract], + *, + course_id: str, + lesson_title: str | None, + heading_layout_regex: str, + include_slide_headings: bool, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + rx = _compile_layout_pattern(heading_layout_regex) + heading_indices = [i for i, s in enumerate(slides) if _is_heading(s, rx)] + + if not heading_indices: + # All slides → single lesson; no topic rows + lesson_rows = run_lesson_only( + slides, + course_id=course_id, + lesson_title=lesson_title, + include_slide_headings=include_slide_headings, + section_heading_layout_regex=heading_layout_regex, + ) + return lesson_rows, [] + + first_h = heading_indices[0] + pre = slides[:first_h] + # Cover (index 0) is never part of lesson body; remaining pre-heading slides get optional slide titles. + lesson_body_slides = merge_and_coalesce_slides( + pre[1:] if len(pre) > 1 else [] + ) + lesson_sections: list[str] = [] + prev_lb: SlideExtract | None = None + for s in lesson_body_slides: + lesson_sections.append( + slide_to_post_html( + s, + include_slide_heading=include_slide_heading_vs_previous( + s, prev_lb, include_slide_headings=include_slide_headings + ), + slide_heading_level=2 + if rx.fullmatch(s.layout_name.strip()) is not None + else 3, + ) + ) + prev_lb = s + lesson_body = blocks.merge_slide_sections(lesson_sections, slide_separator=False) if lesson_sections else "" + lesson_body = strip_session_outline_columns_keep_learning_objectives(lesson_body) + + lesson_row = { + "ID": "", + "post_type": "sfwd-lessons", + "post_title": _lesson_title_from_deck(pre if pre else slides, lesson_title), + "post_content": lesson_body, + "course_id": course_id, + "lesson_id": "", + "quiz_id": "", + } + + topic_rows: list[dict[str, Any]] = [] + for ti, h_idx in enumerate(heading_indices): + end = heading_indices[ti + 1] if ti + 1 < len(heading_indices) else len(slides) + chunk = merge_and_coalesce_slides(slides[h_idx:end]) + if not chunk: + continue + heading_sl = chunk[0] + title = _topic_title(heading_sl, f"Topic {ti + 1}") + parts: list[str] = [] + prev_ch: SlideExtract | None = None + for idx, s in enumerate(chunk): + lvl = 2 if idx == 0 else 3 + parts.append( + slide_to_post_html( + s, + include_slide_heading=include_slide_heading_vs_previous( + s, prev_ch, include_slide_headings=include_slide_headings + ), + slide_heading_level=lvl, + ) + ) + prev_ch = s + body = blocks.merge_slide_sections([p for p in parts if p], slide_separator=False) + body = strip_session_outline_columns_keep_learning_objectives(body) + topic_rows.append( + { + "ID": "", + "post_type": "sfwd-topic", + "post_title": title, + "post_content": body, + "course_id": course_id, + "lesson_id": "", + "quiz_id": "", + } + ) + + return [lesson_row], topic_rows diff --git a/tools/slides-to-learndash/slides_to_learndash/rich_text.py b/tools/slides-to-learndash/slides_to_learndash/rich_text.py new file mode 100644 index 000000000..29421bcc3 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/rich_text.py @@ -0,0 +1,206 @@ +"""Map PowerPoint paragraphs/runs to HTML for WordPress blocks.""" + +from __future__ import annotations + +import html +import re +from typing import Any + +from pptx.oxml.ns import qn +from pptx.util import Length + +_TAG_STRIP = re.compile(r"<[^>]+>") + + +def strip_bold_tags_from_inner_html(inner: str) -> str: + """Remove / wrappers for heading inner HTML; keep other markup.""" + s = inner + while True: + prev = s + s = re.sub(r"]*>(.*?)", r"\1", s, flags=re.IGNORECASE | re.DOTALL) + s = re.sub(r"]*>(.*?)", r"\1", s, flags=re.IGNORECASE | re.DOTALL) + if s == prev: + break + return s + + +def plain_text_from_inner_html(inner: str) -> str: + """Strip tags for title dedupe / comparison.""" + t = _TAG_STRIP.sub("", inner) + t = html.unescape(t) + return " ".join(t.split()) + + +def _paragraph_is_bullet(paragraph: Any) -> bool: + """True when OOXML marks the paragraph as a list item. + + Google Slides (and some exports) use ``a:buChar`` / ``a:buAutoNum`` / ``a:buBlip`` + instead of ``a:numPr``. ``buFont`` alone is *not* sufficient — it appears on + non-bullets together with ``a:buNone``. + """ + p = paragraph._element + ppr = p.pPr + if ppr is None: + return False + if getattr(ppr, "numPr", None) is not None: + return True + for tag in ("a:buChar", "a:buAutoNum", "a:buBlip"): + if ppr.find(qn(tag)) is not None: + return True + lvl = getattr(paragraph, "level", None) + return bool(lvl and lvl > 0) + + +def _run_is_monospace(run: Any) -> bool: + n = (run.font.name or "").lower() + if not n: + return False + keys = ( + "mono", "consolas", "courier", "monaco", "menlo", + "source code", "lucida console", "inconsolata", "fira code", + "jetbrains", "cascadia", "hack", "iosevka", "droid sans mono", + ) + return any(k in n for k in keys) + + +def _run_size_pt(run: Any) -> float | None: + sz = run.font.size + if sz is None: + return None + if isinstance(sz, Length): + return float(sz.pt) + return None + + +def _paragraph_is_code(paragraph: Any) -> bool: + runs = [r for r in paragraph.runs if r.text and r.text.strip()] + if not runs: + return False + return all(_run_is_monospace(r) for r in runs) + + +def _paragraph_max_pt(paragraph: Any) -> float | None: + pts = [_run_size_pt(r) for r in paragraph.runs if r.text] + pts = [p for p in pts if p is not None] + return max(pts) if pts else None + + +def paragraph_heading_level(paragraph: Any) -> int | None: + """Treat larger / emphasized body text as sub-headings (h3/h4).""" + if _paragraph_is_bullet(paragraph) or _paragraph_is_code(paragraph): + return None + runs = [r for r in paragraph.runs if r.text and r.text.strip()] + if not runs: + return None + r0 = runs[0] + pt = _paragraph_max_pt(paragraph) or _run_size_pt(r0) + bold = bool(r0.font.bold) + if pt is None: + return None + # Typical body ~14–18pt; deck titles/subtitles often 20pt+. + if pt >= 28 or (pt >= 24 and bold): + return 3 + if pt >= 22 or (pt >= 20 and bold): + return 4 + return None + + +def _run_has_visible_text(run: Any) -> bool: + """True if the run has non-whitespace characters (link labels users can read).""" + return bool((run.text or "").strip()) + + +def _run_hyperlink_url(run: Any) -> str | None: + """Return URL from a text run hyperlink, if any.""" + try: + h = run.hyperlink + addr = h.address + if addr: + return str(addr).strip() + except (AttributeError, TypeError, ValueError): + pass + return None + + +def _sanitize_href(url: str) -> str | None: + """Return HTML-attribute-safe href or None if the URL must be dropped.""" + u = (url or "").strip() + if not u: + return None + low = u.lower() + scheme = low.split(":", 1)[0] if ":" in low else "" + if scheme in ("javascript", "data", "vbscript"): + return None + if low.startswith("file:"): + return None + return html.escape(u, quote=True) + + +def _wrap_anchor(inner_html: str, url: str | None) -> str: + """Wrap inline HTML in when the URL is safe for WordPress.""" + if not url: + return inner_html + safe = _sanitize_href(url) + if not safe: + return inner_html + low = url.lower().strip() + if low.startswith(("http://", "https://")): + return ( + f'{inner_html}' + ) + return f'{inner_html}' + + +def format_run_html(run: Any) -> str: + """Single run → inline HTML (escaped text + semantic tags + optional hyperlink). + + Hyperlinks on whitespace-only runs (layout/logo hit areas) are omitted — only + human-visible link text is converted to ```` tags. + """ + raw = run.text or "" + if not _run_has_visible_text(run): + return "" + url = _run_hyperlink_url(run) + t = html.escape(raw) + if run.font.bold: + t = f"{t}" + if run.font.italic: + t = f"{t}" + # PowerPoint usually sets underline on linked runs; styling is enough for WordPress. + if getattr(run.font, "underline", None) and not url: + t = f"{t}" + if getattr(run.font, "strike", None): + t = f"{t}" + if _run_is_monospace(run): + t = f"{t}" + return _wrap_anchor(t, url) + + +def paragraph_runs_to_html(paragraph: Any) -> str: + return "".join(format_run_html(r) for r in paragraph.runs if _run_has_visible_text(r)) + + +def paragraph_to_code_plain(paragraph: Any) -> str: + """Preserve line breaks inside code paragraph.""" + return (paragraph.text or "").replace("\r\n", "\n").replace("\r", "\n") + + +def classify_paragraph(paragraph: Any) -> str | None: + """Return segment kind: bullet, code, h3, h4, p — or None if empty.""" + text = (paragraph.text or "").strip() + if not text: + return None + # Code detection wins over bullet: monospace font is the authoritative signal, + # even when indentation level > 0 triggers the generic bullet heuristic. + if _paragraph_is_code(paragraph): + return "code" + if _paragraph_is_bullet(paragraph): + return "bullet" + hl = paragraph_heading_level(paragraph) + if hl == 3: + return "h3" + if hl == 4: + return "h4" + return "p" + + diff --git a/tools/slides-to-learndash/slides_to_learndash/slide_geometry.py b/tools/slides-to-learndash/slides_to_learndash/slide_geometry.py new file mode 100644 index 000000000..7f8dcde3f --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/slide_geometry.py @@ -0,0 +1,275 @@ +"""Geometry-based row/column layout for slide shapes (PPTX EMU coordinates).""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from pptx.enum.shapes import MSO_SHAPE_TYPE + +# Vertical overlap / min(height) above this ⇒ same row band. +_ROW_OVERLAP_MIN = 0.40 +# Horizontal overlap / min(width) above this ⇒ same column (stacked shapes). +_COL_H_OVERLAP_MIN = 0.38 +# Minimum horizontal gap (EMU) between column boxes to force separate columns when overlap is ambiguous. +_MIN_COLUMN_GAP_EMU = 36_000 # ~0.04 in at 914400 EMU/in +# Treat as side-by-side columns: horizontal overlap smaller than this (EMU) ⇒ not stacked in one column. +_MAX_X_OVERLAP_FOR_COLUMN_PAIR_EMU = 45_000 # ~0.05 in + +SlideShapeKind = Literal["text", "picture"] + + +@dataclass +class ShapeBox: + """Bounding box + reference to the underlying shape.""" + + shape: Any + left: int + top: int + width: int + height: int + kind: SlideShapeKind + + +def _bottom(sb: ShapeBox) -> int: + return sb.top + sb.height + + +def _right(sb: ShapeBox) -> int: + return sb.left + sb.width + + +def _vertical_overlap_ratio(a: ShapeBox, b: ShapeBox) -> float: + """Intersection height / min(h1, h2).""" + top_i = max(a.top, b.top) + bot_i = min(_bottom(a), _bottom(b)) + inter = max(0, bot_i - top_i) + h_min = min(a.height, b.height) + if h_min <= 0: + return 0.0 + return inter / h_min + + +def _x_interval_overlap_emu(a: ShapeBox, b: ShapeBox) -> int: + """Width of horizontal intersection of two boxes (EMU).""" + left_i = max(a.left, b.left) + right_i = min(_right(a), _right(b)) + return max(0, right_i - left_i) + + +def _horizontal_overlap_ratio(a: ShapeBox, b: ShapeBox) -> float: + """Intersection width / min(w1, w2).""" + left_i = max(a.left, b.left) + right_i = min(_right(a), _right(b)) + inter = max(0, right_i - left_i) + w_min = min(a.width, b.width) + if w_min <= 0: + return 0.0 + return inter / w_min + + +def _side_by_side_column_pair(a: ShapeBox, b: ShapeBox) -> bool: + """Two shapes that read as left/right columns: almost no horizontal overlap, but y-ranges intersect. + + Catches cases where vertical_overlap / min(height) is below _ROW_OVERLAP_MIN because one box is + much taller than the other (short label beside a tall body text), which would otherwise split + into separate rows and lose wp:columns. + """ + if _x_interval_overlap_emu(a, b) > _MAX_X_OVERLAP_FOR_COLUMN_PAIR_EMU: + return False + top_i = max(a.top, b.top) + bot_i = min(_bottom(a), _bottom(b)) + return bot_i > top_i + + +class _UnionFind: + __slots__ = ("parent",) + + def __init__(self, n: int) -> None: + self.parent = list(range(n)) + + def find(self, x: int) -> int: + p = self.parent + while p[x] != x: + p[x] = p[p[x]] + x = p[x] + return x + + def union(self, a: int, b: int) -> None: + ra, rb = self.find(a), self.find(b) + if ra != rb: + self.parent[ra] = rb + + +def cluster_row_indices(boxes: list[ShapeBox]) -> list[list[int]]: + """Group shape indices into rows using vertical overlap (union-find).""" + n = len(boxes) + if n == 0: + return [] + if n == 1: + return [[0]] + uf = _UnionFind(n) + for i in range(n): + for j in range(i + 1, n): + if _vertical_overlap_ratio(boxes[i], boxes[j]) >= _ROW_OVERLAP_MIN: + uf.union(i, j) + # Second pass: side-by-side column pairs with any vertical intersection (fixes short+tall column rows). + for i in range(n): + for j in range(i + 1, n): + if _side_by_side_column_pair(boxes[i], boxes[j]): + uf.union(i, j) + buckets: dict[int, list[int]] = {} + for i in range(n): + r = uf.find(i) + buckets.setdefault(r, []).append(i) + rows = list(buckets.values()) + rows.sort(key=lambda idxs: min(boxes[i].top for i in idxs)) + for idxs in rows: + idxs.sort(key=lambda i: boxes[i].left) + return rows + + +def assign_columns_in_row(row_boxes: list[ShapeBox]) -> list[list[ShapeBox]]: + """Split shapes in one row into side-by-side columns; merge stacked shapes into one column.""" + if len(row_boxes) <= 1: + return [[row_boxes[0]]] if row_boxes else [] + ordered = sorted(row_boxes, key=lambda s: (s.left, s.top)) + columns: list[list[ShapeBox]] = [] + for sb in ordered: + placed = False + for col in columns: + ref = col[0] + hov = _horizontal_overlap_ratio(sb, ref) + if hov >= _COL_H_OVERLAP_MIN: + col.append(sb) + col.sort(key=lambda s: s.top) + placed = True + break + # Side-by-side: little x-overlap, but check gap from column bbox + lo, hi = min(s.left for s in col), max(_right(s) for s in col) + gap = sb.left - hi + if gap >= _MIN_COLUMN_GAP_EMU and hov < 0.12: + continue + if not placed: + columns.append([sb]) + columns.sort(key=lambda col: min(s.left for s in col)) + for col in columns: + col.sort(key=lambda s: s.top) + return columns + + +def column_width_fractions(columns: list[list[ShapeBox]]) -> list[float]: + """Relative widths from column bounding boxes (sums to 1).""" + if not columns: + return [] + spans: list[int] = [] + for col in columns: + lo = min(s.left for s in col) + hi = max(_right(s) for s in col) + spans.append(max(1, hi - lo)) + total = sum(spans) + if total <= 0: + n = len(columns) + return [1.0 / n] * n + return [s / total for s in spans] + + +_PRESETS_2 = ( + ("50%", "50%"), + ("33.33%", "66.66%"), + ("66.66%", "33.33%"), +) + +_PRESETS_3 = ( + ("33.33%", "33.33%", "33.34%"), + ("25%", "50%", "25%"), +) + + +def _dist(a: tuple[float, ...], b: tuple[float, ...]) -> float: + return sum((x - y) ** 2 for x, y in zip(a, b, strict=True)) + + +def widths_to_preset(fractions: list[float]) -> list[str]: + """Map measured column fractions to the closest core Columns preset.""" + n = len(fractions) + if n == 0: + return [] + if n == 1: + return ["100%"] + if n == 2: + f0, f1 = fractions[0], fractions[1] + target = (f0, f1) + best = _PRESETS_2[0] + best_d = _dist(target, (0.5, 0.5)) + cand = ((0.3333, 0.6667), (0.6667, 0.3333)) + for i, t in enumerate(cand): + d = _dist(target, t) + if d < best_d: + best_d = d + best = _PRESETS_2[i + 1] + return list(best) + if n == 3: + target = tuple(fractions) + best = _PRESETS_3[0] + best_d = _dist(target, (1 / 3, 1 / 3, 1 / 3)) + d2 = _dist(target, (0.25, 0.5, 0.25)) + if d2 < best_d: + best = _PRESETS_3[1] + return list(best) + # 4+ columns: equal split (percent strings sum ~100) + p = 100.0 / n + return [f"{p:.2f}%" for _ in range(n)] + + +def build_row_column_plan(boxes: list[ShapeBox]) -> list[tuple[list[list[ShapeBox]], list[str]]]: + """Return ordered rows; each row is (columns_of_shapeboxes, width_percent_strings).""" + if not boxes: + return [] + row_ixs = cluster_row_indices(boxes) + plan: list[tuple[list[list[ShapeBox]], list[str]]] = [] + for ixs in row_ixs: + row_boxes = [boxes[i] for i in ixs] + cols = assign_columns_in_row(row_boxes) + fr = column_width_fractions(cols) + widths = widths_to_preset(fr) + plan.append((cols, widths)) + return plan + + +def collect_body_shape_boxes( + slide, + *, + title_shape, + media_dir, +) -> list[ShapeBox]: + """Non-title shapes that contribute text or exported pictures.""" + out: list[ShapeBox] = [] + for shape in slide.shapes: + if title_shape is not None and shape == title_shape: + continue + if shape.shape_type == MSO_SHAPE_TYPE.PICTURE: + if media_dir is not None: + out.append( + ShapeBox( + shape=shape, + left=int(shape.left), + top=int(shape.top), + width=int(shape.width), + height=int(shape.height), + kind="picture", + ) + ) + continue + if getattr(shape, "has_text_frame", False) and (shape.text_frame.text or "").strip(): + out.append( + ShapeBox( + shape=shape, + left=int(shape.left), + top=int(shape.top), + width=int(shape.width), + height=int(shape.height), + kind="text", + ) + ) + return out diff --git a/tools/slides-to-learndash/slides_to_learndash/slide_merge.py b/tools/slides-to-learndash/slides_to_learndash/slide_merge.py new file mode 100644 index 000000000..3255d4cda --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/slide_merge.py @@ -0,0 +1,282 @@ +"""Merge consecutive slides with the same title (column continuation + heading dedupe support).""" + +from __future__ import annotations + +from dataclasses import replace + +from slides_to_learndash.extract import ColumnsBlock, LinearBlock, SlideContentBlock, SlideExtract +from slides_to_learndash.rich_text import plain_text_from_inner_html + + +def _parse_width_percent(s: str) -> float: + t = s.strip() + if t.endswith("%"): + try: + return float(t[:-1]) + except ValueError: + return -1.0 + return -1.0 + + +def widths_layout_compatible(a: list[str], b: list[str]) -> bool: + """True if column counts match and width presets are close (EMU noise → different % strings).""" + if len(a) != len(b): + return False + if a == b: + return True + for wa, wb in zip(a, b, strict=True): + pa, pb = _parse_width_percent(wa), _parse_width_percent(wb) + if pa < 0 or pb < 0: + return False + if abs(pa - pb) > 4.0: + return False + return True + + +def titles_equivalent(a: str, b: str) -> bool: + """True if both titles are non-empty and equal after trim + casefold.""" + x = (a or "").strip() + y = (b or "").strip() + if not x or not y: + return False + return x.casefold() == y.casefold() + + +def titles_allow_column_merge(prev: SlideExtract, curr: SlideExtract) -> bool: + """Same-title slides merge; continuation slides often have an empty title placeholder.""" + pt, ct = (prev.title or "").strip(), (curr.title or "").strip() + if not pt: + return False + if not ct: + return True + return pt.casefold() == ct.casefold() + + +def _segment_fingerprint(kind: str, payload: str) -> tuple[str, str]: + if kind == "image": + return ("image", payload.strip()) + if kind == "code": + return ("code", " ".join(payload.strip().split()).casefold()) + return (kind, plain_text_from_inner_html(payload).strip().casefold()) + + +def segments_equal(seg_a: tuple[str, str], seg_b: tuple[str, str]) -> bool: + return _segment_fingerprint(seg_a[0], seg_a[1]) == _segment_fingerprint( + seg_b[0], seg_b[1] + ) + + +def _column_plain_join(segments: list[tuple[str, str]]) -> str: + """Single signature for duplicate detection when HTML differs but text is the same.""" + parts: list[str] = [] + for k, pl in segments: + fp = _segment_fingerprint(k, pl) + parts.append(f"{fp[0]}\0{fp[1]}") + return "\n".join(parts) + + +def merge_column_segments( + a: list[tuple[str, str]], b: list[tuple[str, str]] +) -> list[tuple[str, str]]: + """Append column b onto a after dropping the longest common prefix of segment tuples.""" + if not b: + return list(a) + if not a: + return list(b) + # Identical column body (e.g. same bullets with different markup) — keep one copy. + if _column_plain_join(a) == _column_plain_join(b): + return list(a) + lp = 0 + while lp < len(a) and lp < len(b) and segments_equal(a[lp], b[lp]): + lp += 1 + raw = a + b[lp:] + return _dedupe_consecutive_identical_images(raw) + + +def _dedupe_consecutive_identical_images( + segments: list[tuple[str, str]], +) -> list[tuple[str, str]]: + """Drop back-to-back image segments with the same src (stacked continuation slides).""" + out: list[tuple[str, str]] = [] + for seg in segments: + if ( + out + and seg[0] == "image" + and out[-1][0] == "image" + and seg[1] == out[-1][1] + ): + continue + out.append(seg) + return out + + +def _flatten_columns_only_slide( + slide: SlideExtract, +) -> tuple[list[list[tuple[str, str]]], list[str]] | None: + """Stack every ColumnsBlock row into one segment list per column (same slide). + + Empty LinearBlocks (e.g. trailing placeholder rows with no content) are ignored so + that slides whose only non-empty blocks are ColumnsBlocks can still be merged. + """ + if not slide.content_blocks: + return None + for b in slide.content_blocks: + if isinstance(b, LinearBlock) and not b.segments: + continue + if not isinstance(b, ColumnsBlock): + return None + active_blocks = [ + b for b in slide.content_blocks + if not (isinstance(b, LinearBlock) and not b.segments) + ] + first = active_blocks[0] + n = len(first.columns) + w = first.widths + cols: list[list[tuple[str, str]]] = [[] for _ in range(n)] + for block in active_blocks: + if len(block.columns) != n: + return None + if not widths_layout_compatible(w, block.widths): + return None + for j in range(n): + cols[j].extend(block.columns[j]) + cols = [_dedupe_consecutive_identical_images(c) for c in cols] + return cols, w + + +def mergeable_column_pair(prev: SlideExtract, curr: SlideExtract) -> bool: + """Merge when both slides are columns-only and column counts / widths match.""" + if not titles_allow_column_merge(prev, curr): + return False + fa = _flatten_columns_only_slide(prev) + fb = _flatten_columns_only_slide(curr) + if fa is None or fb is None: + return False + cols_a, wa = fa + cols_b, wb = fb + if len(cols_a) != len(cols_b): + return False + return widths_layout_compatible(wa, wb) + + +def _merge_two_slides(prev: SlideExtract, curr: SlideExtract) -> SlideExtract: + fa = _flatten_columns_only_slide(prev) + fb = _flatten_columns_only_slide(curr) + assert fa is not None and fb is not None + cols_a, wa = fa + cols_b, wb = fb + merged_cols = [ + merge_column_segments(a, b) for a, b in zip(cols_a, cols_b, strict=True) + ] + merged_paths = list(dict.fromkeys(prev.image_paths + curr.image_paths)) + return SlideExtract( + index=prev.index, + layout_name=prev.layout_name, + title=prev.title, + content_blocks=[ColumnsBlock(widths=list(wa), columns=merged_cols)], + image_paths=merged_paths, + ) + + +def merge_consecutive_same_title_slides(slides: list[SlideExtract]) -> list[SlideExtract]: + """Merge consecutive column-layout slides with the same title; linear slides unchanged.""" + if len(slides) < 2: + return list(slides) + out: list[SlideExtract] = [] + i = 0 + while i < len(slides): + cur = slides[i] + while i + 1 < len(slides) and mergeable_column_pair(cur, slides[i + 1]): + cur = _merge_two_slides(cur, slides[i + 1]) + i += 1 + out.append(cur) + i += 1 + return out + + +def coalesce_consecutive_columns_blocks( + blocks: list[SlideContentBlock], +) -> list[SlideContentBlock]: + """Stack consecutive ColumnsBlocks with the same widths into one row (one wp:columns).""" + if len(blocks) <= 1: + return blocks + out: list[SlideContentBlock] = [] + i = 0 + while i < len(blocks): + b = blocks[i] + if not isinstance(b, ColumnsBlock): + out.append(b) + i += 1 + continue + group: list[ColumnsBlock] = [b] + j = i + 1 + while j < len(blocks) and isinstance(blocks[j], ColumnsBlock): + c = blocks[j] + if len(c.columns) != len(b.columns): + break + if not widths_layout_compatible(b.widths, c.widths): + break + group.append(c) + j += 1 + if len(group) == 1: + out.append(group[0]) + else: + merged_cols: list[list[tuple[str, str]]] = [] + for k in range(len(group[0].columns)): + merged: list[tuple[str, str]] = [] + for g in group: + merged.extend(g.columns[k]) + merged_cols.append(_dedupe_consecutive_identical_images(merged)) + out.append( + ColumnsBlock(widths=list(group[0].widths), columns=merged_cols) + ) + i = j + return out + + +def coalesce_slide_column_blocks(slide: SlideExtract) -> SlideExtract: + """Combine stacked column rows on one slide into a single ColumnsBlock when shapes match.""" + flat = _flatten_columns_only_slide(slide) + if flat is None: + new_blocks = coalesce_consecutive_columns_blocks(slide.content_blocks) + if new_blocks is slide.content_blocks: + return slide + return replace(slide, content_blocks=new_blocks) + cols, w = flat + single = ( + len(slide.content_blocks) == 1 + and isinstance(slide.content_blocks[0], ColumnsBlock) + and slide.content_blocks[0].widths == w + and slide.content_blocks[0].columns == cols + ) + if single: + return slide + return replace( + slide, + content_blocks=[ColumnsBlock(widths=list(w), columns=cols)], + ) + + +def merge_and_coalesce_slides(slides: list[SlideExtract]) -> list[SlideExtract]: + """Same-title column merge, then coalesce multiple column rows into one block per slide.""" + merged = merge_consecutive_same_title_slides(slides) + return [coalesce_slide_column_blocks(s) for s in merged] + + +def include_slide_heading_vs_previous( + slide: SlideExtract, + previous: SlideExtract | None, + *, + include_slide_headings: bool, +) -> bool: + """Whether to emit a slide title heading: off when consecutive same title.""" + if not include_slide_headings: + return False + if previous is None: + return True + st = (slide.title or "").strip() + pt = (previous.title or "").strip() + # Blank title on a continuation slide: do not emit a second heading. + if not st and pt: + return False + return not titles_equivalent(slide.title, previous.title) diff --git a/tools/slides-to-learndash/slides_to_learndash/slide_visibility.py b/tools/slides-to-learndash/slides_to_learndash/slide_visibility.py new file mode 100644 index 000000000..4b2b59ac2 --- /dev/null +++ b/tools/slides-to-learndash/slides_to_learndash/slide_visibility.py @@ -0,0 +1,18 @@ +"""OOXML slide visibility (hidden slides) without importing python-pptx here.""" + +from __future__ import annotations + + +def slide_is_hidden(slide: object) -> bool: + """Return True if the slide is marked hidden in PowerPoint (excluded from slideshow). + + The ``show`` attribute on the ``p:sld`` root defaults to true; ``0`` or ``false`` means hidden. + """ + el = getattr(slide, "_element", None) + if el is None: + return False + show = el.get("show") + if show is None: + return False + s = str(show).strip().lower() + return s in ("0", "false") diff --git a/tools/slides-to-learndash/tests/test_columns_layout.py b/tools/slides-to-learndash/tests/test_columns_layout.py new file mode 100644 index 000000000..a03bc9fe6 --- /dev/null +++ b/tools/slides-to-learndash/tests/test_columns_layout.py @@ -0,0 +1,58 @@ +"""Layout/columns extraction tests (demo.pptx fixtures).""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from slides_to_learndash.extract import LinearBlock, load_presentation, slide_to_post_html +from slides_to_learndash.pipeline import build_slides +from slides_to_learndash.slide_geometry import ShapeBox, cluster_row_indices, _vertical_overlap_ratio + +_ROOT = Path(__file__).resolve().parent.parent +_DEMO = _ROOT / "demo.pptx" + + +class TestColumnsLayout(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + if not _DEMO.is_file(): + raise unittest.SkipTest(f"Missing {_DEMO}") + prs = load_presentation(_DEMO) + cls.slides = build_slides(prs, media_dir=None, skip_images=True) + + def test_slide_16_full_width_row_then_two_column_lists(self) -> None: + s16 = self.slides[15] + html = slide_to_post_html(s16, include_slide_heading=False) + self.assertIn("Tuesday", html) + self.assertIn("", html) + self.assertLess(html.find("Tuesday"), html.find("")) + self.assertIn('"width":"50%"', html) + self.assertEqual(html.count(""), 1) + + def test_slide_3_single_column_no_columns_block(self) -> None: + s3 = self.slides[2] + self.assertEqual(len(s3.content_blocks), 1) + self.assertIsInstance(s3.content_blocks[0], LinearBlock) + html = slide_to_post_html(s3, include_slide_heading=False) + self.assertNotIn("wp:columns", html) + + def test_slide_8_two_column_row(self) -> None: + s8 = self.slides[7] + html = slide_to_post_html(s8, include_slide_heading=False) + self.assertIn("", html) + self.assertIn('"width":"33.33%"', html) + self.assertIn('"width":"66.66%"', html) + + def test_row_cluster_merges_side_by_side_when_overlap_ratio_low(self) -> None: + """Short label + tall body column: min-height ratio can fall below 0.4; still one row.""" + a = ShapeBox(None, 0, 0, 50_000, 100_000, "text") + b = ShapeBox(None, 200_000, 70_000, 300_000, 1_000_000, "text") + self.assertLess(_vertical_overlap_ratio(a, b), 0.4) + rows = cluster_row_indices([a, b]) + self.assertEqual(len(rows), 1) + self.assertEqual(set(rows[0]), {0, 1}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/slides-to-learndash/tests/test_content_postprocess.py b/tools/slides-to-learndash/tests/test_content_postprocess.py new file mode 100644 index 000000000..f5de1c4ee --- /dev/null +++ b/tools/slides-to-learndash/tests/test_content_postprocess.py @@ -0,0 +1,88 @@ +"""Session outline post-process and heading bold stripping.""" + +from __future__ import annotations + +import unittest + +from slides_to_learndash import blocks +from slides_to_learndash.content_postprocess import strip_session_outline_columns_keep_learning_objectives +from slides_to_learndash.rich_text import strip_bold_tags_from_inner_html + + +class TestStripSessionOutlineColumns(unittest.TestCase): + def test_replaces_session_outline_columns_with_lo_heading_and_list(self) -> None: + left = blocks.join_blocks( + [ + blocks.heading_block_html("Session Outline", 3), + blocks.list_block_html(["Version Control", "Git Commands"]), + ] + ) + right = blocks.join_blocks( + [ + blocks.paragraph_block_html("Learning Objectives"), + blocks.list_block_html(["Understand Git", "Use branches"]), + ] + ) + columns = blocks.columns_block_html([left, right], ["50%", "50%"]) + tail = blocks.heading_block_html("Next Section", 3) + html = blocks.join_blocks([columns, tail]) + + out = strip_session_outline_columns_keep_learning_objectives(html) + + self.assertNotIn("", out) + self.assertNotIn("Session Outline", out) + self.assertNotIn("Session Outline", out) + self.assertNotIn("Version Control", out) + self.assertIn("Understand Git", out) + self.assertIn("Use branches", out) + self.assertRegex(out, r']*>Learning Objectives') + self.assertNotRegex(out, r"]*>.*Learning Objectives") + self.assertIn("Next Section", out) + + def test_unrelated_columns_unchanged(self) -> None: + left = blocks.paragraph_block_html("Left") + right = blocks.paragraph_block_html("Right") + columns = blocks.columns_block_html([left, right], ["50%", "50%"]) + out = strip_session_outline_columns_keep_learning_objectives(columns) + self.assertEqual(out.strip(), columns.strip()) + + def test_replaces_columns_when_lo_label_is_heading(self) -> None: + left = blocks.join_blocks( + [ + blocks.heading_block_html("Data Types", 3), + blocks.list_block_html(["Primitive Types", "Wrappers"]), + ] + ) + right = blocks.join_blocks( + [ + blocks.heading_block_html("Learning Objectives", 3), + blocks.list_block_html(["Practice Java syntax"]), + ] + ) + columns = blocks.columns_block_html([left, right], ["50%", "50%"]) + html = blocks.join_blocks([blocks.heading_block_html("Data Types", 3), columns]) + + out = strip_session_outline_columns_keep_learning_objectives(html) + + self.assertNotIn("", out) + self.assertNotIn("Java Data Types", out) + self.assertNotIn("Primitive Types", out) + self.assertIn("Practice Java syntax", out) + self.assertRegex(out, r']*>Learning Objectives') + + +class TestStripBoldFromHeadingInnerHtml(unittest.TestCase): + def test_unwraps_strong(self) -> None: + self.assertEqual(strip_bold_tags_from_inner_html("Foo"), "Foo") + + def test_unwraps_nested_strong(self) -> None: + self.assertEqual( + strip_bold_tags_from_inner_html("x"), + "x", + ) + + def test_preserves_em(self) -> None: + self.assertEqual( + strip_bold_tags_from_inner_html("Hi and Bold"), + "Hi and Bold", + ) diff --git a/tools/slides-to-learndash/tests/test_hidden_slides.py b/tools/slides-to-learndash/tests/test_hidden_slides.py new file mode 100644 index 000000000..fad0b8561 --- /dev/null +++ b/tools/slides-to-learndash/tests/test_hidden_slides.py @@ -0,0 +1,37 @@ +"""Hidden slide detection (OOXML ``show`` on ``p:sld``).""" + +from __future__ import annotations + +import unittest +from unittest.mock import MagicMock + +from slides_to_learndash.slide_visibility import slide_is_hidden + + +class TestSlideIsHidden(unittest.TestCase): + def test_visible_when_show_absent(self) -> None: + slide = MagicMock() + slide._element.get.return_value = None + self.assertFalse(slide_is_hidden(slide)) + slide._element.get.assert_called_once_with("show") + + def test_visible_when_show_one(self) -> None: + slide = MagicMock() + slide._element.get.return_value = "1" + self.assertFalse(slide_is_hidden(slide)) + + def test_hidden_when_show_zero(self) -> None: + slide = MagicMock() + slide._element.get.return_value = "0" + self.assertTrue(slide_is_hidden(slide)) + + def test_hidden_when_show_false(self) -> None: + slide = MagicMock() + slide._element.get.return_value = "false" + self.assertTrue(slide_is_hidden(slide)) + + def test_visible_when_no_element(self) -> None: + class Bare: + pass + + self.assertFalse(slide_is_hidden(Bare())) # type: ignore[arg-type] diff --git a/tools/slides-to-learndash/tests/test_slide_merge.py b/tools/slides-to-learndash/tests/test_slide_merge.py new file mode 100644 index 000000000..323c77e91 --- /dev/null +++ b/tools/slides-to-learndash/tests/test_slide_merge.py @@ -0,0 +1,304 @@ +"""Tests for same-title slide merge and heading suppression (no pptx).""" + +from __future__ import annotations + +import unittest + +from slides_to_learndash.extract import ( + ColumnsBlock, + LinearBlock, + SlideExtract, + slide_to_post_html, +) +from slides_to_learndash.slide_merge import ( + _flatten_columns_only_slide, + coalesce_consecutive_columns_blocks, + include_slide_heading_vs_previous, + merge_and_coalesce_slides, + merge_column_segments, + merge_consecutive_same_title_slides, + mergeable_column_pair, + segments_equal, + titles_equivalent, + widths_layout_compatible, +) + + +def _col_slide(title: str, cols: list[list[tuple[str, str]]], idx: int = 1) -> SlideExtract: + w = ["50%", "50%"] if len(cols) == 2 else ["100%"] + return SlideExtract( + index=idx, + layout_name="TWO_COL", + title=title, + content_blocks=[ColumnsBlock(widths=w, columns=cols)], + image_paths=[], + ) + + +def _linear_slide(title: str, segs: list[tuple[str, str]], idx: int = 1) -> SlideExtract: + return SlideExtract( + index=idx, + layout_name="TITLE_ONLY", + title=title, + content_blocks=[LinearBlock(segs)], + image_paths=[], + ) + + +class TestTitlesEquivalent(unittest.TestCase): + def test_case_insensitive(self) -> None: + self.assertTrue(titles_equivalent("Hello", "hello")) + self.assertFalse(titles_equivalent("", "a")) + self.assertFalse(titles_equivalent("a", "")) + + +class TestSegmentsEqual(unittest.TestCase): + def test_paragraph_normalized(self) -> None: + self.assertTrue( + segments_equal(("p", " Hi "), ("p", "hi")) + ) + + +class TestMergeColumnSegments(unittest.TestCase): + def test_duplicate_column(self) -> None: + a = [("p", "x")] + b = [("p", "x")] + self.assertEqual(merge_column_segments(a, b), a) + + def test_continuation(self) -> None: + a = [("p", "x")] + b = [("p", "x"), ("p", "y")] + self.assertEqual(merge_column_segments(a, b), [("p", "x"), ("p", "y")]) + + def test_disjoint(self) -> None: + a = [("p", "a")] + b = [("p", "b")] + self.assertEqual(merge_column_segments(a, b), [("p", "a"), ("p", "b")]) + + def test_semantic_duplicate_different_markup(self) -> None: + a = [("bullet", "git remote")] + b = [("bullet", "git remote")] + out = merge_column_segments(a, b) + self.assertEqual(out, a) + + +class TestWidthsLayoutCompatible(unittest.TestCase): + def test_minor_percent_drift(self) -> None: + self.assertTrue( + widths_layout_compatible(["66.66%", "33.33%"], ["66.67%", "33.33%"]) + ) + + def test_different_layouts(self) -> None: + self.assertFalse(widths_layout_compatible(["50%", "50%"], ["40%", "60%"])) + + +class TestCoalesceColumnsBlocks(unittest.TestCase): + def test_stacks_two_rows(self) -> None: + w = ["50%", "50%"] + b1 = ColumnsBlock( + widths=w, + columns=[[("p", "left1")], [("p", "right1")]], + ) + b2 = ColumnsBlock( + widths=w, + columns=[[("p", "left2")], [("p", "right2")]], + ) + out = coalesce_consecutive_columns_blocks([b1, b2]) + self.assertEqual(len(out), 1) + self.assertEqual( + out[0].columns, + [ + [("p", "left1"), ("p", "left2")], + [("p", "right1"), ("p", "right2")], + ], + ) + + def test_coalesces_when_width_strings_differ_slightly(self) -> None: + b1 = ColumnsBlock( + widths=["66.66%", "33.33%"], + columns=[[("p", "a")], [("p", "b")]], + ) + b2 = ColumnsBlock( + widths=["66.67%", "33.33%"], + columns=[[("p", "c")], [("p", "d")]], + ) + out = coalesce_consecutive_columns_blocks([b1, b2]) + self.assertEqual(len(out), 1) + assert isinstance(out[0], ColumnsBlock) + self.assertEqual( + out[0].columns, + [[("p", "a"), ("p", "c")], [("p", "b"), ("p", "d")]], + ) + + +class TestMergeConsecutiveSameTitleSlides(unittest.TestCase): + def test_merges_two_column_slides(self) -> None: + s1 = _col_slide("T", [[("p", "a")], [("p", "b")]]) + s2 = _col_slide("T", [[("p", "a")], [("p", "c")]], idx=2) + out = merge_consecutive_same_title_slides([s1, s2]) + self.assertEqual(len(out), 1) + blk = out[0].content_blocks[0] + self.assertIsInstance(blk, ColumnsBlock) + assert isinstance(blk, ColumnsBlock) + self.assertEqual(blk.columns[0], [("p", "a")]) + self.assertEqual(blk.columns[1], [("p", "b"), ("p", "c")]) + + def test_linear_not_merged(self) -> None: + s1 = _linear_slide("T", [("p", "one")]) + s2 = _linear_slide("T", [("p", "two")], idx=2) + out = merge_consecutive_same_title_slides([s1, s2]) + self.assertEqual(len(out), 2) + + def test_different_title_not_merged(self) -> None: + s1 = _col_slide("A", [[("p", "1")], [("p", "2")]]) + s2 = _col_slide("B", [[("p", "1")], [("p", "2")]], idx=2) + out = merge_consecutive_same_title_slides([s1, s2]) + self.assertEqual(len(out), 2) + + def test_merges_two_row_slides(self) -> None: + w = ["50%", "50%"] + s1 = SlideExtract( + index=1, + layout_name="X", + title="T", + content_blocks=[ + ColumnsBlock( + widths=w, + columns=[[("p", "L1")], [("p", "R1")]], + ), + ColumnsBlock( + widths=w, + columns=[[("p", "L2a")], [("p", "R2a")]], + ), + ], + image_paths=[], + ) + s2 = SlideExtract( + index=2, + layout_name="X", + title="T", + content_blocks=[ + ColumnsBlock( + widths=w, + columns=[[("p", "L2b")], [("p", "R2b")]], + ), + ColumnsBlock( + widths=w, + columns=[[("p", "L3")], [("p", "R3")]], + ), + ], + image_paths=[], + ) + out = merge_consecutive_same_title_slides([s1, s2]) + self.assertEqual(len(out), 1) + self.assertEqual(len(out[0].content_blocks), 1) + fa = _flatten_columns_only_slide(s1) + fb = _flatten_columns_only_slide(s2) + assert fa is not None and fb is not None + ca, _wa = fa + cb, _wb = fb + blk = out[0].content_blocks[0] + self.assertIsInstance(blk, ColumnsBlock) + assert isinstance(blk, ColumnsBlock) + self.assertEqual(blk.columns[0], merge_column_segments(ca[0], cb[0])) + self.assertEqual(blk.columns[1], merge_column_segments(ca[1], cb[1])) + + +class TestMergeAndCoalesce(unittest.TestCase): + def test_single_slide_two_rows_becomes_one_block(self) -> None: + w = ["50%", "50%"] + s = SlideExtract( + index=1, + layout_name="X", + title="T", + content_blocks=[ + ColumnsBlock(widths=w, columns=[[("p", "a")], [("p", "b")]]), + ColumnsBlock(widths=w, columns=[[("p", "c")], [("p", "d")]]), + ], + image_paths=[], + ) + out = merge_and_coalesce_slides([s]) + self.assertEqual(len(out), 1) + self.assertEqual(len(out[0].content_blocks), 1) + blk = out[0].content_blocks[0] + self.assertIsInstance(blk, ColumnsBlock) + assert isinstance(blk, ColumnsBlock) + self.assertEqual( + blk.columns, + [ + [("p", "a"), ("p", "c")], + [("p", "b"), ("p", "d")], + ], + ) + + +class TestMergeableColumnPair(unittest.TestCase): + def test_widths_mismatch(self) -> None: + s1 = SlideExtract( + index=1, + layout_name="X", + title="T", + content_blocks=[ + ColumnsBlock(widths=["50%", "50%"], columns=[[("p", "a")], [("p", "b")]]) + ], + ) + s2 = SlideExtract( + index=2, + layout_name="X", + title="T", + content_blocks=[ + ColumnsBlock(widths=["40%", "60%"], columns=[[("p", "a")], [("p", "b")]]) + ], + ) + self.assertFalse(mergeable_column_pair(s1, s2)) + + +class TestIncludeSlideHeadingVsPrevious(unittest.TestCase): + def test_first_and_second_same_title(self) -> None: + a = _linear_slide("Same", [("p", "x")]) + b = _linear_slide("same", [("p", "y")], idx=2) + self.assertTrue( + include_slide_heading_vs_previous( + a, None, include_slide_headings=True + ) + ) + self.assertFalse( + include_slide_heading_vs_previous( + b, a, include_slide_headings=True + ) + ) + + def test_headings_off(self) -> None: + a = _linear_slide("Same", [("p", "x")]) + self.assertFalse( + include_slide_heading_vs_previous( + a, None, include_slide_headings=False + ) + ) + + +class TestSlideToPostHtmlHeadings(unittest.TestCase): + def test_second_slide_no_duplicate_heading(self) -> None: + s1 = _linear_slide("Topic", [("p", "body1")]) + s2 = _linear_slide("topic", [("p", "body2")], idx=2) + h1 = slide_to_post_html( + s1, + include_slide_heading=include_slide_heading_vs_previous( + s1, None, include_slide_headings=True + ), + slide_heading_level=3, + ) + h2 = slide_to_post_html( + s2, + include_slide_heading=include_slide_heading_vs_previous( + s2, s1, include_slide_headings=True + ), + slide_heading_level=3, + ) + self.assertIn("wp:heading", h1) + self.assertNotIn("wp:heading", h2) + self.assertIn("body2", h2) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/slides-to-learndash/uv.lock b/tools/slides-to-learndash/uv.lock new file mode 100644 index 000000000..38849bafa --- /dev/null +++ b/tools/slides-to-learndash/uv.lock @@ -0,0 +1,363 @@ +version = 1 +requires-python = ">=3.10" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303 }, +] + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "lxml" +version = "6.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/08/1217ca4043f55c3c92993b283a7dbfa456a2058d8b57bbb416cc96b6efff/lxml-6.0.4.tar.gz", hash = "sha256:4137516be2a90775f99d8ef80ec0283f8d78b5d8bd4630ff20163b72e7e9abf2", size = 4237780 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b9/93d71026bf6c4dfe3afc32064a3fcd533d9032c8b97499744a999f97c230/lxml-6.0.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4a2c26422c359e93d97afd29f18670ae2079dbe2dd17469f1e181aa6699e96a7", size = 8540588 }, + { url = "https://files.pythonhosted.org/packages/c0/61/33639497c73383e2f53f0b93d485248b77d5498f3589534952bd94380ff3/lxml-6.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e3b455459e5ed424a4cc277cd085fc1a50a05b940af30703a13a8ec0932d6a69", size = 4601730 }, + { url = "https://files.pythonhosted.org/packages/10/ad/cb2de3d32a0d4748be7cd002a3e3eb67e82027af3796f9fe2462aadb1f7c/lxml-6.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3109bdeb9674abbc4d8bd3fd273cce4a4087a93f31c17dc321130b71384992e5", size = 5000607 }, + { url = "https://files.pythonhosted.org/packages/93/4d/87d8eaba7638c917b2fd971efd1bd93d0662dade95e1d868c18ba7bb84d9/lxml-6.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d41f733476eecf7a919a1b909b12e67f247564b21c2b5d13e5f17851340847da", size = 5154439 }, + { url = "https://files.pythonhosted.org/packages/1b/6a/dd74a938ff10daadbc441bb4bc9d23fb742341da46f2730d7e335cb034bb/lxml-6.0.4-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:717e702b07b512aca0f09d402896e476cfdc1db12bca0441210b1a36fdddb6dd", size = 5055024 }, + { url = "https://files.pythonhosted.org/packages/ef/4a/ac0f195f52fae450338cae90234588a2ead2337440b4e5ff7230775477a3/lxml-6.0.4-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ad61a5fb291e45bb1d680b4de0c99e28547bd249ec57d60e3e59ebe6628a01f", size = 5285427 }, + { url = "https://files.pythonhosted.org/packages/34/f1/804925a5723b911507d7671ab164b697f2e3acb12c0bb17a201569ab848e/lxml-6.0.4-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:2c75422b742dd70cc2b5dbffb181ac093a847b338c7ca1495d92918ae35eabae", size = 5410657 }, + { url = "https://files.pythonhosted.org/packages/73/bc/1d032759c6fbd45c72c29880df44bd2115cdd4574b01a10c9d448496cb75/lxml-6.0.4-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:28df3bd54561a353ce24e80c556e993b397a41a6671d567b6c9bee757e1bf894", size = 4769048 }, + { url = "https://files.pythonhosted.org/packages/b1/d0/a6b5054a2df979d6c348173bc027cb9abaa781fe96590f93a0765f50748c/lxml-6.0.4-cp310-cp310-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8d7db1fa5f95a8e4fcf0462809f70e536c3248944ddeba692363177ac6b44f2b", size = 5358493 }, + { url = "https://files.pythonhosted.org/packages/c7/ce/99e7233391290b6e9a7d8429846b340aa547f16ad026307bf2a02919a3e2/lxml-6.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8fdae368cb2deb4b2476f886c107aecaaea084e97c0bc0a268861aa0dd2b7237", size = 5106775 }, + { url = "https://files.pythonhosted.org/packages/6f/c8/1d6d65736cec2cd3198bbe512ec121625a3dc4bb7c9dbd19cc0ea967e9b1/lxml-6.0.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:14e4af403766322522440863ca55a9561683b4aedf828df6726b8f83de14a17f", size = 4802389 }, + { url = "https://files.pythonhosted.org/packages/e1/99/2b9b704843f5661347ba33150918d4c1d18025449489b05895d352501ae7/lxml-6.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c4633c39204e97f36d68deff76471a0251afe8a82562034e4eda63673ee62d36", size = 5348648 }, + { url = "https://files.pythonhosted.org/packages/3e/af/2f15de7f947a71ee1b4c850d8f1764adfdfae459e434caf50e6c81983da4/lxml-6.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a72e2e31dbc3c35427486402472ca5d8ca2ef2b33648ed0d1b22de2a96347b76", size = 5307603 }, + { url = "https://files.pythonhosted.org/packages/b2/9a/028f3c7981411b90afce0743a12f947a047e7b75a0e0efd3774a704eb49a/lxml-6.0.4-cp310-cp310-win32.whl", hash = "sha256:15f135577ffb6514b40f02c00c1ba0ca6305248b1e310101ca17787beaf4e7ad", size = 3597402 }, + { url = "https://files.pythonhosted.org/packages/32/84/dac34d557eab04384914a9788caf6ec99132434a52a534bf7b367cf8b366/lxml-6.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:fd7f6158824b8bc1e96ae87fb14159553be8f7fa82aec73e0bdf98a5af54290c", size = 4019839 }, + { url = "https://files.pythonhosted.org/packages/97/cb/c91537a07a23ee6c55cf701df3dc34f76cf0daec214adffda9c8395648ef/lxml-6.0.4-cp310-cp310-win_arm64.whl", hash = "sha256:5ff4d73736c80cb9470c8efa492887e4e752a67b7fd798127794e2be103ebef1", size = 3667037 }, + { url = "https://files.pythonhosted.org/packages/15/93/5145f2c9210bf99c01f2f54d364be805f556f2cb13af21d3c2d80e0780bb/lxml-6.0.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3602d57fdb6f744f4c5d0bd49513fe5abbced08af85bba345fc354336667cd47", size = 8525003 }, + { url = "https://files.pythonhosted.org/packages/93/19/9d61560a53ac1b26aec1a83ae51fadbe0cc0b6534e2c753ad5af854f231b/lxml-6.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8c7976c384dcab4bca42f371449fb711e20f1bfce99c135c9b25614aed80e55", size = 4594697 }, + { url = "https://files.pythonhosted.org/packages/93/1a/0db40884f959c94ede238507ea0967dd47527ab11d130c5a571088637e78/lxml-6.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:579e20c120c3d231e53f0376058e4e1926b71ca4f7b77a7a75f82aea7a9b501e", size = 4922365 }, + { url = "https://files.pythonhosted.org/packages/04/db/4136fab3201087bd5a4db433b9a36e50808d8af759045e7d7af757b46178/lxml-6.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f32a27be5fb286febd16c0d13d4a3aee474d34417bd172e64d76c6a28e2dc14", size = 5066748 }, + { url = "https://files.pythonhosted.org/packages/03/d9/aad543afc57e6268200332ebe695be0320fdd2219b175d34a52027aa1bad/lxml-6.0.4-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d53b7cdaa961a4343312964f6c5a150d075a55e95e1338078d413bf38eba8c0", size = 5000464 }, + { url = "https://files.pythonhosted.org/packages/ab/92/14cc575b97dedf02eb8de96af8d977f06b9f2500213805165606ff06c011/lxml-6.0.4-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d4cc697347f6c61764b58767109e270d0b4a92aba4a8053a967ed9de23a5ea9", size = 5201395 }, + { url = "https://files.pythonhosted.org/packages/a7/72/0ff17f32a737a9c2840f781aee4bbd5cec947b966ff0c74c5dec56098beb/lxml-6.0.4-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:108b8d6da624133eaa1a6a5bbcb1f116b878ea9fd050a1724792d979251706fb", size = 5329108 }, + { url = "https://files.pythonhosted.org/packages/f7/f7/3b1f43e0db54462b5f1ebd96ee43b240388e3b9bf372546694175bec2d41/lxml-6.0.4-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c087d643746489df06fe3ac03460d235b4b3ae705e25838257510c79f834e50f", size = 4658132 }, + { url = "https://files.pythonhosted.org/packages/94/cb/90513445e4f08c500f953543aadf18501e5438b31bc816d0ce9a5e09cc5c/lxml-6.0.4-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2063c486f80c32a576112201c93269a09ebeca5b663092112c5fb39b32556340", size = 5264665 }, + { url = "https://files.pythonhosted.org/packages/17/d2/c1fa939ea0fa75190dd452d9246f97c16372e2d593fe9f4684cae5c37dda/lxml-6.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ff016e86ec14ae96253a3834302e0e89981956b73e4e74617eeba4a6a81da08b", size = 5043801 }, + { url = "https://files.pythonhosted.org/packages/22/d4/01cdd3c367045526a376cc1eadacf647f193630db3f902b8842a76b3eb2e/lxml-6.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0e9ba5bcd75efb8cb4613463e6cfb55b5a76d4143e4cfa06ea027bc6cc696a3e", size = 4711416 }, + { url = "https://files.pythonhosted.org/packages/8d/77/f6af805c6e23b9a12970c8c38891b087ffd884c2d4df6069e63ff1623fd6/lxml-6.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9a69668bef9268f54a92f2254917df530ca4630a621027437f0e948eb1937e7b", size = 5251326 }, + { url = "https://files.pythonhosted.org/packages/2b/bb/bcd429655f6d12845d91f17e3977d63de22cde5fa77f7d4eef7669a80e8c/lxml-6.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280f8e7398bdc48c7366ad375a5586692cd73b269d9e82e6898f9ada70dc0bcb", size = 5224752 }, + { url = "https://files.pythonhosted.org/packages/69/cd/0342c5a3663115560899a0529789969a72bc5209c8f0084e5b0598cda94d/lxml-6.0.4-cp311-cp311-win32.whl", hash = "sha256:a8eddf3c705e00738db695a9a77830f8d57f7d21a54954fbef23a1b8806384ed", size = 3592977 }, + { url = "https://files.pythonhosted.org/packages/92/c1/386ee2e8a8008cccc4903435f19aaffd16d9286186106752d08be2bd7ccb/lxml-6.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:b74d5b391fc49fc3cc213c930f87a7dedf2b4b0755aae4638e91e4501e278430", size = 4023718 }, + { url = "https://files.pythonhosted.org/packages/a7/a0/19f5072fdc7c73d44004506172dba4b7e3d179d9b3a387efce9c30365afd/lxml-6.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:2f0cf04bafc14b0eebfbc3b5b73b296dd76b5d7640d098c02e75884bb0a70f2b", size = 3666955 }, + { url = "https://files.pythonhosted.org/packages/3d/18/4732abab49bbb041b1ded9dd913ca89735a0dcca038eacec64c44ba02163/lxml-6.0.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:af0b8459c4e21a8417db967b2e453d1855022dac79c79b61fb8214f3da50f17e", size = 8570033 }, + { url = "https://files.pythonhosted.org/packages/72/7e/38523ec7178ca35376551911455d1b2766bc9d98bcc18f606a167fa9ecbb/lxml-6.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e0cdcea2affa53fa17dc4bf5cefc0edf72583eac987d669493a019998a623fa3", size = 4623270 }, + { url = "https://files.pythonhosted.org/packages/f1/cf/f9b6c9bf9d8c63d923ef893915141767cea4cea71774f20c36d0c14e1585/lxml-6.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8da4d4840c1bc07da6fcd647784f7fbaf538eeb7a57ce6b2487acc54c5e33330", size = 4929471 }, + { url = "https://files.pythonhosted.org/packages/e5/53/3117f988c9e20be4156d2b8e1bda82ae06878d11aeb820dea111a7cfa4e3/lxml-6.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb04a997588c3980894ded9172c10c5a3e45d3f1c5410472733626d268683806", size = 5092355 }, + { url = "https://files.pythonhosted.org/packages/4e/ca/05c6ac773a2bd3edb48fa8a5c5101e927ce044c4a8aed1a85ff00fab20a5/lxml-6.0.4-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca449642a08a6ceddf6e6775b874b6aee1b6242ed80aea84124497aba28e5384", size = 5004520 }, + { url = "https://files.pythonhosted.org/packages/f1/db/d8aa5aa3a51d0aa6706ef85f85027f7c972cd840fe69ba058ecaf32d093d/lxml-6.0.4-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35b3ccdd137e62033662787dd4d2b8be900c686325d6b91e3b1ff6213d05ba11", size = 5629961 }, + { url = "https://files.pythonhosted.org/packages/9d/75/8fff4444e0493aeb15ab0f4a55c767b5baed9074cf67a1835dc1161f3a1f/lxml-6.0.4-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45dc690c54b1341fec01743caed02e5f1ea49d7cfb81e3ba48903e5e844ed68a", size = 5237561 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/6d6cd73014f2dbf47a8aa7accd9712726f46ef4891e1c126bc285cfb94e4/lxml-6.0.4-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:15ae922e8f74b05798a0e88cee46c0244aaec6a66b5e00be7d18648fed8c432e", size = 5349197 }, + { url = "https://files.pythonhosted.org/packages/2d/43/e3e9a126e166234d1659d1dd9004dc1dd50cdc3c68575b071b0a1524b4de/lxml-6.0.4-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:ebd816653707fbf10c65e3dee3bc24dac6b691654c21533b1ae49287433f4db0", size = 4693123 }, + { url = "https://files.pythonhosted.org/packages/6c/98/b146dd123a4a7b69b571ff23ea8e8c68de8d8c1b03e23d01c6374d4fd835/lxml-6.0.4-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:21284cf36b95dd8be774eb06c304b440cf49ee811800a30080ce6d93700f0383", size = 5242967 }, + { url = "https://files.pythonhosted.org/packages/7e/60/8c275584452b55a902c883e8ab63d755c5ef35d7ad1f06f9e6559095521d/lxml-6.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c08a2a9d0c4028ef5fc5a513b2e1e51af069a83c5b4206139edd08b3b8c2926", size = 5046810 }, + { url = "https://files.pythonhosted.org/packages/19/aa/19ec216147e1105e5403fe73657c693a6e91bde855a13242dd6031e829e5/lxml-6.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1bc2f0f417112cf1a428599dd58125ab74d8e1c66893efd9b907cbb4a5db6e44", size = 4776383 }, + { url = "https://files.pythonhosted.org/packages/41/c8/90afdb838705a736268fcffd2698c05e9a129144ce215d5e14db3bdfc295/lxml-6.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c0d86e328405529bc93913add9ff377e8b8ea9be878e611f19dbac7766a84483", size = 5643497 }, + { url = "https://files.pythonhosted.org/packages/32/ec/1135261ec9822dafb90be0ff6fb0ec79cee0b7fe878833dfe5f2b8c393bd/lxml-6.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3cce9420fe8f91eae5d457582599d282195c958cb670aa4bea313a79103ba33f", size = 5232185 }, + { url = "https://files.pythonhosted.org/packages/13/f2/7380b11cae6943720f525e5a28ad9dbead96ac710417e556b7c03f3a8af3/lxml-6.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96214985ec194ce97b9028414e179cfb21230cba4e2413aee7e249461bb84f4d", size = 5259968 }, + { url = "https://files.pythonhosted.org/packages/65/8f/141734f2c456f2253fed4237d8d4b241e3d701129cf6f0b135ccf241a75a/lxml-6.0.4-cp312-cp312-win32.whl", hash = "sha256:b2209b310e7ed1d4cd1c00d405ec9c49722fce731c7036abc1d876bf8df78139", size = 3594958 }, + { url = "https://files.pythonhosted.org/packages/b7/a9/c6d3531c6d8814af0919fbdb9bda43c9e8b5deffcb70c8534017db233512/lxml-6.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:03affcacfba4671ebc305813b02bfaf34d80b6a7c5b23eafc5d6da14a1a6e623", size = 3995897 }, + { url = "https://files.pythonhosted.org/packages/03/5d/1dabeddf762e5a315a31775b2bca39811d7e7a15fc3e677d044b9da973fe/lxml-6.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:af9678e3a2a047465515d95a61690109af7a4c9486f708249119adcef7861049", size = 3658607 }, + { url = "https://files.pythonhosted.org/packages/78/f6/550a1ed9afde66e24bfcf9892446ea9779152df336062c6df0f7733151a2/lxml-6.0.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecc3d55ed756ee6c3447748862a97e1f5392d2c5d7f474bace9382345e4fc274", size = 8559522 }, + { url = "https://files.pythonhosted.org/packages/11/93/3f687c14d2b4d24b60fe13fd5482c8853f82a10bb87f2b577123e342ed1a/lxml-6.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7d5a627a368a0e861350ccc567a70ec675d2bc4d8b3b54f48995ae78d8d530e", size = 4617380 }, + { url = "https://files.pythonhosted.org/packages/b5/ed/91e443366063d3fb7640ae2badd5d7b65be4095ac6d849788e39c043baae/lxml-6.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d385141b186cc39ebe4863c1e41936282c65df19b2d06a701dedc2a898877d6a", size = 4922791 }, + { url = "https://files.pythonhosted.org/packages/30/4b/2243260b70974aca9ba0cc71bd668c0c3a79644d80ddcabbfbdb4b131848/lxml-6.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0132bb040e9bb5a199302e12bf942741defbc52922a2a06ce9ff7be0d0046483", size = 5080972 }, + { url = "https://files.pythonhosted.org/packages/f8/c3/54c53c4f772341bc12331557f8b0882a426f53133926306cbe6d7f0ee7e4/lxml-6.0.4-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26aee5321e4aa1f07c9090a35f6ab8b703903fb415c6c823cfdb20ee0d779855", size = 4992236 }, + { url = "https://files.pythonhosted.org/packages/be/0f/416de42e22f287585abee610eb0d1c2638c9fe24cee7e15136e0b5e138f8/lxml-6.0.4-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b5652455de198ff76e02cfa57d5efc5f834fa45521aaf3fcc13d6b5a88bde23d", size = 5612398 }, + { url = "https://files.pythonhosted.org/packages/7d/63/29a3fa79b8a182f5bd5b5bdcb6f625f49f08f41d60a26ca25482820a1b99/lxml-6.0.4-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75842801fb48aea73f4c281b923a010dfb39bad75edf8ceb2198ec30c27f01cc", size = 5227480 }, + { url = "https://files.pythonhosted.org/packages/7c/4a/44d1843de599b1c6dbe578e4248c2f15e7fac90c5c86eb26775eaeac0fe0/lxml-6.0.4-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:94a1f74607a5a049ff6ff8de429fec922e643e32b5b08ec7a4fe49e8de76e17c", size = 5341001 }, + { url = "https://files.pythonhosted.org/packages/0d/52/c8aebde49f169e4e3452e7756be35be1cb2903e30d961cb57aa65a27055f/lxml-6.0.4-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:173cc246d3d3b6d3b6491f0b3aaf22ebdf2eed616879482acad8bd84d73eb231", size = 4699105 }, + { url = "https://files.pythonhosted.org/packages/78/60/76fc3735c31c28b70220d99452fb72052e84b618693ca2524da96f0131d8/lxml-6.0.4-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f0f2ee1be1b72e9890da87e4e422f2f703ff4638fd5ec5383055db431e8e30e9", size = 5231095 }, + { url = "https://files.pythonhosted.org/packages/e5/60/448f01c52110102f23df5f07b3f4fde57c8e13e497e182a743d125324c0b/lxml-6.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c51a274b7e8b9ce394c3f8b471eb0b23c1914eec64fdccf674e082daf72abf11", size = 5042411 }, + { url = "https://files.pythonhosted.org/packages/4a/2a/90612a001fa4fa0ff0443ebb0256a542670fe35473734c559720293e7aff/lxml-6.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:210ea934cba1a1ec42f88c4190c4d5c67b2d14321a8faed9b39e8378198ff99d", size = 4768431 }, + { url = "https://files.pythonhosted.org/packages/84/d8/572845a7d741c8a8ffeaf928185263e14d97fbd355de164677340951d7a5/lxml-6.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:14fe654a59eebe16368c51778caeb0c8fda6f897adcd9afe828d87d13b5d5e51", size = 5634972 }, + { url = "https://files.pythonhosted.org/packages/d7/1d/392b8c9f8cf1d502bbec50dee137c7af3dd5def5e5cd84572fbf0ba0541c/lxml-6.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ec160a2b7e2b3cb71ec35010b19a1adea05785d19ba5c9c5f986b64b78fef564", size = 5222909 }, + { url = "https://files.pythonhosted.org/packages/21/ab/949fc96f825cf083612aee65d5a02eacc5eaeb2815561220e33e1e160677/lxml-6.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d305b86ef10b23cf3a6d62a2ad23fa296f76495183ee623f64d2600f65ffe09c", size = 5249096 }, + { url = "https://files.pythonhosted.org/packages/56/e8/fbe44df79ede5ff760401cc3c49c4204f49f0f529cc6b27d0af7b63f5472/lxml-6.0.4-cp313-cp313-win32.whl", hash = "sha256:a2f31380aa9a9b52591e79f1c1d3ac907688fbeb9d883ba28be70f2eb5db2277", size = 3595808 }, + { url = "https://files.pythonhosted.org/packages/f8/df/e873abb881092256520edf0d67d686e36f3c86b3cf289f01b6458272dede/lxml-6.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:b8efa9f681f15043e497293d58a4a63199564b253ed2291887d92bb3f74f59ab", size = 3994635 }, + { url = "https://files.pythonhosted.org/packages/23/a8/9c56c8914b9b18d89face5a7472445002baf309167f7af65d988842129fd/lxml-6.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:905abe6a5888129be18f85f2aea51f0c9863fa0722fb8530dfbb687d2841d221", size = 3657374 }, + { url = "https://files.pythonhosted.org/packages/10/18/36e28a809c509a67496202771f545219ac5a2f1cd61aae325991fcf5ab91/lxml-6.0.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:569d3b18340863f603582d2124e742a68e85755eff5e47c26a55e298521e3a01", size = 8575045 }, + { url = "https://files.pythonhosted.org/packages/11/38/a168c820e3b08d3b4fa0f4e6b53b3930086b36cc11e428106d38c36778cd/lxml-6.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b6245ee5241342d45e1a54a4a8bc52ef322333ada74f24aa335c4ab36f20161", size = 4622963 }, + { url = "https://files.pythonhosted.org/packages/53/e0/2c9d6abdd82358cea3c0d8d6ca272a6af0f38156abce7827efb6d5b62d17/lxml-6.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79a1173ba3213a3693889a435417d4e9f3c07d96e30dc7cc3a712ed7361015fe", size = 4948832 }, + { url = "https://files.pythonhosted.org/packages/96/d7/f2202852e91d7baf3a317f4523a9c14834145301e5b0f2e80c01c4bfbd49/lxml-6.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc18bb975666b443ba23aedd2fcf57e9d0d97546b52a1de97a447c4061ba4110", size = 5085865 }, + { url = "https://files.pythonhosted.org/packages/09/57/abee549324496e92708f71391c6060a164d3c95369656a1a15e9f20d8162/lxml-6.0.4-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2079f5dc83291ac190a52f8354b78648f221ecac19fb2972a2d056b555824de7", size = 5030001 }, + { url = "https://files.pythonhosted.org/packages/c2/f8/432da7178c5917a16468af6c5da68fef7cf3357d4bd0e6f50272ec9a59b5/lxml-6.0.4-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3eda02da4ca16e9ca22bbe5654470c17fa1abcd967a52e4c2e50ff278221e351", size = 5646303 }, + { url = "https://files.pythonhosted.org/packages/82/f9/e1c04ef667a6bf9c9dbd3bf04c50fa51d7ee25b258485bb748b27eb9a1c7/lxml-6.0.4-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3787cdc3832b70e21ac2efafea2a82a8ccb5e85bec110dc68b26023e9d3caae", size = 5237940 }, + { url = "https://files.pythonhosted.org/packages/d0/f0/cdea60d92df731725fc3c4f33e387b100f210acd45c92969e42d2ba993fa/lxml-6.0.4-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:3f276d49c23103565d39440b9b3f4fc08fa22f5a96395ea4b4d4fea4458b1505", size = 5350050 }, + { url = "https://files.pythonhosted.org/packages/2e/15/bf52c7a70b6081bb9e00d37cc90fcf60aa84468d9d173ad2fade38ec34c5/lxml-6.0.4-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:fdfdad73736402375b11b3a137e48cd09634177516baf5fc0bd80d1ca85f3cda", size = 4696409 }, + { url = "https://files.pythonhosted.org/packages/c5/69/9bade267332cc06f9a9aa773b5a11bdfb249af485df9e142993009ea1fc4/lxml-6.0.4-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:75912421456946931daba0ec3cedfa824c756585d05bde97813a17992bfbd013", size = 5249072 }, + { url = "https://files.pythonhosted.org/packages/14/ca/043bcacb096d6ed291cbbc58724e9625a453069d6edeb840b0bf18038d05/lxml-6.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:48cd5a88da67233fd82f2920db344503c2818255217cd6ea462c9bb8254ba7cb", size = 5083779 }, + { url = "https://files.pythonhosted.org/packages/04/89/f5fb18d76985969e84af13682e489acabee399bb54738a363925ea6e7390/lxml-6.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:87af86a8fa55b9ff1e6ee4233d762296f2ce641ba948af783fb995c5a8a3371b", size = 4736953 }, + { url = "https://files.pythonhosted.org/packages/84/ba/d1d7284bb4ba951f188c3fc0455943c1fcbd1c33d1324d6d57b7d4a45be6/lxml-6.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a743714cd656ba7ccb29d199783906064c7b5ba3c0e2a79f0244ea0badc6a98c", size = 5669605 }, + { url = "https://files.pythonhosted.org/packages/72/05/1463e55f2de27bb60feddc894dd7c0833bd501f8861392ed416291b38db5/lxml-6.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e31c76bd066fb4f81d9a32e5843bffdf939ab27afb1ffc1c924e749bfbdb00e3", size = 5236886 }, + { url = "https://files.pythonhosted.org/packages/fe/fb/0b6ee9194ce3ac49db4cadaa8a9158f04779fc768b6c27c4e2945d71a99d/lxml-6.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f185fd6e7d550e9917d7103dccf51be589aba953e15994fb04646c1730019685", size = 5263382 }, + { url = "https://files.pythonhosted.org/packages/9a/93/ec18a08e98dd82cac39f1d2511ee2bed5affb94d228356d8ef165a4ec3b9/lxml-6.0.4-cp314-cp314-win32.whl", hash = "sha256:774660028f8722a598400430d2746fb0075949f84a9a5cd9767d9152e3baaac5", size = 3656164 }, + { url = "https://files.pythonhosted.org/packages/15/86/52507316abfc7150bf6bb191e39a12e301ee80334610a493884ae2f9d20d/lxml-6.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:fbd7d14349413f5609c0b537b1a48117d6ccef1af37986af6b03766ad05bf43e", size = 4062512 }, + { url = "https://files.pythonhosted.org/packages/f1/d5/09c593a2ef2234b8cd6cf059e2dc212e0654bf05c503f0ef2daf05adb680/lxml-6.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:a61a01ec3fbfd5b73a69a7bf513271051fd6c5795d82fc5daa0255934cd8db3d", size = 3740745 }, + { url = "https://files.pythonhosted.org/packages/4a/3c/42a98bf6693938bf7b285ec7f70ba2ae9d785d0e5b2cdb85d2ee29e287eb/lxml-6.0.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:504edb62df33cea502ea6e73847c647ba228623ca3f80a228be5723a70984dd5", size = 8826437 }, + { url = "https://files.pythonhosted.org/packages/c2/c2/ad13f39b2db8709788aa2dcb6e90b81da76db3b5b2e7d35e0946cf984960/lxml-6.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f01b7b0316d4c0926d49a7f003b2d30539f392b140a3374bb788bad180bc8478", size = 4734892 }, + { url = "https://files.pythonhosted.org/packages/2c/6d/c559d7b5922c5b0380fc2cb5ac134b6a3f9d79d368347a624ee5d68b0816/lxml-6.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab999933e662501efe4b16e6cfb7c9f9deca7d072cd1788b99c8defde78c0dfb", size = 4969173 }, + { url = "https://files.pythonhosted.org/packages/c7/78/ca521e36157f38e3e1a29276855cdf48d213138fc0c8365693ff5c876ca7/lxml-6.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67c3f084389fe75932c39b6869a377f6c8e21e818f31ae8a30c71dd2e59360e2", size = 5103134 }, + { url = "https://files.pythonhosted.org/packages/28/a7/7d62d023bacaa0aaf60af8c0a77c6c05f84327396d755f3aa64b788678a9/lxml-6.0.4-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:377ea1d654f76ed6205c87d14920f829c9f4d31df83374d3cbcbdaae804d37b2", size = 5027205 }, + { url = "https://files.pythonhosted.org/packages/34/be/51b194b81684f2e85e5d992771c45d70cb22ac6f7291ac6bc7b255830afe/lxml-6.0.4-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e60cd0bcacbfd1a96d63516b622183fb2e3f202300df9eb5533391a8a939dbfa", size = 5594461 }, + { url = "https://files.pythonhosted.org/packages/39/24/8850f38fbf89dd072ff31ba22f9e40347aeada7cadf710ecb04b8d9f32d4/lxml-6.0.4-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9e30fd63d41dd0bbdb020af5cdfffd5d9b554d907cb210f18e8fcdc8eac013", size = 5223378 }, + { url = "https://files.pythonhosted.org/packages/2a/9b/595239ba8c719b0fdc7bc9ebdb7564459c9a6b24b8b363df4a02674aeece/lxml-6.0.4-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:1fb4a1606bb68c533002e7ed50d7e55e58f0ef1696330670281cb79d5ab2050d", size = 5311415 }, + { url = "https://files.pythonhosted.org/packages/be/cb/aa27ac8d041acf34691577838494ad08df78e83fdfdb66948d2903e9291e/lxml-6.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:695c7708438e449d57f404db8cc1b769e77ad5b50655f32f8175686ba752f293", size = 4637953 }, + { url = "https://files.pythonhosted.org/packages/f6/f2/f19114fd86825c2d1ce41cd99daad218d30cfdd2093d4de9273986fb4d68/lxml-6.0.4-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d49c35ae1e35ee9b569892cf8f8f88db9524f28d66e9daee547a5ef9f3c5f468", size = 5231532 }, + { url = "https://files.pythonhosted.org/packages/9a/0e/c3fa354039ec0b6b09f40fbe1129efc572ac6239faa4906de42d5ce87c0a/lxml-6.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5801072f8967625e6249d162065d0d6011ef8ce3d0efb8754496b5246b81a74b", size = 5083767 }, + { url = "https://files.pythonhosted.org/packages/b3/4b/1a0dbb6d6ffae16e54a8a3796ded0ad2f9c3bc1ff3728bde33456f4e1d63/lxml-6.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cbf768541526eba5ef1a49f991122e41b39781eafd0445a5a110fc09947a20b5", size = 4758079 }, + { url = "https://files.pythonhosted.org/packages/a9/01/a246cf5f80f96766051de4b305d6552f80bdaefb37f04e019e42af0aba69/lxml-6.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eecce87cc09233786fc31c230268183bf6375126cfec1c8b3673fcdc8767b560", size = 5618686 }, + { url = "https://files.pythonhosted.org/packages/eb/1f/b072a92369039ebef11b0a654be5134fcf3ed04c0f437faf9435ac9ba845/lxml-6.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:07dce892881179e11053066faca2da17b0eeb0bb7298f11bcf842a86db207dbd", size = 5227259 }, + { url = "https://files.pythonhosted.org/packages/d5/a0/dc97034f9d4c0c4d30875147d81fd2c0c7f3d261b109db36ed746bf8ab1d/lxml-6.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e4f97aee337b947e6699e5574c90d087d3e2ce517016241c07e7e98a28dca885", size = 5246190 }, + { url = "https://files.pythonhosted.org/packages/f2/ef/85cb69835113583c2516fee07d0ffb4d824b557424b06ba5872c20ba6078/lxml-6.0.4-cp314-cp314t-win32.whl", hash = "sha256:064477c0d4c695aa1ea4b9c1c4ee9043ab740d12135b74c458cc658350adcd86", size = 3896005 }, + { url = "https://files.pythonhosted.org/packages/3d/5e/2231f34cc54b8422b793593138d86d3fa4588fb2297d4ea0472390f25627/lxml-6.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:25bad2d8438f4ef5a7ad4a8d8bcaadde20c0daced8bdb56d46236b0a7d1cbdd0", size = 4391037 }, + { url = "https://files.pythonhosted.org/packages/39/53/8ba3cd5984f8363635450c93f63e541a0721b362bb32ae0d8237d9674aee/lxml-6.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:1dcd9e6cb9b7df808ea33daebd1801f37a8f50e8c075013ed2a2343246727838", size = 3816184 }, + { url = "https://files.pythonhosted.org/packages/41/25/260b86340ec5aadda5e18ed39df0eea61ef8781fb0fcc16c847cdb9dfdff/lxml-6.0.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b29bcca95e82cd201d16c2101085faa2669838f4697fd914b7124a6c77032f80", size = 3929209 }, + { url = "https://files.pythonhosted.org/packages/8a/cc/b2157461584525fb0ceb7f4c3b6c1b276f6c7dd34858d78075ae8973bf3d/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a95e29710ecdf99b446990144598f6117271cb2ec19fd45634aa087892087077", size = 4209535 }, + { url = "https://files.pythonhosted.org/packages/1d/fa/7fdcd1eb31ec0d5871a4a0b1587e78a331f59941ff3af59bed064175499e/lxml-6.0.4-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:13085e0174e9c9fa4eb5a6bdfb81646d1f7be07e5895c958e89838afb77630c6", size = 4316979 }, + { url = "https://files.pythonhosted.org/packages/53/0c/dab9f5855e7d2e51c8eb461713ada38a7d4eb3ab07fec8d13c46ed353ad6/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e205c4869a28ec4447375333072978356cd0eeadd0412c643543238e638b89a3", size = 4249929 }, + { url = "https://files.pythonhosted.org/packages/a4/88/39e8e4ca7ee1bc9e7cd2f6b311279624afa70a375eef8727f0bb83db2936/lxml-6.0.4-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aec26080306a66ad5c62fad0053dd2170899b465137caca7eac4b72bda3588bf", size = 4399464 }, + { url = "https://files.pythonhosted.org/packages/66/54/14c518cc9ce5151fcd1fa95a1c2396799a505dca2c4f0acdf85fb23fe293/lxml-6.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3912221f41d96283b10a7232344351c8511e31f18734c752ed4798c12586ea35", size = 3507404 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "pillow" +version = "12.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355 }, + { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871 }, + { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734 }, + { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080 }, + { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236 }, + { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220 }, + { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124 }, + { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324 }, + { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363 }, + { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523 }, + { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318 }, + { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347 }, + { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873 }, + { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168 }, + { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188 }, + { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401 }, + { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655 }, + { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105 }, + { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402 }, + { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149 }, + { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626 }, + { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531 }, + { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279 }, + { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490 }, + { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462 }, + { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744 }, + { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371 }, + { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215 }, + { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783 }, + { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112 }, + { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489 }, + { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129 }, + { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612 }, + { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837 }, + { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528 }, + { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401 }, + { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094 }, + { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402 }, + { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005 }, + { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669 }, + { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194 }, + { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423 }, + { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667 }, + { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580 }, + { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896 }, + { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266 }, + { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508 }, + { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927 }, + { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624 }, + { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252 }, + { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550 }, + { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114 }, + { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667 }, + { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966 }, + { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241 }, + { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592 }, + { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542 }, + { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765 }, + { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848 }, + { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515 }, + { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159 }, + { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185 }, + { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386 }, + { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384 }, + { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599 }, + { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021 }, + { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360 }, + { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628 }, + { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321 }, + { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723 }, + { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400 }, + { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835 }, + { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225 }, + { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541 }, + { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251 }, + { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807 }, + { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935 }, + { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720 }, + { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498 }, + { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413 }, + { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084 }, + { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579 }, + { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969 }, + { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674 }, + { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479 }, + { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230 }, + { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404 }, + { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215 }, + { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "python-pptx" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "lxml" }, + { name = "pillow" }, + { name = "typing-extensions" }, + { name = "xlsxwriter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/a9/0c0db8d37b2b8a645666f7fd8accea4c6224e013c42b1d5c17c93590cd06/python_pptx-1.0.2.tar.gz", hash = "sha256:479a8af0eaf0f0d76b6f00b0887732874ad2e3188230315290cd1f9dd9cc7095", size = 10109297 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4f/00be2196329ebbff56ce564aa94efb0fbc828d00de250b1980de1a34ab49/python_pptx-1.0.2-py3-none-any.whl", hash = "sha256:160838e0b8565a8b1f67947675886e9fea18aa5e795db7ae531606d68e785cba", size = 472788 }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755 }, +] + +[[package]] +name = "slides-to-learndash" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "python-pptx" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "python-pptx", specifier = ">=0.6.21" }, + { name = "typer", specifier = ">=0.9.0" }, +] + +[[package]] +name = "typer" +version = "0.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315 }, +] diff --git a/web/app/plugins/cbf-multisite/includes/Customizations/WP_Cron.php b/web/app/plugins/cbf-multisite/includes/Customizations/WP_Cron.php index 78f80460b..5314b5116 100644 --- a/web/app/plugins/cbf-multisite/includes/Customizations/WP_Cron.php +++ b/web/app/plugins/cbf-multisite/includes/Customizations/WP_Cron.php @@ -30,31 +30,15 @@ public static function export_quiz_activity() { } } - /** - * Add a weekly schedule. - */ - public static function extend_cron_schedules( $schedules ) { - if ( ! array_key_exists( 'weekly', $schedules ) ) { - $schedules['weekly'] = array( - 'interval' => 604800, - 'display' => __( 'Once Weekly' ), - ); - } - - return $schedules; - } - /** * Hook in methods. */ public static function hooks() { - add_filter( 'cron_schedules', array( __CLASS__, 'extend_cron_schedules' ) ); - if ( defined( 'ENABLE_CBF_SCHEDULED_EXPORT' ) && ENABLE_CBF_SCHEDULED_EXPORT && get_current_blog_id() === intval( ACADEMY_SITE_ID ) ) { add_action( self::EXPORT_EVENT_NAME, array( __CLASS__, 'export_quiz_activity' ) ); if ( ! wp_next_scheduled( self::EXPORT_EVENT_NAME ) ) { - wp_schedule_event( strtotime( 'this Saturday 8:00am', time() ), 'weekly', WP_Cron::EXPORT_EVENT_NAME ); + wp_schedule_event( strtotime( 'today 8:00am', time() ), 'daily', WP_Cron::EXPORT_EVENT_NAME ); } } }