Initial commit: CookIT recipe collection and week planner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 22:55:09 +01:00
commit 7d7a0bc60b
173 files changed
+22803

No files matched your search

@@ -0,0 +1 @@
"""One-time converter from the Wochenspeiseplan workbook to a CookIt Recipe Bundle."""
@@ -0,0 +1,58 @@
"""Writes a Recipe Bundle: a zip with bundle.json and downscaled photos (see docs/recipe-bundle-format.md)."""
from __future__ import annotations
import io
import json
import zipfile
from datetime import datetime, timezone
from PIL import Image, ImageOps, UnidentifiedImageError
FORMAT = "cookit.recipe-bundle"
VERSION = 1
MAX_PHOTO_EDGE = 1600
JPEG_QUALITY = 82
def downscale(data: bytes) -> bytes | None:
"""JPEG no larger than MAX_PHOTO_EDGE on its long side, or None if the picture can't be read."""
try:
img = Image.open(io.BytesIO(data))
img = ImageOps.exif_transpose(img)
except (UnidentifiedImageError, OSError, ValueError):
return None
if img.mode in ("RGBA", "LA", "P"):
img = img.convert("RGBA")
background = Image.new("RGB", img.size, "white")
background.paste(img, mask=img.getchannel("A"))
img = background
elif img.mode != "RGB":
img = img.convert("RGB")
img.thumbnail((MAX_PHOTO_EDGE, MAX_PHOTO_EDGE), Image.Resampling.LANCZOS)
out = io.BytesIO()
img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True)
return out.getvalue()
def write_bundle(path: str, recipes: list[tuple[dict, list[bytes]]]) -> None:
"""recipes: (recipe dict, raw photo bytes in display order)."""
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
entries = []
for recipe, photos in recipes:
recipe["photos"] = []
for raw in photos:
jpeg = downscale(raw)
if jpeg is None:
continue
name = f"photos/{recipe['id']}-{len(recipe['photos']) + 1}.jpg"
z.writestr(zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)), jpeg, zipfile.ZIP_STORED)
recipe["photos"].append(name)
entries.append(recipe)
manifest = {
"format": FORMAT,
"version": VERSION,
"createdAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
"recipes": entries,
}
z.writestr("bundle.json", json.dumps(manifest, ensure_ascii=False, indent=2))
+238
View File
@@ -0,0 +1,238 @@
"""Pure parsing helpers for single cell values. No workbook access here."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
# Canonical unit keys (shared with the app) and the spellings found in the workbook.
UNIT_ALIASES: dict[str, list[str]] = {
"gram": ["gramm", "g", "gr"],
"kilogram": ["kilogramm", "kg"],
"milliliter": ["milliliter", "mililiter", "ml"],
"liter": ["liter", "l"],
"tablespoon": ["esslöffel", "el"],
"teaspoon": ["teelöffel", "tl"],
"pinch": ["prise", "prisen"],
"knifeTip": ["msp.", "msp", "messerspitze"],
"pack": ["packung", "pkt.", "pkt", "päckchen"],
"can": ["dose"],
"jar": ["glas"],
"tub": ["becher"],
"bag": ["beutel"],
"carton": ["karton"],
"bunch": ["bund"],
"head": ["kopf"],
"slice": ["scheibe/n", "scheibe", "scheiben"],
"clove": ["zehe/n", "zehe", "zehen"],
"sprig": ["zweig (e)", "zweig", "zweige"],
"handful": ["handvoll", "hand voll"],
"cup": ["tasse", "tassen"],
"grain": ["körner", "korn"],
"piece": ["stück", "stk.", "stk"],
"small": ["kleine", "klein", "kleiner", "kleines"],
"medium": ["mittel", "mittlere", "mittelgroße"],
"large": ["große", "groß", "großer", "großes"],
"some": ["etwas"],
}
_UNIT_LOOKUP = {alias: key for key, aliases in UNIT_ALIASES.items() for alias in aliases}
_FRACTIONS = {"½": 0.5, "¼": 0.25, "¾": 0.75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": 0.125}
_NUMBER = r"\d+(?:[.,]\d+)?"
def clean(value) -> str | None:
"""Cell value as stripped text, or None when empty."""
if value is None:
return None
text = str(value).replace("\r\n", "\n").strip()
return text or None
def _round(x: float) -> float | int:
x = round(x, 3)
return int(x) if x == int(x) else x
def _single_amount(text: str) -> float | None:
text = text.strip()
if text in _FRACTIONS:
return _FRACTIONS[text]
m = re.fullmatch(rf"(\d+)\s*([½¼¾⅓⅔⅛])", text)
if m:
return int(m.group(1)) + _FRACTIONS[m.group(2)]
m = re.fullmatch(r"(?:(\d+)\s+)?(\d+)\s*/\s*(\d+)", text)
if m and int(m.group(3)) != 0:
return int(m.group(1) or 0) + int(m.group(2)) / int(m.group(3))
if re.fullmatch(_NUMBER, text):
return float(text.replace(",", "."))
return None
def parse_amount(value) -> tuple[float | int | None, float | int | None] | None:
"""(amount, amountMax) from a quantity cell; None when the text is not a quantity."""
if value is None:
return (None, None)
if isinstance(value, (int, float)):
return (_round(float(value)), None) if value != 0 else (None, None)
text = clean(value)
if text is None:
return (None, None)
parts = re.split(r"\s*(?:-|–|bis)\s*", text)
if len(parts) == 2:
low, high = _single_amount(parts[0]), _single_amount(parts[1])
if low is not None and high is not None:
return (_round(low), _round(high))
single = _single_amount(text)
if single is not None:
return (_round(single), None) if single != 0 else (None, None)
return None
def parse_unit(value) -> str | None:
"""Canonical unit key for a unit cell, or None when unrecognised."""
text = clean(value)
if text is None:
return None
return _UNIT_LOOKUP.get(text.lower())
def split_amount_unit(value) -> tuple[float | int, str | None] | None:
"""'100 g' / '1 TL' / '3' typed into the unit column -> (amount, unit key)."""
text = clean(value)
if text is None:
return None
m = re.fullmatch(rf"({_NUMBER})\s*(.*)", text)
if not m:
return None
amount = _round(float(m.group(1).replace(",", ".")))
rest = m.group(2).strip()
if not rest:
return (amount, None)
unit = parse_unit(rest)
return (amount, unit) if unit else None
def is_number(value) -> bool:
if isinstance(value, (int, float)):
return True
text = clean(value)
return text is not None and re.fullmatch(_NUMBER, text) is not None
def parse_minutes(value) -> int | None | str:
"""Minutes from a time cell. Returns the original text when it cannot be read."""
if value is None:
return None
if isinstance(value, (int, float)):
return int(round(value)) or None
text = clean(value)
if text is None:
return None
total, matched = 0.0, False
for num, unit in re.findall(rf"({_NUMBER})\s*(stunden|stunde|std\.?|h|minuten|min\.?|m)?\b", text.lower()):
n = float(num.replace(",", "."))
total += n * 60 if unit and unit[0] in "sh" else n
matched = True
if not matched:
return text
return int(round(total)) or None
@dataclass
class Servings:
value: float | int | None
remark: str | None = None # leftover text such as "8 Stk." that does not fit the number
def parse_servings(value) -> Servings:
if value is None:
return Servings(None)
if isinstance(value, (int, float)):
return Servings(_round(float(value)) or None)
text = clean(value)
if text is None:
return Servings(None)
m = re.match(rf"({_NUMBER})\s*(.*)", text, re.S)
if not m:
return Servings(None, text)
number = _round(float(m.group(1).replace(",", ".")))
rest = m.group(2).strip()
if rest.lower() in ("", "portion", "portionen", "person", "personen"):
return Servings(number)
return Servings(number, text)
WHOLE_RECIPE_THRESHOLD = 1500
def calories_basis(kcal: float) -> str:
"""Guessed basis for a bare kcal value (agreed rule: above 1500 means the whole recipe)."""
return "wholeRecipe" if kcal > WHOLE_RECIPE_THRESHOLD else "perServing"
_URL = re.compile(r"https?://\S+")
def extract_urls(text: str) -> tuple[list[str], str | None]:
"""URLs found in text, and the text that remains without them."""
urls = _URL.findall(text)
rest = _URL.sub("", text).strip()
return urls, (rest or None)
# --- Steps -------------------------------------------------------------------
LONG_STEP_CHARS = 600
_INLINE_HEADING = re.compile(r"^([A-ZÄÖÜ][\wäöüß\-]*(?: [\wäöüß\-]+){0,2}):\s*(\S.*)$", re.S)
_ENUMERATION = re.compile(r"^(?:schritt\s*)?\d+\s*[.):]\s+", re.I)
def _is_heading(line: str) -> bool:
return len(line) <= 40 and not re.search(r"[.!?]$", line) and len(line.split()) <= 4
@dataclass
class StepSection:
title: str | None
steps: list[str] = field(default_factory=list)
def split_steps(text: str | None) -> list[StepSection]:
"""Instruction text -> Step Sections. Blank lines separate Steps; short lines become headings."""
if not text:
return []
lines = [line.strip() for line in text.replace("\r\n", "\n").split("\n")]
body = "\n".join(lines).strip()
if not body:
return []
paragraphs = [p.strip() for p in re.split(r"\n{2,}", body) if p.strip()]
if len(paragraphs) == 1 and "\n" in paragraphs[0]:
paragraphs = [line for line in paragraphs[0].split("\n") if line]
sections = [StepSection(None)]
def add_step(step: str) -> None:
step = _ENUMERATION.sub("", step).strip()
if step:
sections[-1].steps.append(step)
for i, para in enumerate(paragraphs):
head, *rest = para.split("\n")
is_last = i == len(paragraphs) - 1
if _is_heading(head) and (rest or not is_last):
# "Teig\nMehl verkneten…" or a lone "Teig" line before the next paragraph.
sections.append(StepSection(head.rstrip(":").strip()))
if rest:
add_step("\n".join(rest))
continue
m = _INLINE_HEADING.match(para)
if m:
# "Soße:Die vegane Mayonnaise…"
sections.append(StepSection(m.group(1)))
add_step(m.group(2))
continue
add_step(para)
return [s for s in sections if s.steps]
+382
View File
@@ -0,0 +1,382 @@
"""Turns one recipe sheet of the workbook template into a Recipe Bundle recipe."""
from __future__ import annotations
import hashlib
import re
import uuid
from dataclasses import dataclass, field
from openpyxl.utils.cell import coordinate_from_string, column_index_from_string
from . import parse
from .workbook import Sheet
SKIPPED_SHEETS = {"Leeres Rezept", "Spinat-Feta-Pizza"} # the template and its example recipe
# Stable IDs: re-running the converter yields the same Recipe IDs, so a re-import skips them.
ID_NAMESPACE = uuid.UUID("6f1d3c3e-2b7a-4c55-9a51-0c6b8f2e9d10")
# Cells the template itself fills or that this module reads explicitly.
TEMPLATE_LABELS = {"A1", "C1", "F1", "G1", "C2", "D2", "E2", "F2", "G2", "B5", "G6", "B8", "G10", "B11"}
B12_BOILERPLATE = "Löschen Sie dieses Rezeptarbeitsblatt nicht."
PLACEHOLDER_PREFIXES = ("Geben Sie", "Kopieren Sie dieses Arbeitsblatt", "Stichwort 1")
NO_COURSE = {"Gang"}
NUTRITION_LABELS = {"fett", "fette", "kohlenhydrate", "eiweiß", "ballaststoffe", "kalorien/ 100g", "kalorien gesamt"}
FIRST_INGREDIENT_ROW = 3
class Reason:
"""Review Flag reasons, shown to the cook in the app."""
STRAY_TEXT = "Zusätzlicher Text aus der Tabelle wurde in die Notizen übernommen."
NO_SERVINGS = "Portionen fehlen."
UNKNOWN_UNIT = "Einheit nicht erkannt und in den Zutatennamen übernommen: {unit}"
UNKNOWN_AMOUNT = "Menge nicht erkannt und als Hinweis übernommen: {amount}"
MERGED_INSTRUCTIONS = "Anleitung war auf mehrere Zellen verteilt und wurde zusammengeführt."
LONG_STEP = "Sehr langer Schritt – eventuell aufteilen."
NAMELESS_LINES = "Zutatenzeilen ohne Zutat wurden verworfen: {values}"
DUPLICATE_LIST = "Doppelte Zutatenliste wurde verworfen."
ALTERNATIVE = "„ODER“-Alternative wurde als eigene Zutatengruppe übernommen."
UNREADABLE_TIME = "Zeitangabe nicht erkannt und in die Notizen übernommen."
def is_recipe_sheet(sheet: Sheet) -> bool:
return (
sheet.title not in SKIPPED_SHEETS
and parse.clean(sheet.cells.get("C2")) == "Menge"
and parse.clean(sheet.cells.get("E2")) == "Zutat"
)
TEMPLATE_SHEET = "Leeres Rezept"
def template_photo_hashes(sheets: list[Sheet]) -> set[str]:
"""The template's stock picture, which was copied onto every sheet made from it."""
return {hashlib.md5(p).hexdigest() for s in sheets if s.title == TEMPLATE_SHEET for p in s.photos}
def real_photos(sheet: Sheet, placeholders: set[str]) -> list[bytes]:
return [p for p in sheet.photos if hashlib.md5(p).hexdigest() not in placeholders]
def recipe_id(sheet_title: str) -> str:
return str(uuid.uuid5(ID_NAMESPACE, sheet_title))
def _rc(coord: str) -> tuple[int, int]:
col, row = coordinate_from_string(coord)
return row, column_index_from_string(col)
@dataclass
class _Builder:
sheet: Sheet
notes: list[str] = field(default_factory=list)
sources: list[str] = field(default_factory=list)
reasons: list[str] = field(default_factory=list)
used: set[str] = field(default_factory=set)
def get(self, coord: str):
self.used.add(coord)
return self.sheet.cells.get(coord)
def text(self, coord: str) -> str | None:
value = parse.clean(self.get(coord))
if value and value.startswith(PLACEHOLDER_PREFIXES):
return None
return value
def flag(self, reason: str) -> None:
if reason not in self.reasons:
self.reasons.append(reason)
def add_note_text(self, text: str, stray: bool) -> None:
urls, rest = parse.extract_urls(text)
for url in urls:
if url not in self.sources:
self.sources.append(url)
if rest:
self.notes.append(rest)
if stray:
self.flag(Reason.STRAY_TEXT)
def build_recipe(sheet: Sheet) -> dict:
b = _Builder(sheet)
name = _name(b.text("C1"), sheet.title)
course = b.text("F1")
if course in NO_COURSE:
course = None
comment_b12 = b.text("B12")
if comment_b12 and comment_b12 != B12_BOILERPLATE:
b.add_note_text(comment_b12, stray=False)
tags = _tags(b.text("B9"))
prep = _minutes(b, "G3", "Vorbereitungszeit")
cook = _minutes(b, "G7", "Kochzeit")
servings = _servings(b)
calories = _calories(b)
groups = _ingredient_groups(b)
sections = _steps(b)
_stray_cells(b)
_stray_comments(b)
return {
"id": recipe_id(sheet.title),
"name": name,
"course": course,
"tags": tags,
"servings": servings,
"prepMinutes": prep,
"cookMinutes": cook,
"calories": calories,
"ingredientGroups": groups,
"stepSections": sections,
"photos": [],
"sources": b.sources,
"notes": "\n\n".join(b.notes) or None,
"favourite": False,
"reviewReasons": b.reasons,
}
def _name(title_cell: str | None, sheet_title: str) -> str:
"""Sheet tabs are cut at 31 characters, title cells are sometimes abbreviated: keep the longer one."""
if not title_cell or title_cell in SKIPPED_SHEETS:
return sheet_title.strip()
return max(title_cell, sheet_title.strip(), key=len)
def _tags(text: str | None) -> list[str]:
tags, seen = [], set()
for tag in (text or "").replace(";", ",").split(","):
tag = tag.strip()
if tag and tag.casefold() not in seen:
seen.add(tag.casefold())
tags.append(tag)
return tags
def _minutes(b: _Builder, coord: str, label: str) -> int | None:
value = parse.parse_minutes(b.get(coord))
if isinstance(value, str):
if not value.startswith(PLACEHOLDER_PREFIXES):
b.notes.append(f"{label}: {value}")
b.flag(Reason.UNREADABLE_TIME)
return None
return value
def _servings(b: _Builder):
raw = b.get("G11")
if parse.clean(raw) and str(raw).startswith(PLACEHOLDER_PREFIXES):
raw = None
servings = parse.parse_servings(raw)
if servings.remark:
b.add_note_text(f"Ergebnis: {servings.remark}", stray=True)
if servings.value is None:
b.flag(Reason.NO_SERVINGS)
return servings.value
def _calories(b: _Builder) -> dict | None:
raw = b.get("B6")
comment = b.sheet.comments.get("B6")
b.used.add("comment:B6")
if not parse.is_number(raw):
text = parse.clean(raw)
if text and not text.startswith(PLACEHOLDER_PREFIXES):
b.add_note_text(f"Kalorien: {text}", stray=True)
return None
kcal = float(str(raw).replace(",", "."))
if kcal <= 0:
return None
if comment and "pro portion" in comment.lower():
basis = "perServing"
else:
# Guessed, but not flagged: the threshold matched every value in the workbook on review.
basis = parse.calories_basis(kcal)
if comment:
b.add_note_text(f"Kalorien: {comment}", stray=True)
return {"kcal": int(round(kcal)), "basis": basis}
# --- Ingredients -------------------------------------------------------------
def _row_cells(b: _Builder, row: int):
return b.sheet.cells.get(f"C{row}"), b.sheet.cells.get(f"D{row}"), b.sheet.cells.get(f"E{row}")
def _ingredient_rows(b: _Builder) -> list[int]:
rows = {
_rc(coord)[0]
for coord in b.sheet.cells
if coord[0] in "CDE" and _rc(coord)[0] >= FIRST_INGREDIENT_ROW and _rc(coord)[1] in (3, 4, 5)
}
return sorted(rows)
def _blocks(rows: list[int]) -> list[list[int]]:
"""Runs of consecutive rows; a blank row in the sheet separates Ingredient Groups."""
blocks: list[list[int]] = []
for row in rows:
if blocks and row == blocks[-1][-1] + 1:
blocks[-1].append(row)
else:
blocks.append([row])
return blocks
def _group_marker(comment: str | None) -> str | None:
"""A cell comment such as "Ab hier: Zutaten für die Sauce" names the group starting at that row."""
if not comment or "zutaten" not in comment.lower():
return None
m = re.search(r"zutaten\s+für\s+(?:die|den|das)?\s*(.+)", comment, re.I | re.S)
name = (m.group(1) if m else comment).strip().rstrip(":").strip()
return name[:1].upper() + name[1:]
def _ingredient_groups(b: _Builder) -> list[dict]:
groups: list[dict] = []
seen_names: set[str] = set()
pending_name: str | None = None
dropped: list[str] = []
for block in _blocks(_ingredient_rows(b)):
for row in block:
b.used.update({f"C{row}", f"D{row}", f"E{row}"})
block_rows = [(row, *_row_cells(b, row)) for row in block]
# Nutrition tables pasted into the ingredient columns belong in the Notes.
if any((parse.clean(d) or "").lower() in NUTRITION_LABELS for _, _, d, _ in block_rows):
lines = [" ".join(str(v).strip() for v in (c, d, e) if parse.clean(v)) for _, c, d, e in block_rows]
b.add_note_text("\n".join(lines), stray=True)
continue
named = [parse.clean(e).casefold() for _, _, _, e in block_rows if parse.clean(e)]
if len(named) >= 3 and sum(n in seen_names for n in named) / len(named) >= 0.8:
b.flag(Reason.DUPLICATE_LIST)
continue
current: dict | None = None
for row, c, d, e in block_rows:
if any((parse.clean(v) or "").upper() == "ODER" for v in (c, e)) and not parse.clean(d):
pending_name = "Alternative"
b.flag(Reason.ALTERNATIVE)
current = None
continue
if not parse.clean(e):
if all(v is None or parse.is_number(v) for v in (c, d)):
continue # leftover numbers from rescaling a recipe
if parse.clean(c) or parse.clean(d):
dropped.append(" ".join(str(v).strip() for v in (c, d) if parse.clean(v)))
continue
marker = None
for col in "CDE":
marker = marker or _group_marker(b.sheet.comments.get(f"{col}{row}"))
if marker:
pending_name = marker
current = None
if current is None:
current = {"name": pending_name, "lines": []}
pending_name = None
groups.append(current)
current["lines"].append(_line(b, row, c, d, e))
seen_names.add(parse.clean(e).casefold())
if dropped:
b.flag(Reason.NAMELESS_LINES.format(values=", ".join(dropped)))
return [g for g in groups if g["lines"]]
def _line(b: _Builder, row: int, c, d, e) -> dict:
name = parse.clean(e)
remarks: list[str] = []
amount_text = parse.clean(c)
if amount_text and amount_text.startswith("(") and name.endswith(")"):
# "(2 | Teelöffel | Oregano)": the whole line is in parentheses, i.e. optional.
c, name = amount_text[1:], name[:-1].strip()
remarks.append("optional")
amount = parse.parse_amount(c)
unit = parse.parse_unit(d)
unit_text = parse.clean(d)
if amount is None:
b.flag(Reason.UNKNOWN_AMOUNT.format(amount=parse.clean(c)))
remarks.append(parse.clean(c))
amount = (None, None)
if unit_text and unit is None:
combined = parse.split_amount_unit(unit_text)
if combined and amount == (None, None):
amount, unit = (combined[0], None), combined[1]
else:
b.flag(Reason.UNKNOWN_UNIT.format(unit=unit_text))
name = f"{unit_text} {name}"
for col in "CDE":
comment = b.sheet.comments.get(f"{col}{row}")
b.used.add(f"comment:{col}{row}")
if comment and not _group_marker(comment):
remarks.append(comment)
return {
"amount": amount[0],
"amountMax": amount[1],
"unit": unit,
"name": name,
"remark": "; ".join(remarks) or None,
}
# --- Steps -------------------------------------------------------------------
def _steps(b: _Builder) -> list[dict]:
instruction_cells = sorted((c for c in b.sheet.cells if c.startswith("F") and c not in ("F1", "F2")), key=_rc)
texts = []
for coord in instruction_cells:
text = b.text(coord)
if text:
texts.append(text)
b.used.update(instruction_cells)
if len(texts) > 1:
b.flag(Reason.MERGED_INSTRUCTIONS)
sections = parse.split_steps("\n\n".join(texts))
if any(len(step) > parse.LONG_STEP_CHARS for s in sections for step in s.steps):
b.flag(Reason.LONG_STEP)
return [{"title": s.title, "steps": s.steps} for s in sections]
# --- Everything else -----------------------------------------------------------
def _stray_cells(b: _Builder) -> None:
rows: dict[int, list[tuple[int, str]]] = {}
for coord, value in b.sheet.cells.items():
if coord in b.used or coord in TEMPLATE_LABELS:
continue
text = parse.clean(value)
if not text or text == B12_BOILERPLATE or text.startswith(PLACEHOLDER_PREFIXES):
continue
row, col = _rc(coord)
rows.setdefault(row, []).append((col, text))
for row in sorted(rows):
line = " ".join(text for _, text in sorted(rows[row]))
b.add_note_text(line, stray=True)
def _stray_comments(b: _Builder) -> None:
for coord, comment in b.sheet.comments.items():
if f"comment:{coord}" in b.used or _group_marker(comment):
continue
b.add_note_text(comment, stray=True)
@@ -0,0 +1,112 @@
"""Reads what the converter needs from the .xlsm: cell values, cell comments and embedded photos."""
from __future__ import annotations
import posixpath
import re
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
import openpyxl
NS = {
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
"xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
}
@dataclass
class Sheet:
title: str
cells: dict[str, object] # "C3" -> raw value (cached formula results)
comments: dict[str, str] = field(default_factory=dict) # "C3" -> comment text without author
photos: list[bytes] = field(default_factory=list) # embedded pictures, top-left first
def read_sheets(path: str) -> list[Sheet]:
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
sheets = []
for ws in wb.worksheets:
cells = {}
for row in ws.iter_rows():
for cell in row:
if cell.value is not None and str(cell.value).strip() != "":
cells[cell.coordinate] = cell.value
sheets.append(Sheet(ws.title, cells))
wb.close()
with zipfile.ZipFile(path) as z:
parts = _sheet_parts(z)
for sheet in sheets:
part = parts.get(sheet.title)
if part:
sheet.comments = _comments(z, part)
sheet.photos = _photos(z, part)
return sheets
def _rels(z: zipfile.ZipFile, part: str) -> dict[str, str]:
"""Relationship id -> absolute part path for a part's .rels file."""
rels_path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels")
if rels_path not in z.namelist():
return {}
root = ET.fromstring(z.read(rels_path))
out = {}
for rel in root.findall("rel:Relationship", NS):
target = rel.get("Target")
if rel.get("TargetMode") == "External":
continue
out[rel.get("Id")] = (
target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join(posixpath.dirname(part), target))
)
return out
def _sheet_parts(z: zipfile.ZipFile) -> dict[str, str]:
root = ET.fromstring(z.read("xl/workbook.xml"))
rels = _rels(z, "xl/workbook.xml")
return {
s.get("name"): rels[s.get(f"{{{NS['r']}}}id")]
for s in root.find("main:sheets", NS)
if s.get(f"{{{NS['r']}}}id") in rels
}
def _comments(z: zipfile.ZipFile, sheet_part: str) -> dict[str, str]:
out = {}
for target in _rels(z, sheet_part).values():
if not re.search(r"comments\d*\.xml$", target):
continue
root = ET.fromstring(z.read(target))
authors = [a.text or "" for a in root.find("main:authors", NS)]
for c in root.find("main:commentList", NS):
text = "".join(t.text or "" for t in c.iter(f"{{{NS['main']}}}t")).strip()
for author in authors:
if author and text.startswith(author + ":"):
text = text[len(author) + 1 :].strip()
if text:
out[c.get("ref")] = text
return out
def _photos(z: zipfile.ZipFile, sheet_part: str) -> list[bytes]:
found = []
for target in _rels(z, sheet_part).values():
if not re.search(r"drawings/drawing\d+\.xml$", target):
continue
media = _rels(z, target)
root = ET.fromstring(z.read(target))
for anchor in root:
frm = anchor.find("xdr:from", NS)
row = int(frm.find("xdr:row", NS).text) if frm is not None else 0
col = int(frm.find("xdr:col", NS).text) if frm is not None else 0
for blip in anchor.iter(f"{{{NS['a']}}}blip"):
embed = blip.get(f"{{{NS['r']}}}embed")
if embed in media:
found.append((row, col, z.read(media[embed])))
found.sort(key=lambda f: (f[0], f[1]))
return [data for _, _, data in found]