feat: add web UI with sidebar TOC and CLI args for json_to_excel

This commit is contained in:
Drunk-Youth
2026-06-29 17:08:34 +08:00
parent 8104abb37a
commit 40c74e545b
8 changed files with 1536 additions and 10 deletions
+342
View File
@@ -0,0 +1,342 @@
# -*- coding: utf-8 -*-
"""
Web backend for the OCR table -> online spreadsheet pipeline.
The OCR call mirrors `api.py` as closely as possible:
* same module-level imports (alibabacloud_ocr_api20210707, credentials,
tea_openapi, darabonba_stream, tea_util, etc.)
* same `Sample` class with `create_client` (only difference: credentials
are parameterized instead of hardcoded)
* same `main` shape: build client -> read body stream -> build
`RecognizeTableOcrRequest` -> call `recognize_table_ocr_with_options`
* response: we use `to_map()` (the proper TeaModel way) instead of
`print(json.dumps(resp, default=str))`, which produces invalid JSON
(Python repr, not JSON) in the original.
Endpoints:
GET / -> index.html
POST /api/convert -> image + aliyun AK -> full pipeline -> {sheet_data, ocr_result}
POST /api/load_xlsx -> .xlsx file -> {sheet_data}
POST /api/load_json -> OCR-style .json file -> {sheet_data}
GET /api/load_demo -> 111.html / 111.xlsx -> {sheet_data}
GET /sheet -> sheet.html (legacy standalone template)
GET /api/health -> health check
"""
import json
import os
import re
import sys
import tempfile
import traceback
from flask import Flask, jsonify, request, send_from_directory
# Make sibling modules importable
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
# ---------- Aliyun SDK imports (mirror api.py) ----------
from alibabacloud_credentials.client import Client as CredentialClient
from alibabacloud_credentials.models import Config as CredentialConfig
from alibabacloud_darabonba_stream.client import Client as StreamClient
from alibabacloud_ocr_api20210707 import models as ocr_api_20210707_models
from alibabacloud_ocr_api20210707.client import Client as ocr_api20210707Client
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
from generate_sheet import xlsx_to_spreadsheet_data # noqa: E402
# ---------- Sample class: same shape as api.py ----------
class Sample:
def __init__(self):
pass
@staticmethod
def create_client(access_key_id: str, access_key_secret: str) -> ocr_api20210707Client:
"""
Mirrors api.py's create_client, but credentials are parameters
instead of hardcoded. Everything else (config shape, endpoint)
is identical to api.py.
"""
credentialsConfig = CredentialConfig(
type='access_key',
access_key_id=access_key_id,
access_key_secret=access_key_secret,
)
credentialsClient = CredentialClient(credentialsConfig)
config = open_api_models.Config(
credential=credentialsClient
)
config.endpoint = f'ocr-api.cn-hangzhou.aliyuncs.com'
return ocr_api20210707Client(config)
@staticmethod
def recognize_table(image_path: str, access_key_id: str, access_key_secret: str) -> dict:
"""
Mirrors api.py's main(), but:
* image path is a parameter
* credentials are parameters
* returns the structured response dict (via to_map) instead of
printing invalid-JSON string
"""
client = Sample.create_client(access_key_id, access_key_secret)
body_stream = StreamClient.read_from_file_path(image_path)
recognize_table_ocr_request = ocr_api_20210707_models.RecognizeTableOcrRequest(
body=body_stream
)
runtime = util_models.RuntimeOptions()
resp = client.recognize_table_ocr_with_options(recognize_table_ocr_request, runtime)
# `to_map()` is the documented way to get a JSON-serializable dict
# from a TeaModel response. This is what api.py's `default=str`
# hack was trying to do (and failing at, since str(resp) yields
# a Python repr, not JSON).
return resp.to_map() if hasattr(resp, 'to_map') else _tea_fallback(resp)
def _tea_fallback(resp):
"""Last-resort: serialize via str(). May produce Python repr, not JSON."""
try:
return json.loads(json.dumps(resp, default=str))
except Exception:
return {"_raw": str(resp)}
# ---------- Flask app ----------
app = Flask(__name__, static_folder=None)
def run_subprocess(cmd: list) -> tuple[int, str, str]:
import subprocess
p = subprocess.run(cmd, capture_output=True, text=True, cwd=HERE)
return p.returncode, p.stdout, p.stderr
@app.route("/")
def index():
return send_from_directory(HERE, "index.html")
@app.route("/sheet")
@app.route("/sheet.html")
def sheet():
return send_from_directory(HERE, "sheet.html")
@app.route("/111.html")
def sheet_111():
return send_from_directory(HERE, "111.html")
@app.route("/api/health")
def health():
return jsonify({"ok": True, "service": "shudao"})
@app.route("/api/convert", methods=["POST"])
def api_convert():
if "image" not in request.files:
return jsonify({"ok": False, "stage": "input", "error": "缺少图片(字段名 image"}), 400
access_key_id = (request.form.get("access_key_id") or "").strip()
access_key_secret = (request.form.get("access_key_secret") or "").strip()
if not access_key_id or not access_key_secret:
return jsonify({"ok": False, "stage": "input", "error": "缺少阿里云 AccessKey ID / Secret"}), 400
image = request.files["image"]
if not image.filename:
return jsonify({"ok": False, "stage": "input", "error": "图片文件为空"}), 400
workdir = tempfile.mkdtemp(prefix="shudao_")
img_path = os.path.join(workdir, "input" + os.path.splitext(image.filename)[1].lower() or ".png")
json_path = os.path.join(workdir, "ocr.json")
xlsx_path = os.path.join(workdir, "ocr.xlsx")
try:
image.save(img_path)
# ---- Step 1: OCR (mirrors api.py exactly) ----
try:
ocr_raw = Sample.recognize_table(img_path, access_key_id, access_key_secret)
except Exception as e:
return jsonify({
"ok": False,
"stage": "ocr",
"error": f"OCR 调用失败:{e}",
"trace": traceback.format_exc(),
}), 500
# The Aliyun SDK returns {body: {Data: "<json string>", RequestId}, headers, statusCode}
# json_to_excel.py expects the body shape: {Data: "<json string>", RequestId}
# (it then json.loads the Data string to get prism_tablesInfo).
ocr_body = ocr_raw.get("body") if isinstance(ocr_raw, dict) else None
if not isinstance(ocr_body, dict):
return jsonify({
"ok": False,
"stage": "ocr",
"error": "OCR 响应格式异常:缺少 body 字段",
"raw_type": type(ocr_raw).__name__,
}), 500
with open(json_path, "w", encoding="utf-8") as f:
json.dump(ocr_body, f, ensure_ascii=False, indent=2)
# ---- Step 2: json -> xlsx ----
rc, out, err = run_subprocess(
[sys.executable, os.path.join(HERE, "json_to_excel.py"), json_path, xlsx_path]
)
if rc != 0:
return jsonify({
"ok": False,
"stage": "json_to_excel",
"error": err or out or "json_to_excel 失败",
}), 500
# ---- Step 3: xlsx -> sheet data ----
try:
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
except Exception as e:
return jsonify({
"ok": False,
"stage": "xlsx_to_sheet",
"error": f"xlsx 解析失败:{e}",
"trace": traceback.format_exc(),
}), 500
return jsonify({
"ok": True,
"sheet_data": sheet_data,
"ocr_result": ocr_raw,
})
except Exception as e:
return jsonify({
"ok": False,
"stage": "unhandled",
"error": f"未处理异常:{e}",
"trace": traceback.format_exc(),
}), 500
# -------------------- Direct file imports (skip OCR) --------------------
@app.route("/api/load_xlsx", methods=["POST"])
def api_load_xlsx():
"""Directly parse an uploaded .xlsx file into sheet data."""
if "file" not in request.files:
return jsonify({"ok": False, "error": "缺少文件(字段名 file"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"ok": False, "error": "文件为空"}), 400
if not f.filename.lower().endswith(".xlsx"):
return jsonify({"ok": False, "error": "请上传 .xlsx 文件"}), 400
workdir = tempfile.mkdtemp(prefix="shudao_xlsx_")
xlsx_path = os.path.join(workdir, f.filename)
try:
f.save(xlsx_path)
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
return jsonify({"ok": True, "sheet_data": sheet_data})
except Exception as e:
return jsonify({
"ok": False,
"stage": "xlsx_parse",
"error": f"xlsx 解析失败:{e}",
"trace": traceback.format_exc(),
}), 500
@app.route("/api/load_json", methods=["POST"])
def api_load_json():
"""Parse an uploaded Aliyun-OCR-style JSON file -> xlsx -> sheet data."""
if "file" not in request.files:
return jsonify({"ok": False, "error": "缺少文件(字段名 file"}), 400
f = request.files["file"]
if not f.filename:
return jsonify({"ok": False, "error": "文件为空"}), 400
workdir = tempfile.mkdtemp(prefix="shudao_json_")
json_path = os.path.join(workdir, "input.json")
xlsx_path = os.path.join(workdir, "out.xlsx")
try:
f.save(json_path)
# Validate the JSON structure
with open(json_path, "r", encoding="utf-8") as fp:
data = json.load(fp)
# Accept either the body shape {Data: "<json string>", RequestId: ...}
# or the already-parsed OCR shape with prism_tablesInfo at the top level
if isinstance(data, dict) and "Data" in data and isinstance(data["Data"], str):
ocr_body = data # already in body shape
elif isinstance(data, dict) and "prism_tablesInfo" in data:
# Wrap into body shape so json_to_excel can handle it
ocr_body = {"Data": json.dumps(data, ensure_ascii=False)}
else:
return jsonify({
"ok": False,
"stage": "json_validate",
"error": "JSON 格式不符合预期(既不是 Aliyun OCR body 也没有 prism_tablesInfo",
}), 400
with open(json_path, "w", encoding="utf-8") as fp:
json.dump(ocr_body, fp, ensure_ascii=False, indent=2)
rc, out, err = run_subprocess(
[sys.executable, os.path.join(HERE, "json_to_excel.py"), json_path, xlsx_path]
)
if rc != 0:
return jsonify({
"ok": False,
"stage": "json_to_excel",
"error": err or out or "json_to_excel 失败",
}), 500
sheet_data = xlsx_to_spreadsheet_data(xlsx_path)
return jsonify({"ok": True, "sheet_data": sheet_data})
except Exception as e:
return jsonify({
"ok": False,
"stage": "json_load",
"error": f"JSON 加载失败:{e}",
"trace": traceback.format_exc(),
}), 500
@app.route("/api/load_demo", methods=["GET"])
def api_load_demo():
"""Load the project-local 111.html's embedded sheet data (if any),
or fall back to re-running generate_sheet on 111.xlsx."""
# Approach 1: extract {{DATA}} from 111.html (cleanest, no re-conversion)
candidate_html = os.path.join(HERE, "111.html")
if os.path.exists(candidate_html):
try:
with open(candidate_html, "r", encoding="utf-8") as fp:
content = fp.read()
# Find the first { ... } that contains the "name" key
# The data is in a `const rawData = {...};` block
m = re.search(r"const\s+rawData\s*=\s*(\{.*?\});\s*//\s*Normalize", content, re.DOTALL)
if m:
sheet_data = json.loads(m.group(1))
return jsonify({"ok": True, "sheet_data": sheet_data, "source": "111.html"})
except Exception:
pass # fall through
# Approach 2: re-run generate_sheet on 111.xlsx
candidate_xlsx = os.path.join(HERE, "111.xlsx")
if not os.path.exists(candidate_xlsx):
return jsonify({
"ok": False,
"error": "示例数据不可用(既没有 111.html 也没有 111.xlsx",
}), 404
try:
sheet_data = xlsx_to_spreadsheet_data(candidate_xlsx)
return jsonify({"ok": True, "sheet_data": sheet_data, "source": "111.xlsx"})
except Exception as e:
return jsonify({
"ok": False,
"stage": "xlsx_parse",
"error": f"111.xlsx 解析失败:{e}",
"trace": traceback.format_exc(),
}), 500
if __name__ == "__main__":
port = int(os.environ.get("PORT", "5000"))
print(f" * Serving Shudao on http://127.0.0.1:{port}")
app.run(host="127.0.0.1", port=port, debug=False)