"""Extract the official GAUDELOT PDF catalog into site data and product thumbnails.""" from __future__ import annotations import json import re from pathlib import Path import pdfplumber from PIL import Image, ImageChops ROOT = Path(__file__).resolve().parents[1] PDF_PATH = ROOT.parent / "公司资料" / "公司产品" / "汕头市高德乐产品型号表20260630.pdf" RENDER_DIR = ROOT / "tmp" / "pdfs" / "catalog-hi" IMAGE_DIR = ROOT / "public" / "catalog-products" DATA_PATH = ROOT / "app" / "catalog-data.ts" CATEGORY_BY_PAGE = { **{page: "标准舵机" for page in range(1, 14)}, **{page: "异形舵机" for page in range(14, 16)}, **{page: "全金属舵机" for page in range(16, 20)}, **{page: "大扭矩舵机" for page in range(20, 22)}, **{page: "积木舵机" for page in range(22, 24)}, **{page: "标准齿轮箱" for page in range(24, 28)}, **{page: "翻盖电机" for page in range(28, 30)}, } # Business categories can intentionally differ from the source page heading. CATEGORY_BY_CODE = { "4.A.05.A0008": "异形舵机", } SPEC_LABELS = { "标准舵机": [ "产品尺寸", "最大砝码负载", "堵转扭矩", "空载速度", "可操作角度", "额定电压", "电压范围", "外壳材质", "齿轮级数", "齿轮材质", "马达类型", "端子连接线", ], "异形舵机": [ "产品尺寸", "最大砝码负载", "堵转扭矩", "空载速度", "可操作角度", "额定电压", "电压范围", "外壳材质", "齿轮级数", "齿轮材质", "马达类型", "端子连接线", ], "全金属舵机": [ "产品尺寸", "马达类型", "传感器类型", "信号类型", "协议版本", "连接线类型", "电压范围", "额定电压", "操作角度范围", "外壳材质", "齿轮材质", "齿轮比", "齿轮级数", "堵转扭矩", "负载扭矩", "额定扭矩", "转动速度", "转动速度RPM", ], "大扭矩舵机": [ "产品尺寸", "马达类型", "传感器类型", "信号类型", "协议版本", "连接线类型", "电压范围", "额定电压", "操作角度范围", "外壳材质", "齿轮材质", "齿轮比", "齿轮级数", "堵转扭矩", "负载扭矩", "额定扭矩", "转动速度", "转动速度RPM", "输出齿类型", "重量", "附加说明", ], "积木舵机": [ "外形尺寸", "额定电压", "电压范围", "最大砝码负载", "堵转扭矩", "空载速度", "通讯方式", "端子连接线", ], "标准齿轮箱": [ "产品尺寸", "空载速度", "堵转扭矩", "齿轮比", "齿轮级数", "齿轮类型", "马达类型", "额定电压", "电压范围", "外壳材质", ], "翻盖电机": [ "产品尺寸", "空载速度", "最大砝码负载", "堵转扭矩", "额定电压", "电压范围", "寿命", "马达类型", "外壳材质", "齿轮材质", ], } def clean(value: str | None) -> str: if not value: return "" value = value.replace("\n", "").replace("\u00a0", " ") value = re.sub(r"\s+", "", value) return value.strip() def clean_code(value: str | None) -> str: return clean(value).replace("|", "") def slug(value: str) -> str: safe = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower() return safe or "product" def parse_number(value: str) -> float | None: match = re.search(r"-?\d+(?:\.\d+)?", value.replace(",", "")) return float(match.group()) if match else None def normalize_spec_values(category: str, row: list[str | None], inherited_size: str) -> list[str]: # PDF tables contain a blank merged column before the actual size value. size = clean(row[4]) or inherited_size if category in {"标准舵机", "异形舵机"}: return [size] + [clean(value) for value in row[5:16]] if category == "全金属舵机": return [size] + [clean(value) for value in row[5:22]] if category == "大扭矩舵机": return [size] + [clean(value) for value in row[5:25]] if category == "积木舵机": return [size] + [clean(value) for value in row[5:12]] if category in {"标准齿轮箱", "翻盖电机"}: return [size] + [clean(value) for value in row[5:14]] raise ValueError(f"Unsupported category: {category}") def crop_product_image( page_image: Image.Image, page_width: float, page_height: float, cell: tuple[float, float, float, float], output_path: Path, ) -> None: scale_x = page_image.width / page_width scale_y = page_image.height / page_height x0, top, x1, bottom = cell inset = 3 box = ( max(0, int(x0 * scale_x) + inset), max(0, int(top * scale_y) + inset), min(page_image.width, int(x1 * scale_x) - inset), min(page_image.height, int(bottom * scale_y) - inset), ) crop = page_image.crop(box).convert("RGB") background = Image.new("RGB", crop.size, "white") diff = ImageChops.difference(crop, background).convert("L") mask = diff.point(lambda pixel: 255 if pixel > 14 else 0) bbox = mask.getbbox() if bbox: crop = crop.crop(bbox) crop.thumbnail((280, 210), Image.Resampling.LANCZOS) card = Image.new("RGB", (320, 240), "white") card.paste(crop, ((320 - crop.width) // 2, (240 - crop.height) // 2)) card.save(output_path, "WEBP", quality=88, method=6) def main() -> None: if not PDF_PATH.exists(): raise FileNotFoundError(PDF_PATH) IMAGE_DIR.mkdir(parents=True, exist_ok=True) products: list[dict[str, object]] = [] category_counts: dict[str, int] = {} with pdfplumber.open(PDF_PATH) as pdf: for page_number, page in enumerate(pdf.pages, 1): page_category = CATEGORY_BY_PAGE[page_number] page_text = clean(page.extract_text() or "") expected_heading = f"{page_category}产品明细表" if expected_heading not in page_text: raise RuntimeError( f"Category heading mismatch on page {page_number}: expected {expected_heading}" ) tables = page.find_tables() if not tables: raise RuntimeError(f"No table found on page {page_number}") table = tables[0] rows = table.extract() render_path = RENDER_DIR / f"page-{page_number:02d}.png" if not render_path.exists(): raise FileNotFoundError(f"Missing rendered page: {render_path}") rendered = Image.open(render_path).convert("RGB") inherited_name = "" inherited_size = "" inherited_image = "" for row_index, row in enumerate(rows[3:], 3): if len(row) < 3: continue code = clean_code(row[2]) if not code or not re.search(r"[A-Z].*\d", code): continue category = CATEGORY_BY_CODE.get(code, page_category) explicit_name = clean(row[1]) if explicit_name: inherited_name = explicit_name name = explicit_name or inherited_name or code explicit_size = clean(row[4]) if len(row) > 4 else "" if explicit_size: inherited_size = explicit_size cell = table.rows[row_index].cells[0] if row_index < len(table.rows) else None if explicit_name and cell: image_name = f"p{page_number:02d}-{slug(code)}.webp" crop_product_image(rendered, page.width, page.height, cell, IMAGE_DIR / image_name) inherited_image = f"/catalog-products/{image_name}" values = normalize_spec_values(page_category, row, inherited_size) specs = { label: value for label, value in zip(SPEC_LABELS[page_category], values) if value and value != "/" } torque_value = parse_number(specs.get("堵转扭矩", "")) rated_voltage = parse_number(specs.get("额定电压", "")) products.append( { "id": f"p{page_number:02d}-{row_index}-{slug(code)}", "category": category, "name": name, "code": code, "image": inherited_image, "page": page_number, "specs": specs, "torqueValue": torque_value, "ratedVoltage": rated_voltage, } ) category_counts[category] = category_counts.get(category, 0) + 1 payload = json.dumps(products, ensure_ascii=False, indent=2) counts = json.dumps(category_counts, ensure_ascii=False, indent=2) DATA_PATH.write_text( "// Generated from the official 2026-06-30 PDF product catalog.\n" "// Run scripts/extract_catalog.py after the PDF or page renders change.\n\n" "export type CatalogProduct = {\n" " id: string;\n" " category: string;\n" " name: string;\n" " code: string;\n" " image: string;\n" " page: number;\n" " specs: Record;\n" " torqueValue: number | null;\n" " ratedVoltage: number | null;\n" "};\n\n" f"export const CATALOG_PRODUCTS: CatalogProduct[] = {payload};\n\n" f"export const CATALOG_COUNTS: Record = {counts};\n", encoding="utf-8", ) print(json.dumps({"total": len(products), "categories": category_counts}, ensure_ascii=False)) if __name__ == "__main__": main()