]> localhost Git - kotoba_to_anki.git/commitdiff
initial commit master
authorj h <jeffrey.hemminger.ctr@nga.mil>
Thu, 1 Oct 2026 12:57:39 +0000 (21:57 +0900)
committerj h <jeffrey.hemminger.ctr@nga.mil>
Thu, 1 Oct 2026 12:57:39 +0000 (21:57 +0900)
README.md [new file with mode: 0644]
input/.gitkeep [new file with mode: 0644]
output/.gitkeep [new file with mode: 0644]
pyproject.toml [new file with mode: 0644]
src/kotoba_to_anki/__init__.py [new file with mode: 0644]
src/kotoba_to_anki/anki_package.py [new file with mode: 0644]
src/kotoba_to_anki/config.py [new file with mode: 0644]
src/kotoba_to_anki/extractor.py [new file with mode: 0644]
src/kotoba_to_anki/logging_setup.py [new file with mode: 0644]
src/kotoba_to_anki/main.py [new file with mode: 0644]
uv.lock [new file with mode: 0644]

diff --git a/README.md b/README.md
new file mode 100644 (file)
index 0000000..0406be1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,164 @@
+# kotoba-to-anki
+
+Extract "KOTOBA" Japanese vocabulary slides (image + Japanese word + English
+translation) from a folder of PDF slide decks into a single CSV that's ready
+to import into Anki.
+
+## How it works
+
+Each PDF's first page is treated as a title/cover slide and skipped. Every
+other page contains one or two vocabulary items, each made up of:
+
+- one illustration/photo,
+- a Japanese headword (sometimes with a smaller furigana reading next to it),
+- an English translation,
+- occasionally a small grammar tag such as `N`, `V`, or `adverb`.
+
+The script finds every embedded image on a page, splits the page into
+left/right (or single, full-width) zones around those images, and assigns
+each piece of text to the nearest zone based on its position. Grammar tags
+are filtered out using a list in `src/kotoba_to_anki/config.py` (`POS_TAGS`)
+before the remaining text is classified as Japanese or English by character
+range. The larger of two Japanese text fragments becomes the headword; the
+smaller becomes its furigana reading, shown in parentheses.
+
+This is a geometry-based (not fixed-order) approach, so it's robust to PDFs
+where the tag/column layout order shifts from slide to slide -- as long as
+each vocabulary item still has exactly one image nearby.
+
+## Setup
+
+Requires Python 3.10+. Using [uv](https://docs.astral.sh/uv/) (recommended):
+
+```bash
+cd kotoba_to_anki
+uv venv
+uv pip install -e .
+```
+
+Or with a plain venv + pip:
+
+```bash
+cd kotoba_to_anki
+python3 -m venv .venv
+source .venv/bin/activate
+pip install -e .
+```
+
+## Usage
+
+1. Drop your PDFs into `input/` (subfolders are fine, all `*.pdf` files are
+   found recursively).
+2. Run the tool:
+
+   ```bash
+   uv run kotoba-to-anki
+   # or, without the installed console script:
+   uv run python -m kotoba_to_anki.main
+   ```
+
+3. Results:
+   - `output/kotoba_anki_deck.apkg` -- a self-contained Anki package with
+     every note (including its image) bundled inside. This is the
+     recommended way to get the deck into Anki -- see "Importing the .apkg"
+     below.
+   - `output/kotoba_anki_deck.csv` -- one row per vocabulary item, columns
+     `Media, Japanese, English`. The `Media` column contains an
+     `<img src="...">` tag pointing at the matching file in `output/media/`.
+     Kept as a plain-text/spreadsheet-friendly alternative -- see
+     "Importing the CSV instead" below for how to actually get the media
+     into Anki when using this route.
+   - `output/media/` -- every extracted image, named
+     `<pdf-stem>_p<page>_<slot>.<ext>` (e.g. `Step2Unit575859_kotoha_p2_1.png`).
+   - `kotoba_to_anki.log` -- an INFO-level log in the directory you ran the
+     command from. It is **appended to** on every run, so history builds up
+     across sessions -- useful for tracking down which page of which PDF
+     broke on a given run.
+
+   Use `--no-csv` or `--no-apkg` to skip either output, and `--deck-name` /
+   `--apkg-name` / `--csv-name` to rename them.
+
+All PDFs found in `input/` are combined into that single CSV/media output
+(many PDFs -> one deck), matching a typical "one Anki deck per unit batch"
+workflow.
+
+### Options
+
+```
+--input-dir DIR    Directory of source PDFs (default: input)
+--output-dir DIR   Directory for the CSV + apkg + media/ folder (default: output)
+--csv-name NAME     Output CSV filename (default: kotoba_anki_deck.csv)
+--apkg-name NAME     Output .apkg filename (default: kotoba_anki_deck.apkg)
+--deck-name NAME     Deck name inside the .apkg (default: KOTOBA Vocabulary)
+--no-csv             Skip writing the CSV
+--no-apkg            Skip writing the .apkg
+--log-name NAME      Log filename, written to the current directory (default: kotoba_to_anki.log)
+```
+
+## Importing the .apkg (recommended)
+
+This is the easiest path and the one most people should use:
+
+1. In Anki, *File > Import*, choose `output/kotoba_anki_deck.apkg`.
+2. That's it -- the note type ("KOTOBA Vocabulary"), the deck, every note,
+   and every image are all bundled inside the file. Anki extracts the
+   images into `collection.media` and creates the notes automatically; no
+   manual file copying, no path/field mapping step.
+3. The default card shows the image + Japanese term on the front and the
+   English translation on the back. Feel free to edit the note type's card
+   template afterward in Anki (*Tools > Manage Note Types*) -- as long as
+   you don't change `config.ANKI_MODEL_ID`/`ANKI_DECK_ID` in a later run,
+   re-importing after regenerating the deck will merge into the same note
+   type/deck rather than creating duplicates (each note's identity is
+   derived from its field content, so unchanged entries are recognized as
+   the same note and only new/changed ones are added or updated).
+
+## Importing the CSV instead
+
+Use this only if you specifically want a plain CSV (e.g. to inspect/edit in
+a spreadsheet before importing, or to feed a different tool). The CSV's
+`Media` column contains bare `<img src="filename.jpg">` tags -- Anki fields
+store raw HTML, and `<img src="...">` is resolved by Anki against a single
+**flat** `collection.media` folder (no subfolders), never as a filesystem
+path relative to the CSV. That lookup only succeeds if you've put the
+matching file into that folder yourself first:
+
+1. Copy every file from `output/media/` into your Anki profile's
+   `collection.media` folder (Anki: *Tools > Check Media...* will tell you
+   where that folder is if you're not sure; e.g. on macOS it's
+   `~/Library/Application Support/Anki2/<profile>/collection.media`, on
+   Windows `%APPDATA%\Anki2\<profile>\collection.media`, on Linux
+   `~/.local/share/Anki2/<profile>/collection.media`).
+2. In Anki, *File > Import*, choose `output/kotoba_anki_deck.csv`.
+3. Map the three columns to fields on a note type that has an image field
+   and accepts HTML (e.g. Basic, with the Media column mapped to the Front
+   or a dedicated "Picture" field). Since the first row is a header
+   (`Media,Japanese,English`), tell the importer to skip/ignore the first
+   line, or delete it from the CSV before importing if your Anki version
+   doesn't offer that option.
+4. Do step 1 *before* you review the new cards -- if the image isn't in
+   `collection.media` yet, the card just shows a broken-image icon instead
+   of an error.
+
+## Debugging a broken run
+
+If a PDF's layout doesn't match expectations (e.g. a brand new grammar tag,
+or a slide with an unusual number of images), the script logs a `WARNING`
+line rather than crashing, and continues to the next page/PDF. Check
+`kotoba_to_anki.log` for lines like:
+
+```
+... p4 slot 1: 1 extra English line(s) ignored near x=310: ['Adj'] (if these are a new grammar tag, add it to config.POS_TAGS)
+```
+
+That means a piece of text wasn't recognized as a known tag and got treated
+as a stray English line. Add the new tag to `POS_TAGS` in
+`src/kotoba_to_anki/config.py` and re-run.
+
+Other useful log signals:
+
+- `no embedded images found, skipping page` -- a page had no images at all
+  (nothing to build a card from).
+- `missing Japanese text` / `missing English text` -- an item's image was
+  found but its paired text wasn't; the row is still written with the
+  missing field left blank so you can spot and fix it manually.
diff --git a/input/.gitkeep b/input/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/output/.gitkeep b/output/.gitkeep
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644 (file)
index 0000000..60411c6
--- /dev/null
@@ -0,0 +1,20 @@
+[project]
+name = "kotoba-to-anki"
+version = "0.1.0"
+description = "Extract KOTOBA Japanese vocabulary flashcards (image + Japanese + English) from PDF slide decks into a single Anki-importable CSV."
+readme = "README.md"
+requires-python = ">=3.10"
+dependencies = [
+    "pymupdf>=1.24.0",
+    "genanki>=0.13.0",
+]
+
+[project.scripts]
+kotoba-to-anki = "kotoba_to_anki.main:main"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/kotoba_to_anki"]
diff --git a/src/kotoba_to_anki/__init__.py b/src/kotoba_to_anki/__init__.py
new file mode 100644 (file)
index 0000000..ceb9327
--- /dev/null
@@ -0,0 +1,3 @@
+"""kotoba_to_anki: extract KOTOBA Japanese vocabulary slides from PDFs into an Anki-ready CSV."""
+
+__version__ = "0.1.0"
diff --git a/src/kotoba_to_anki/anki_package.py b/src/kotoba_to_anki/anki_package.py
new file mode 100644 (file)
index 0000000..99a616f
--- /dev/null
@@ -0,0 +1,101 @@
+"""Build a native Anki package (.apkg) from extracted vocabulary entries.
+
+Unlike the CSV output, a .apkg bundles its media files inside the package
+itself. Anki copies them into the profile's collection.media folder
+automatically on import, so there is no manual "find my collection.media
+folder and copy files into it" step, and no ambiguity about how the image
+reference in a field resolves to a file on disk.
+"""
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+import genanki
+
+from . import config
+from .extractor import VocabEntry
+
+logger = logging.getLogger("kotoba_to_anki")
+
+_CSS = """
+.card {
+    font-family: "Helvetica Neue", Arial, sans-serif;
+    text-align: center;
+    color: #222;
+    background-color: white;
+}
+.media img {
+    max-width: 320px;
+    max-height: 320px;
+    margin-bottom: 12px;
+}
+.jp {
+    font-size: 32px;
+    margin-top: 8px;
+}
+.en {
+    font-size: 26px;
+    margin-top: 8px;
+    color: #2a6b2a;
+}
+"""
+
+
+def _build_model() -> genanki.Model:
+    return genanki.Model(
+        config.ANKI_MODEL_ID,
+        "KOTOBA Vocabulary",
+        fields=[
+            {"name": "Media"},
+            {"name": "Japanese"},
+            {"name": "English"},
+        ],
+        templates=[
+            {
+                "name": "Picture + Japanese -> English",
+                "qfmt": '<div class="media">{{Media}}</div>\n<div class="jp">{{Japanese}}</div>',
+                "afmt": '{{FrontSide}}\n<hr id="answer">\n<div class="en">{{English}}</div>',
+            }
+        ],
+        css=_CSS,
+    )
+
+
+def write_apkg(
+    entries: list[VocabEntry],
+    media_dir: Path,
+    apkg_path: Path,
+    deck_name: str = config.DEFAULT_DECK_NAME,
+) -> int:
+    """Write all entries to a single .apkg file. Returns the number of notes written."""
+    model = _build_model()
+    deck = genanki.Deck(config.ANKI_DECK_ID, deck_name)
+
+    media_files: list[str] = []
+    written = 0
+
+    for entry in entries:
+        image_path = media_dir / entry.image_filename
+        if not image_path.exists():
+            logger.warning(
+                "Skipping apkg note for %r: media file not found at %s",
+                entry.japanese or entry.english,
+                image_path,
+            )
+            continue
+
+        note = genanki.Note(
+            model=model,
+            fields=[f'<img src="{entry.image_filename}">', entry.japanese, entry.english],
+        )
+        deck.add_note(note)
+        media_files.append(str(image_path))
+        written += 1
+
+    package = genanki.Package(deck)
+    package.media_files = media_files
+    package.write_to_file(apkg_path)
+
+    logger.info("Wrote %d note(s) to Anki package %s (deck %r)", written, apkg_path, deck_name)
+    return written
diff --git a/src/kotoba_to_anki/config.py b/src/kotoba_to_anki/config.py
new file mode 100644 (file)
index 0000000..7ddc4f0
--- /dev/null
@@ -0,0 +1,70 @@
+"""Configuration constants for the KOTOBA -> Anki extraction pipeline.
+
+Tweak these values if a new PDF unit uses a layout convention (e.g. a new
+grammar tag) that the extractor doesn't recognize yet. Check the log file
+after a run -- unrecognized tags usually show up as "extra English line(s)
+ignored" warnings, which is the signal to add a new entry to POS_TAGS below.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+# ---------------------------------------------------------------------------
+# Directories / filenames (all overridable via CLI flags, see main.py --help)
+# ---------------------------------------------------------------------------
+DEFAULT_INPUT_DIR = Path("input")
+DEFAULT_OUTPUT_DIR = Path("output")
+DEFAULT_MEDIA_SUBDIR = "media"
+DEFAULT_CSV_NAME = "kotoba_anki_deck.csv"
+DEFAULT_APKG_NAME = "kotoba_anki_deck.apkg"
+DEFAULT_DECK_NAME = "KOTOBA Vocabulary"
+DEFAULT_LOG_NAME = "kotoba_to_anki.log"
+
+# Fixed genanki model/deck IDs. These MUST stay constant across runs: Anki
+# uses them (plus each note's guid, which genanki derives from its field
+# content) to recognize "this is the same note type / deck" on re-import,
+# so re-running the pipeline updates/merges instead of duplicating cards.
+# Do not change these unless you want re-imports to create a brand new,
+# unrelated note type and deck.
+ANKI_MODEL_ID = 1607392319934
+ANKI_DECK_ID = 1607392319935
+
+# Number of leading pages to skip in every PDF (title/cover page(s)).
+TITLE_PAGES_TO_SKIP = 1
+
+# ---------------------------------------------------------------------------
+# Part-of-speech / grammar tag labels that appear as small standalone
+# annotations on a slide (e.g. "N", "V", "adverb"). These are discarded
+# before the remaining text on the slide is classified as Japanese or
+# English. Matching is case-insensitive against the FULL stripped text of a
+# line (punctuation/whitespace removed), so "N", "n", "N " and "(N)" all
+# match the same "n" entry below.
+POS_TAGS = {
+    "n", "v", "adj", "adj-i", "adj-na", "adj-no", "i-adj", "na-adj",
+    "adv", "adverb",
+    "exp", "expression", "phrase",
+    "conj", "conjunction",
+    "prep", "preposition",
+    "pron", "pronoun",
+    "part", "particle",
+    "interj", "interjection",
+    "noun", "verb", "adjective",
+    "counter", "suffix", "prefix",
+    "aux", "auxiliary",
+    "num", "numeral",
+}
+
+# ---------------------------------------------------------------------------
+# Unicode ranges used to detect Japanese text (hiragana, katakana, kanji,
+# and common Japanese/fullwidth punctuation).
+# ---------------------------------------------------------------------------
+JAPANESE_RANGES = (
+    (0x3040, 0x309F),  # Hiragana
+    (0x30A0, 0x30FF),  # Katakana
+    (0x4E00, 0x9FFF),  # CJK Unified Ideographs (Kanji)
+    (0x3000, 0x303F),  # CJK punctuation (incl. the full-width brackets/dot)
+    (0xFF00, 0xFFEF),  # Fullwidth forms
+)
+
+# Output CSV column order (matches: media / Japanese / English request).
+CSV_HEADERS = ["Media", "Japanese", "English"]
diff --git a/src/kotoba_to_anki/extractor.py b/src/kotoba_to_anki/extractor.py
new file mode 100644 (file)
index 0000000..45d0b22
--- /dev/null
@@ -0,0 +1,299 @@
+"""Core PDF -> vocabulary-entry extraction logic.
+
+Layout assumption (observed across KOTOBA vocabulary slides):
+
+- Page 1 of every PDF is a title/cover page and is skipped.
+- Each remaining page contains 1-2 "vocabulary items" laid out side by side
+  (occasionally just one, centered / full width).
+- Each item = one embedded image + one Japanese text line (optionally with a
+  smaller furigana reading line near it) + one English translation line.
+- Some slides also show a small grammar/part-of-speech tag ("N", "V",
+  "adverb", ...) which is not part of any vocabulary item and must be
+  discarded (see config.POS_TAGS).
+
+Slide designs vary quite a bit (highlight colors, tag position, one vs two
+columns, text stacking order), so extraction is done geometrically rather
+than by assuming a fixed text order:
+
+1. Every embedded image on the page is located (with its bounding box).
+2. The page width is split into horizontal "slots", one per image, using the
+   midpoints between neighboring image centers as slot boundaries. This
+   works for any number of images (1, 2, or more) per page.
+3. Every text line on the page is assigned to the slot whose image center is
+   horizontally closest.
+4. Within a slot, text lines are classified as Japanese or English by
+   character ranges. Grammar tags are dropped beforehand.
+5. The largest Japanese text in a slot is treated as the headword; a second,
+   smaller Japanese text is treated as its furigana reading. The
+   bottom-most English line is treated as the translation.
+"""
+from __future__ import annotations
+
+import logging
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+import pymupdf
+
+from . import config
+
+logger = logging.getLogger("kotoba_to_anki")
+
+_TAG_STRIP_RE = re.compile(r"[\s.:()\[\]・、。]+")
+
+
+@dataclass
+class VocabEntry:
+    japanese: str
+    english: str
+    image_filename: str
+
+
+@dataclass
+class _Line:
+    text: str
+    x0: float
+    y0: float
+    x1: float
+    y1: float
+    size: float
+
+    @property
+    def x_center(self) -> float:
+        return (self.x0 + self.x1) / 2
+
+
+@dataclass
+class _ImageSlot:
+    xref: int
+    x0: float
+    y0: float
+    x1: float
+    y1: float
+    slot_x0: float = 0.0
+    slot_x1: float = 0.0
+    japanese_lines: list[_Line] = field(default_factory=list)
+    english_lines: list[_Line] = field(default_factory=list)
+
+    @property
+    def x_center(self) -> float:
+        return (self.x0 + self.x1) / 2
+
+
+def _is_japanese_char(ch: str) -> bool:
+    code = ord(ch)
+    return any(lo <= code <= hi for lo, hi in config.JAPANESE_RANGES)
+
+
+def _contains_japanese(text: str) -> bool:
+    return any(_is_japanese_char(ch) for ch in text)
+
+
+def _is_pos_tag(text: str) -> bool:
+    cleaned = _TAG_STRIP_RE.sub("", text).strip().lower()
+    return cleaned in config.POS_TAGS
+
+
+def _extract_lines(page: "pymupdf.Page") -> list[_Line]:
+    """Flatten the page's text into line-level records with bounding boxes."""
+    lines: list[_Line] = []
+    page_dict = page.get_text("dict")
+    for block in page_dict.get("blocks", []):
+        for line in block.get("lines", []):
+            spans = line.get("spans", [])
+            if not spans:
+                continue
+            text = "".join(span.get("text", "") for span in spans).strip()
+            if not text:
+                continue
+            x0 = min(s["bbox"][0] for s in spans)
+            y0 = min(s["bbox"][1] for s in spans)
+            x1 = max(s["bbox"][2] for s in spans)
+            y1 = max(s["bbox"][3] for s in spans)
+            size = max(s.get("size", 0.0) for s in spans)
+            lines.append(_Line(text=text, x0=x0, y0=y0, x1=x1, y1=y1, size=size))
+    return lines
+
+
+def _build_slots(page: "pymupdf.Page") -> list[_ImageSlot]:
+    """One slot per embedded image, with slot x-boundaries splitting the page."""
+    infos = [info for info in page.get_image_info(xrefs=True) if info.get("xref")]
+    if not infos:
+        return []
+
+    slots = [
+        _ImageSlot(
+            xref=info["xref"],
+            x0=info["bbox"][0],
+            y0=info["bbox"][1],
+            x1=info["bbox"][2],
+            y1=info["bbox"][3],
+        )
+        for info in infos
+    ]
+    slots.sort(key=lambda s: s.x_center)
+
+    page_width = page.rect.width
+    for i, slot in enumerate(slots):
+        if i == 0:
+            left_bound = 0.0
+        else:
+            left_bound = (slots[i - 1].x_center + slot.x_center) / 2
+        if i == len(slots) - 1:
+            right_bound = page_width
+        else:
+            right_bound = (slot.x_center + slots[i + 1].x_center) / 2
+        slot.slot_x0 = left_bound
+        slot.slot_x1 = right_bound
+    return slots
+
+
+def _assign_lines_to_slots(
+    lines: list[_Line], slots: list[_ImageSlot], pdf_name: str, page_number: int
+) -> None:
+    for line in lines:
+        if _is_pos_tag(line.text):
+            logger.debug("%s p%s: dropping tag-like line %r", pdf_name, page_number, line.text)
+            continue
+
+        best_slot = min(slots, key=lambda s: abs(line.x_center - s.x_center))
+
+        if _contains_japanese(line.text):
+            best_slot.japanese_lines.append(line)
+        else:
+            best_slot.english_lines.append(line)
+
+
+def _resolve_japanese(slot: _ImageSlot, pdf_name: str, page_number: int) -> Optional[str]:
+    if not slot.japanese_lines:
+        return None
+    ordered = sorted(slot.japanese_lines, key=lambda l: l.size, reverse=True)
+    headword = ordered[0]
+    reading = ordered[1] if len(ordered) > 1 else None
+    if len(ordered) > 2:
+        logger.warning(
+            "%s p%s: %d extra Japanese line(s) ignored near x=%.0f: %s",
+            pdf_name,
+            page_number,
+            len(ordered) - 2,
+            slot.x_center,
+            [l.text for l in ordered[2:]],
+        )
+    if reading:
+        return f"{headword.text} ({reading.text})"
+    return headword.text
+
+
+def _resolve_english(slot: _ImageSlot, pdf_name: str, page_number: int) -> Optional[str]:
+    if not slot.english_lines:
+        return None
+    ordered = sorted(slot.english_lines, key=lambda l: l.y0)
+    translation = ordered[-1]
+    if len(ordered) > 1:
+        logger.warning(
+            "%s p%s: %d extra English line(s) ignored near x=%.0f: %s "
+            "(if these are a new grammar tag, add it to config.POS_TAGS)",
+            pdf_name,
+            page_number,
+            len(ordered) - 1,
+            slot.x_center,
+            [l.text for l in ordered[:-1]],
+        )
+    return translation.text
+
+
+def _save_image(doc: "pymupdf.Document", xref: int, media_dir: Path, base_name: str) -> Optional[str]:
+    try:
+        image = doc.extract_image(xref)
+    except Exception:
+        logger.exception("Failed to extract image xref=%s (%s)", xref, base_name)
+        return None
+    ext = image.get("ext", "png")
+    filename = f"{base_name}.{ext}"
+    out_path = media_dir / filename
+    try:
+        out_path.write_bytes(image["image"])
+    except Exception:
+        logger.exception("Failed to write image file %s", out_path)
+        return None
+    return filename
+
+
+def extract_pdf(pdf_path: Path, media_dir: Path) -> list[VocabEntry]:
+    """Extract all vocabulary entries from a single PDF (skipping its title page)."""
+    entries: list[VocabEntry] = []
+    pdf_name = pdf_path.name
+
+    try:
+        doc = pymupdf.open(pdf_path)
+    except Exception:
+        logger.exception("Could not open PDF: %s", pdf_path)
+        return entries
+
+    logger.info("Processing %s (%d pages)", pdf_name, doc.page_count)
+
+    for page_index in range(config.TITLE_PAGES_TO_SKIP, doc.page_count):
+        page_number = page_index + 1
+        page = doc.load_page(page_index)
+
+        try:
+            slots = _build_slots(page)
+            if not slots:
+                logger.warning("%s p%s: no embedded images found, skipping page", pdf_name, page_number)
+                continue
+
+            lines = _extract_lines(page)
+            _assign_lines_to_slots(lines, slots, pdf_name, page_number)
+
+            for slot_index, slot in enumerate(slots, start=1):
+                japanese = _resolve_japanese(slot, pdf_name, page_number)
+                english = _resolve_english(slot, pdf_name, page_number)
+
+                if not japanese and not english:
+                    logger.warning(
+                        "%s p%s slot %d: no text found near image xref=%s, skipping item",
+                        pdf_name,
+                        page_number,
+                        slot_index,
+                        slot.xref,
+                    )
+                    continue
+                if not japanese:
+                    logger.warning(
+                        "%s p%s slot %d: missing Japanese text (english=%r)",
+                        pdf_name, page_number, slot_index, english,
+                    )
+                if not english:
+                    logger.warning(
+                        "%s p%s slot %d: missing English text (japanese=%r)",
+                        pdf_name, page_number, slot_index, japanese,
+                    )
+
+                base_name = f"{pdf_path.stem}_p{page_number}_{slot_index}"
+                filename = _save_image(doc, slot.xref, media_dir, base_name)
+                if not filename:
+                    logger.warning(
+                        "%s p%s slot %d: skipping item, image could not be saved",
+                        pdf_name, page_number, slot_index,
+                    )
+                    continue
+
+                entry = VocabEntry(
+                    japanese=japanese or "",
+                    english=english or "",
+                    image_filename=filename,
+                )
+                entries.append(entry)
+                logger.info(
+                    "%s p%s slot %d: extracted entry JP=%r EN=%r image=%s",
+                    pdf_name, page_number, slot_index, entry.japanese, entry.english, filename,
+                )
+        except Exception:
+            logger.exception("%s p%s: unexpected error, skipping page", pdf_name, page_number)
+            continue
+
+    doc.close()
+    logger.info("Finished %s: %d entries extracted", pdf_name, len(entries))
+    return entries
diff --git a/src/kotoba_to_anki/logging_setup.py b/src/kotoba_to_anki/logging_setup.py
new file mode 100644 (file)
index 0000000..ff68ba8
--- /dev/null
@@ -0,0 +1,51 @@
+"""Logging configuration.
+
+Logs at INFO level (and above) to both the console and a log file in the
+base directory (the directory the script is run from). The log file is
+opened in append mode so history builds up across multiple runs, which is
+useful for debugging intermittent PDF layout issues over time.
+"""
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+_LOGGER_NAME = "kotoba_to_anki"
+
+
+def setup_logging(base_dir: Path, log_name: str) -> logging.Logger:
+    """Configure and return the package logger.
+
+    Safe to call multiple times (e.g. in tests) -- handlers are only
+    attached once per process.
+    """
+    base_dir.mkdir(parents=True, exist_ok=True)
+    log_path = base_dir / log_name
+
+    logger = logging.getLogger(_LOGGER_NAME)
+    logger.setLevel(logging.INFO)
+    logger.propagate = False
+
+    if logger.handlers:
+        return logger
+
+    fmt = logging.Formatter(
+        fmt="%(asctime)s [%(levelname)s] %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+    )
+
+    file_handler = logging.FileHandler(log_path, mode="a", encoding="utf-8")
+    file_handler.setLevel(logging.INFO)
+    file_handler.setFormatter(fmt)
+
+    console_handler = logging.StreamHandler()
+    console_handler.setLevel(logging.INFO)
+    console_handler.setFormatter(fmt)
+
+    logger.addHandler(file_handler)
+    logger.addHandler(console_handler)
+
+    logger.info("=" * 78)
+    logger.info("Logging initialized -> %s (append mode)", log_path)
+
+    return logger
diff --git a/src/kotoba_to_anki/main.py b/src/kotoba_to_anki/main.py
new file mode 100644 (file)
index 0000000..476dd02
--- /dev/null
@@ -0,0 +1,144 @@
+"""CLI entry point: convert a directory of KOTOBA vocabulary PDFs into a
+single Anki-importable CSV (many PDFs -> one CSV + one media folder).
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import sys
+from pathlib import Path
+
+from . import config
+from .anki_package import write_apkg
+from .extractor import extract_pdf
+from .logging_setup import setup_logging
+
+
+def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "Extract KOTOBA vocabulary (image + Japanese + English) from every "
+            "PDF in --input-dir into a single Anki-importable CSV in --output-dir."
+        )
+    )
+    parser.add_argument(
+        "--input-dir",
+        type=Path,
+        default=config.DEFAULT_INPUT_DIR,
+        help="Directory containing the source PDFs, searched recursively. Default: %(default)s",
+    )
+    parser.add_argument(
+        "--output-dir",
+        type=Path,
+        default=config.DEFAULT_OUTPUT_DIR,
+        help="Directory to write the CSV and media/ folder into. Default: %(default)s",
+    )
+    parser.add_argument(
+        "--csv-name",
+        default=config.DEFAULT_CSV_NAME,
+        help="Filename for the combined output CSV. Default: %(default)s",
+    )
+    parser.add_argument(
+        "--apkg-name",
+        default=config.DEFAULT_APKG_NAME,
+        help="Filename for the combined output Anki package (.apkg). Default: %(default)s",
+    )
+    parser.add_argument(
+        "--deck-name",
+        default=config.DEFAULT_DECK_NAME,
+        help="Deck name to use inside the generated .apkg. Default: %(default)s",
+    )
+    parser.add_argument(
+        "--no-csv",
+        action="store_true",
+        help="Skip writing the CSV output.",
+    )
+    parser.add_argument(
+        "--no-apkg",
+        action="store_true",
+        help="Skip writing the .apkg output.",
+    )
+    parser.add_argument(
+        "--log-name",
+        default=config.DEFAULT_LOG_NAME,
+        help="Filename for the log file, written to the current working directory "
+        "and appended to on every run. Default: %(default)s",
+    )
+    return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = parse_args(argv)
+    base_dir = Path.cwd()
+    logger = setup_logging(base_dir, args.log_name)
+
+    input_dir: Path = args.input_dir
+    output_dir: Path = args.output_dir
+    media_dir = output_dir / config.DEFAULT_MEDIA_SUBDIR
+    csv_path = output_dir / args.csv_name
+    apkg_path = output_dir / args.apkg_name
+
+    if not input_dir.exists():
+        logger.error("Input directory does not exist: %s", input_dir)
+        return 1
+
+    pdf_paths = sorted(input_dir.rglob("*.pdf"))
+    if not pdf_paths:
+        logger.warning("No PDFs found in %s", input_dir)
+        return 0
+
+    output_dir.mkdir(parents=True, exist_ok=True)
+    media_dir.mkdir(parents=True, exist_ok=True)
+
+    logger.info("Run started: %d PDF(s) found in %s", len(pdf_paths), input_dir)
+
+    all_entries = []
+    pdf_success = 0
+    pdf_failed = 0
+
+    for pdf_path in pdf_paths:
+        try:
+            entries = extract_pdf(pdf_path, media_dir)
+        except Exception:
+            logger.exception(
+                "Fatal error while processing %s, continuing with remaining PDFs", pdf_path
+            )
+            pdf_failed += 1
+            continue
+
+        if entries:
+            pdf_success += 1
+        else:
+            logger.warning("%s produced no vocabulary entries", pdf_path.name)
+
+        all_entries.extend(entries)
+
+    if not args.no_csv:
+        with csv_path.open("w", newline="", encoding="utf-8") as f:
+            writer = csv.writer(f)
+            writer.writerow(config.CSV_HEADERS)
+            writer.writerows(
+                [f'<img src="{e.image_filename}">', e.japanese, e.english] for e in all_entries
+            )
+        logger.info("Wrote %d row(s) to CSV %s", len(all_entries), csv_path)
+    else:
+        logger.info("Skipping CSV output (--no-csv)")
+
+    if not args.no_apkg:
+        write_apkg(all_entries, media_dir, apkg_path, deck_name=args.deck_name)
+    else:
+        logger.info("Skipping .apkg output (--no-apkg)")
+
+    logger.info(
+        "Run finished: %d PDF(s) OK, %d PDF(s) failed, %d total vocabulary entries",
+        pdf_success,
+        pdf_failed,
+        len(all_entries),
+    )
+    logger.info("Media files written to %s", media_dir)
+
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/uv.lock b/uv.lock
new file mode 100644 (file)
index 0000000..3332c4a
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,162 @@
+version = 1
+revision = 3
+requires-python = ">=3.10"
+
+[[package]]
+name = "cached-property"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/76/4b/3d870836119dbe9a5e3c9a61af8cc1a8b69d75aea564572e385882d5aefb/cached_property-2.0.1.tar.gz", hash = "sha256:484d617105e3ee0e4f1f58725e72a8ef9e93deee462222dbd51cd91230897641", size = 10574, upload-time = "2024-10-25T15:43:55.667Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/11/0e/7d8225aab3bc1a0f5811f8e1b557aa034ac04bdf641925b30d3caf586b28/cached_property-2.0.1-py3-none-any.whl", hash = "sha256:f617d70ab1100b7bcf6e42228f9ddcb78c676ffa167278d9f730d1c2fba69ccb", size = 7428, upload-time = "2024-10-25T15:43:54.711Z" },
+]
+
+[[package]]
+name = "chevron"
+version = "0.14.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440, upload-time = "2021-01-02T22:47:59.233Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595, upload-time = "2021-01-02T22:47:57.847Z" },
+]
+
+[[package]]
+name = "frozendict"
+version = "2.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/90/b2/2a3d1374b7780999d3184e171e25439a8358c47b481f68be883c14086b4c/frozendict-2.4.7.tar.gz", hash = "sha256:e478fb2a1391a56c8a6e10cc97c4a9002b410ecd1ac28c18d780661762e271bd", size = 317082, upload-time = "2025-11-11T22:40:14.251Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/bd/920b1c5ff1df427a5fc3fd4c2f13b0b0e720c3d57fafd80557094c1fefe0/frozendict-2.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bd37c087a538944652363cfd77fb7abe8100cc1f48afea0b88b38bf0f469c3d2", size = 59848, upload-time = "2025-11-11T22:37:10.964Z" },
+    { url = "https://files.pythonhosted.org/packages/a6/9c/e3e186925b1d84f816d458be4e2ea785bbeba15fd2e9e85c5ae7e7a90421/frozendict-2.4.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2b96f224a5431889f04b2bc99c0e9abe285679464273ead83d7d7f2a15907d35", size = 38164, upload-time = "2025-11-11T22:37:12.622Z" },
+    { url = "https://files.pythonhosted.org/packages/10/4c/af931d88c51ee2fcbf8c817557dcb975133a188f1b44bfa82caa940beeab/frozendict-2.4.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5c1781f28c4bbb177644b3cb6d5cf7da59be374b02d91cdde68d1d5ef32e046b", size = 38341, upload-time = "2025-11-11T22:37:13.611Z" },
+    { url = "https://files.pythonhosted.org/packages/ba/7a/c1fd4f736758cf93939cc3b7c8399fe1db0c121881431d41fcdbae344343/frozendict-2.4.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8a06f6c3d3b8d487226fdde93f621e04a54faecc5bf5d9b16497b8f9ead0ac3e", size = 112882, upload-time = "2025-11-11T22:37:15.098Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/b0/304294f7cd099582a98d63e7a9cec34a9905d07f7628b42fc3f9c9a9bc94/frozendict-2.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b809d1c861436a75b2b015dbfd94f6154fa4e7cb0a70e389df1d5f6246b21d1e", size = 120482, upload-time = "2025-11-11T22:37:16.182Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/61/689212ea4124fcbd097c0ac02c2c6a4e345ccc132d9104d054ff6b43ab64/frozendict-2.4.7-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75eefdf257a84ea73d553eb80d0abbff0af4c9df62529e4600fd3f96ff17eeb3", size = 113527, upload-time = "2025-11-11T22:37:17.389Z" },
+    { url = "https://files.pythonhosted.org/packages/5c/9b/38a762f4e76903efd4340454cac2820f583929457822111ef6a00ff1a3f4/frozendict-2.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a4d2b27d8156922c9739dd2ff4f3934716e17cfd1cf6fb61aa17af7d378555e9", size = 130068, upload-time = "2025-11-11T22:37:18.494Z" },
+    { url = "https://files.pythonhosted.org/packages/cf/41/9751e9ec1a2e810e8f961aea4f8958953157478daff6b868277ab7c5ef8c/frozendict-2.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ebd953c41408acfb8041ff9e6c3519c09988fb7e007df7ab6b56e229029d788", size = 126184, upload-time = "2025-11-11T22:37:19.789Z" },
+    { url = "https://files.pythonhosted.org/packages/71/be/b179b5f200cb0f52debeccc63b786cabcc408c4542f47c4245f978ad36e3/frozendict-2.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c64d34b802912ee6d107936e970b90750385a1fdfd38d310098b2918ba4cbf2", size = 120168, upload-time = "2025-11-11T22:37:20.929Z" },
+    { url = "https://files.pythonhosted.org/packages/25/c2/1536bc363dbce414e6b632f496aa8219c0db459a99eeafa02eba380e4cfa/frozendict-2.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:294a7d7d51dd979021a8691b46aedf9bd4a594ce3ed33a4bdf0a712d6929d712", size = 114997, upload-time = "2025-11-11T22:37:21.888Z" },
+    { url = "https://files.pythonhosted.org/packages/29/63/3e9efb490c00a0bf3c7bbf72fc73c90c4a6ebe30595e0fc44f59182b2ae7/frozendict-2.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f65d1b90e9ddc791ea82ef91a9ae0ab27ef6c0cfa88fadfa0e5ca5a22f8fa22f", size = 117292, upload-time = "2025-11-11T22:37:22.978Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/66/d25b1e94f9b0e64025d5cadc77b9b857737ebffd8963ee91de7c5a06415a/frozendict-2.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:82d5272d08451bcef6fb6235a0a04cf1816b6b6815cec76be5ace1de17e0c1a4", size = 110656, upload-time = "2025-11-11T22:38:37.652Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/5d/0e7e3294e18bf41d38dbc9ee82539be607c8d26e763ae12d9e41f03f2dae/frozendict-2.4.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5943c3f683d3f32036f6ca975e920e383d85add1857eee547742de9c1f283716", size = 113225, upload-time = "2025-11-11T22:38:38.631Z" },
+    { url = "https://files.pythonhosted.org/packages/e0/fb/b72c9b261ac7a7803528aa63bba776face8ad8d39cc4ca4825ddaa7777a9/frozendict-2.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:88c6bea948da03087035bb9ca9625305d70e084aa33f11e17048cb7dda4ca293", size = 126713, upload-time = "2025-11-11T22:38:39.588Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/d9/e13af40bd9ef27b5c9ba10b0e31b03acac9468236b878dab030c75102a47/frozendict-2.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ffd1a9f9babec9119712e76a39397d8aa0d72ef8c4ccad917c6175d7e7f81b74", size = 114166, upload-time = "2025-11-11T22:38:41.073Z" },
+    { url = "https://files.pythonhosted.org/packages/40/2b/435583b11f5332cd3eb479d0a67a87bc9247c8b094169b07bd8f0777fc48/frozendict-2.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0ff6f57854cc8aa8b30947ec005f9246d96e795a78b21441614e85d39b708822", size = 121542, upload-time = "2025-11-11T22:38:42.199Z" },
+    { url = "https://files.pythonhosted.org/packages/38/25/097f3c0dc916d7c76f782cb65544e683ff3940a0ed997fc32efdb0989c45/frozendict-2.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d774df483c12d6cba896eb9a1337bbc5ad3f564eb18cfaaee3e95fb4402f2a86", size = 118610, upload-time = "2025-11-11T22:38:43.339Z" },
+    { url = "https://files.pythonhosted.org/packages/61/d1/6964158524484d7f3410386ff27cbc8f33ef06f8d9ee0e188348efb9a139/frozendict-2.4.7-cp310-cp310-win32.whl", hash = "sha256:a10d38fa300f6bef230fae1fdb4bc98706b78c8a3a2f3140fde748469ef3cfe8", size = 34547, upload-time = "2025-11-11T22:38:44.327Z" },
+    { url = "https://files.pythonhosted.org/packages/94/27/c22d614332c61ace4406542787edafaf7df533c6f02d1de8979d35492587/frozendict-2.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:dd518f300e5eb6a8827bee380f2e1a31c01dc0af069b13abdecd4e5769bd8a97", size = 37693, upload-time = "2025-11-11T22:38:45.571Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/d8/9d6604357b1816586612e0e89bab6d8a9c029e95e199862dc99ce8ae2ed5/frozendict-2.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:3842cfc2d69df5b9978f2e881b7678a282dbdd6846b11b5159f910bc633cbe4f", size = 35563, upload-time = "2025-11-11T22:38:46.642Z" },
+    { url = "https://files.pythonhosted.org/packages/38/74/f94141b38a51a553efef7f510fc213894161ae49b88bffd037f8d2a7cb2f/frozendict-2.4.7-py3-none-any.whl", hash = "sha256:972af65924ea25cf5b4d9326d549e69a9a4918d8a76a9d3a7cd174d98b237550", size = 16264, upload-time = "2025-11-11T22:40:12.836Z" },
+]
+
+[[package]]
+name = "genanki"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "cached-property" },
+    { name = "chevron" },
+    { name = "frozendict" },
+    { name = "pyyaml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e6/07/4459ffd44e8abfd52743f915ab6c0d4c227ee9da3f7f649c930146a93438/genanki-0.13.1.tar.gz", hash = "sha256:84d090423a8879520465bfe9784083edacb8d35e2ba511fa5a1bdef01d8f71ed", size = 23340, upload-time = "2023-11-12T18:25:16.802Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/af/7e/6c74ea7aaf2a36fd7df281267fade72e1f06ed1e44315bd77af2c6f82800/genanki-0.13.1-py3-none-any.whl", hash = "sha256:65b59434008588a1213b940474d1aca8cca83243af6fc0e26200b560efe4d9e3", size = 16177, upload-time = "2023-11-12T18:25:15.201Z" },
+]
+
+[[package]]
+name = "kotoba-to-anki"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+    { name = "genanki" },
+    { name = "pymupdf" },
+]
+
+[package.metadata]
+requires-dist = [
+    { name = "genanki", specifier = ">=0.13.0" },
+    { name = "pymupdf", specifier = ">=1.24.0" },
+]
+
+[[package]]
+name = "pymupdf"
+version = "1.28.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/fb/b6761fa2d5266f2cdb24c3b91f4023070ab7848381417678e7a289a1d52a/pymupdf-1.28.2.tar.gz", hash = "sha256:5e0be7908a715aa20333caddd73f1d6f01e4cd0c26e869fa2dd0b7f344da2249", size = 87903557, upload-time = "2026-08-06T21:43:23.321Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/b4/51/550c9a75c4ff3245cb4ecb7bb95cbe2ab7374230b8e2b7a1f7259444150b/pymupdf-1.28.2-cp310-abi3-macosx_10_15_x86_64.whl", hash = "sha256:5fc315b425ff1f7afdd1ea2f348205cb19b806767daae7ce4d64115799c2bae1", size = 24645079, upload-time = "2026-08-06T21:37:25.001Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:7113846b35dbf0a033f088e4f4fb543dabeb4b0b12c112966a1ca1ee2d5eacae", size = 23875605, upload-time = "2026-08-06T21:37:40.369Z" },
+    { url = "https://files.pythonhosted.org/packages/d2/86/4a68f080b71b46802178346af46486e1697508e760855ff5f3b218a6dff7/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3050a233dde1211efe89ada74e2add6238436434159f46097a1423aad2842545", size = 25095554, upload-time = "2026-08-06T21:37:58.485Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/06/dace3e27af26690cb20bead80dbac42941b0841eb689b8aabbd67dde16f0/pymupdf-1.28.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:397d6715c1f0df7548a92d0afd8ce370fc48fa47aeefac16be2bc04a16a8227f", size = 25762500, upload-time = "2026-08-06T21:38:17.438Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/61/4146dfa1d8172a1ce8d59f0eed94896ddefb8deb2274534d0522fbb8abf5/pymupdf-1.28.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f89fb2d86d07d643a269f17a093105057e20c79c1d06c103b53600067b6d2b01", size = 25986309, upload-time = "2026-08-06T21:38:35.472Z" },
+    { url = "https://files.pythonhosted.org/packages/52/60/1fb6e64676f7500ebe89054b9e5bbbe14d3101c92d5f1a40ac9a35227673/pymupdf-1.28.2-cp310-abi3-win32.whl", hash = "sha256:530ef543a3885b3b81cb72a854e7c5a625a9233201221132bb6c31698c6a2bdb", size = 18525353, upload-time = "2026-08-06T21:38:47.697Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/61/d563bbccba262f9dd6d2d35ccb72593648184d886188efb12d9ce8f34dd6/pymupdf-1.28.2-cp310-abi3-win_amd64.whl", hash = "sha256:ebd244918798502d7b4504c90410d1711a4d7675a32584ca30f1bab419ecbffe", size = 19826532, upload-time = "2026-08-06T21:39:00.213Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/93/08f404a1f0155fe24137cf2d3aabd3e2b4b08c62053ed89c60f2611be3e9/pymupdf-1.28.2-cp310-abi3-win_arm64.whl", hash = "sha256:ffe91a24edc75c80da2a4b62f50fc0f54632d34fc8fe4cbc48e5c7ff07cf8fb4", size = 19759252, upload-time = "2026-08-06T21:39:12.937Z" },
+    { url = "https://files.pythonhosted.org/packages/58/8c/d897dcd32a25b58186c968b15ce4324ca029e9d96460de12325314e390be/pymupdf-1.28.2-cp313-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2e1b574c0fd2cb238021033fd3c0f9c4388816638df064e4bfb56d9d81736dc8", size = 18399403, upload-time = "2026-08-06T21:39:25.008Z" },
+    { url = "https://files.pythonhosted.org/packages/f6/f1/de34a1c53fe2bf8c6e71db84b0ced782d408970c9810d2b456a2ae96814c/pymupdf-1.28.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:fd481ed48bef56305c41fb7e05a055c03345c899c7b101dad086258b438f8168", size = 25802333, upload-time = "2026-08-06T21:39:41.426Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+    { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+    { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+    { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+    { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+    { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+    { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+    { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+    { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]