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
Vendored
BIN
View File
Binary file not shown.
+200 -4
View File
@@ -6,17 +6,82 @@
<title>x-data-spreadsheet</title> <title>x-data-spreadsheet</title>
<style> <style>
body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; } body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; }
#xspreadsheet { width:100%; height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; } .layout { display:flex; gap:20px; align-items:flex-start; }
.main { flex:1 1 auto; min-width:0; }
#xspreadsheet { width:100%; height:calc(100vh - 140px); min-height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
.toc-sidebar {
flex: 0 0 200px;
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow-y: auto;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
padding: 12px 0;
box-sizing: border-box;
}
.toc-sidebar h3 {
margin: 0 14px 8px 0;
font-size: 12px;
font-weight: 600;
color: #333;
border-bottom: 1px solid #eee;
padding: 0 14px 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.toc-sheet-group { margin-bottom: 10px; }
.toc-sheet-name {
font-size: 11px;
color: #999;
margin: 4px 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toc-list { list-style: none; padding: 0; margin: 0; }
.toc-list li { margin: 0; }
.toc-link {
display: block;
padding: 5px 14px;
color: #555;
text-decoration: none;
font-size: 12.5px;
line-height: 1.5;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toc-link:hover { background: #f0f7ff; color: #1677ff; }
.toc-link.active {
background: #e6f4ff;
color: #1677ff;
border-left-color: #1677ff;
font-weight: 600;
}
.toc-empty { font-size: 12px; color: #999; padding: 8px 14px; }
</style> </style>
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css"> <link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
</head> </head>
<body> <body>
<h1>x-data-spreadsheet</h1> <h1>x-data-spreadsheet</h1>
<div id="xspreadsheet"></div> <div class="layout">
<div class="main">
<div id="xspreadsheet"></div>
</div>
<aside class="toc-sidebar">
<h3>目录</h3>
<div id="toc-container"></div>
</aside>
</div>
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script> <script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
<script> <script>
const sheetData = { const rawData = {
"name": "Table_0", "name": "Table_0",
"rows": { "rows": {
"0": { "0": {
@@ -639,7 +704,136 @@
} }
} }
}; };
// Normalize: x_spreadsheet.loadData accepts an array of sheets or a single sheet
const sheets = Array.isArray(rawData) ? rawData : [rawData];
// ---- TOC builder ----
function cellText(row, col) {
if (!row || !row.cells) return null;
const c = row.cells[col];
if (!c) return null;
return (c.text || '').trim() || null;
}
function extractToc(sheet) {
const items = [];
const rows = sheet.rows || {};
const len = rows.len || 0;
const seen = new Set();
for (let i = 0; i < len; i++) {
const r = rows[i];
if (!r || !r.cells) continue;
const c0 = cellText(r, 0);
const c1 = cellText(r, 1);
const c2 = cellText(r, 2);
// 1) Top-level labels in column 0 (基本要求 / 外观质量 / 工程质量等级评定 / ...)
if (c0 && !seen.has('c0:' + c0)) {
items.push({ label: c0, row: i });
seen.add('c0:' + c0);
continue;
}
// 2) Numbered items in column 1 (项次: 1A, 2A, 3, 4, 5, 6, 7, 8, ...)
if (c1 && /^[0-9]+[A-Za-z]?$/.test(c1)) {
const label = c1 + (c2 ? ' ' + c2 : '');
if (!seen.has('c1:' + c1)) {
items.push({ label, row: i });
seen.add('c1:' + c1);
}
continue;
}
}
return items;
}
function renderToc(sheets) {
const container = document.getElementById('toc-container');
container.innerHTML = '';
let hasAny = false;
sheets.forEach((sheet, sheetIdx) => {
const items = extractToc(sheet);
if (!items.length && sheets.length === 1) return;
hasAny = true;
const group = document.createElement('div');
group.className = 'toc-sheet-group';
if (sheets.length > 1) {
const name = document.createElement('div');
name.className = 'toc-sheet-name';
name.textContent = sheet.name || ('Sheet ' + (sheetIdx + 1));
group.appendChild(name);
}
const ul = document.createElement('ul');
ul.className = 'toc-list';
items.forEach(item => {
const li = document.createElement('li');
const a = document.createElement('a');
a.className = 'toc-link';
a.href = 'javascript:void(0)';
a.dataset.sheet = sheetIdx;
a.dataset.row = item.row;
a.title = item.label + ' (row ' + item.row + ')';
a.innerHTML = item.label + '<span class="row-tag" style="color:#bbb;font-size:10.5px;margin-left:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">·' + item.row + '</span>';
a.addEventListener('click', () => scrollToRow(sheetIdx, item.row, a));
li.appendChild(a);
ul.appendChild(li);
});
group.appendChild(ul);
container.appendChild(group);
});
if (!hasAny) {
const empty = document.createElement('div');
empty.className = 'toc-empty';
empty.textContent = '(未识别到目录项)';
container.appendChild(empty);
}
}
// ---- Scroll handling ----
// x-data-spreadsheet renders the grid on a canvas; rows are NOT in the DOM.
// We scroll by setting the vertical scrollbar's scrollTop, which fires
// the library's internal moveFn -> scrolly() -> re-render.
let __ssRef = null; // populated after load
function getSheetData(sheetIdx) {
if (!__ssRef) return null;
// spreadsheet.datas[] holds the pt instances; .data is shorthand for datas[0]
if (Array.isArray(__ssRef.datas) && __ssRef.datas[sheetIdx]) return __ssRef.datas[sheetIdx];
if (sheetIdx === 0 && __ssRef.data) return __ssRef.data;
return null;
}
function scrollToRow(sheetIdx, rowIndex, linkEl) {
if (!__ssRef) return;
// Mark active
document.querySelectorAll('.toc-link.active').forEach(el => el.classList.remove('active'));
if (linkEl) linkEl.classList.add('active');
try {
const liveData = getSheetData(sheetIdx);
if (!liveData || !liveData.rows || typeof liveData.rows.sumHeight !== 'function') {
console.warn('No live sheet data for index', sheetIdx);
return;
}
const y = liveData.rows.sumHeight(0, rowIndex);
const vbar = __ssRef.sheet && __ssRef.sheet.verticalScrollbar;
if (vbar && vbar.el && vbar.el[0]) {
vbar.el[0].scrollTop = y;
}
} catch (e) {
console.warn('scrollToRow failed:', e);
}
}
// ---- Init ----
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (typeof x_spreadsheet === 'undefined') { if (typeof x_spreadsheet === 'undefined') {
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>'; document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>';
@@ -647,7 +841,9 @@
} }
try { try {
const spreadsheet = x_spreadsheet('#xspreadsheet'); const spreadsheet = x_spreadsheet('#xspreadsheet');
spreadsheet.loadData(sheetData); spreadsheet.loadData(sheets);
__ssRef = spreadsheet;
renderToc(sheets);
} catch (e) { } catch (e) {
console.error('Error:', e); console.error('Error:', e);
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>'; document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>';
BIN
View File
Binary file not shown.
Binary file not shown.
+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)
+788
View File
@@ -0,0 +1,788 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Shudao · OCR 表格转可编辑表格</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0; padding: 0;
font-family: -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", Arial, sans-serif;
background: #f5f6f8;
color: #222;
}
/* ----- Header ----- */
header {
background: #fff;
border-bottom: 1px solid #e6e8eb;
padding: 14px 24px;
display: flex; align-items: center; justify-content: space-between;
position: sticky; top: 0; z-index: 100;
}
header h1 { margin: 0; font-size: 18px; font-weight: 600; }
header .sub { color: #888; font-size: 12px; margin-left: 8px; }
header .actions { display: flex; gap: 8px; }
header button, .btn {
padding: 7px 14px; border: 1px solid #d9d9d9; background: #fff;
border-radius: 4px; cursor: pointer; font-size: 13px;
transition: all 0.15s;
}
header button:hover, .btn:hover { border-color: #1677ff; color: #1677ff; }
header button.primary, .btn.primary {
background: #1677ff; color: #fff; border-color: #1677ff;
}
header button.primary:hover, .btn.primary:hover {
background: #4096ff; border-color: #4096ff;
}
header button:disabled, .btn:disabled {
background: #f5f5f5; color: #bbb; border-color: #e8e8e8; cursor: not-allowed;
}
/* ----- Upload panel ----- */
.upload-panel {
background: #fff;
margin: 20px;
padding: 20px 24px;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
}
.upload-panel.hidden { display: none; }
.upload-panel h2 {
margin: 0 0 14px; font-size: 15px; font-weight: 600;
}
.upload-panel .grid {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px;
align-items: end;
}
.upload-panel .field { display: flex; flex-direction: column; gap: 6px; }
.upload-panel label { font-size: 12px; color: #666; }
.upload-panel input[type="text"],
.upload-panel input[type="password"],
.upload-panel input[type="file"] {
padding: 7px 10px; border: 1px solid #d9d9d9; border-radius: 4px;
font-size: 13px; font-family: inherit;
}
.upload-panel input:focus { outline: none; border-color: #1677ff; }
.upload-panel .help { font-size: 11px; color: #999; margin-top: 4px; }
.upload-panel .actions {
display: flex; gap: 10px; margin-top: 18px; align-items: center;
}
.upload-panel .status {
font-size: 13px; color: #666; margin-left: 8px;
}
.upload-panel .status.error { color: #cf1322; }
.upload-panel .status.ok { color: #389e0d; }
.upload-panel .error-detail {
margin: 12px 0 0;
padding: 10px 12px;
background: #fff2f0;
border: 1px solid #ffccc7;
border-radius: 4px;
color: #5c0011;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
white-space: pre-wrap;
word-break: break-all;
max-height: 240px;
overflow-y: auto;
}
/* ----- Result area ----- */
.result-area { display: none; padding: 0 20px 20px; }
.result-area.visible { display: flex; gap: 16px; align-items: flex-start; }
.spreadsheet-wrap {
flex: 1 1 auto; min-width: 0;
background: #fff; border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
padding: 0; overflow: hidden;
}
#xspreadsheet {
width: 100%; height: calc(100vh - 120px); min-height: 500px;
}
/* ----- TOC (fixed, compact) ----- */
.toc {
flex: 0 0 200px; /* hard cap; sidebar never grows */
position: sticky;
top: 70px;
max-height: calc(100vh - 90px);
overflow-y: auto;
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.04);
padding: 12px 0; /* no horizontal padding — items align flush */
}
.toc h3 {
margin: 0 14px 8px;
font-size: 12px; font-weight: 600; color: #333;
letter-spacing: 0.5px;
text-transform: uppercase;
border-bottom: 1px solid #eee;
padding-bottom: 8px;
}
.toc-list { list-style: none; padding: 0; margin: 0; }
.toc-item { display: block; }
.toc-link {
display: block; /* block so the click target is full row width,
but the visible "chip" hugs content via padding */
padding: 5px 14px;
color: #555; text-decoration: none;
font-size: 12.5px; line-height: 1.5;
cursor: pointer; user-select: none;
border-left: 2px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.toc-link:hover { background: #f0f7ff; color: #1677ff; }
.toc-link.active {
background: #e6f4ff; color: #1677ff;
border-left-color: #1677ff; font-weight: 600;
}
.toc-link .row-tag {
color: #bbb; font-size: 10.5px; margin-left: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.toc-link.active .row-tag { color: #69b1ff; }
.toc-list.nested { margin-left: 0; }
.toc-list.nested .toc-link { padding-left: 28px; font-size: 12px; color: #666; }
.toc-list.nested .toc-link.active { color: #1677ff; }
.toc-empty {
padding: 12px 14px; color: #999; font-size: 12px; font-style: italic;
}
/* ----- Loading overlay ----- */
.loading {
display: none; position: fixed; inset: 0;
background: rgba(255,255,255,0.7);
z-index: 200; align-items: center; justify-content: center;
flex-direction: column; gap: 12px;
}
.loading.visible { display: flex; }
.spinner {
width: 36px; height: 36px;
border: 3px solid #e6e8eb; border-top-color: #1677ff;
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading .text { color: #555; font-size: 14px; }
/* ----- Dropdown menu ----- */
.menu-anchor { position: relative; }
.menu {
position: absolute; right: 0; top: calc(100% + 6px);
background: #fff; border: 1px solid #e6e8eb; border-radius: 6px;
box-shadow: 0 4px 16px rgba(0,0,0,0.10);
min-width: 200px; padding: 6px 0;
display: none; z-index: 150;
}
.menu.open { display: block; }
.menu-item {
display: block; width: 100%; text-align: left;
padding: 8px 14px; border: 0; background: transparent;
font-size: 13px; cursor: pointer; color: #333;
}
.menu-item:hover { background: #f0f7ff; color: #1677ff; }
.menu-item .desc { display: block; color: #999; font-size: 11px; margin-top: 2px; font-weight: normal; }
/* ----- TOC edit mode ----- */
.toc-toolbar {
display: flex; align-items: center; gap: 6px;
padding: 0 14px 8px; border-bottom: 1px solid #eee; margin-bottom: 6px;
}
.toc-toolbar h3 { margin: 0; border: 0; padding: 0; flex: 1; }
.toc-toolbar button {
padding: 3px 8px; font-size: 11px; border: 1px solid #d9d9d9;
background: #fff; border-radius: 3px; cursor: pointer; color: #555;
}
.toc-toolbar button:hover { border-color: #1677ff; color: #1677ff; }
.toc-toolbar button.active { background: #e6f4ff; border-color: #1677ff; color: #1677ff; }
.toc-list .toc-item-row {
display: flex; align-items: center;
padding: 4px 14px;
color: #555; font-size: 12.5px; line-height: 1.5;
cursor: pointer; user-select: none;
border-left: 2px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.toc-list .toc-item-row:hover { background: #f0f7ff; color: #1677ff; }
.toc-list .toc-item-row.active {
background: #e6f4ff; color: #1677ff;
border-left-color: #1677ff; font-weight: 600;
}
.toc-list .toc-item-row .toc-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
.toc-list .toc-item-row .row-tag {
color: #bbb; font-size: 10.5px; margin-left: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.toc-list .toc-item-row.active .row-tag { color: #69b1ff; }
.toc-list .toc-item-row .toc-actions {
display: none; gap: 2px; margin-left: 4px;
}
.toc.edit-mode .toc-item-row:hover { background: #fafafa; color: #333; }
.toc.edit-mode .toc-item-row.active { background: #fffbe6; color: #333; border-left-color: #faad14; }
.toc.edit-mode .toc-item-row .toc-actions { display: inline-flex; }
.toc-action-btn {
width: 20px; height: 20px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid #e6e8eb; background: #fff; border-radius: 3px;
cursor: pointer; color: #888; font-size: 11px; line-height: 1;
}
.toc-action-btn:hover { border-color: #1677ff; color: #1677ff; }
.toc-action-btn.danger:hover { border-color: #ff4d4f; color: #ff4d4f; }
.toc-children {
list-style: none; padding: 0; margin: 0;
}
.toc-children .toc-item-row { padding-left: 28px; font-size: 12px; color: #666; }
.toc-children .toc-children .toc-item-row { padding-left: 42px; }
.toc-add-row {
margin: 4px 14px 8px;
padding: 6px 10px;
border: 1px dashed #d9d9d9; border-radius: 4px;
color: #999; font-size: 12px; cursor: pointer;
display: none; text-align: center;
}
.toc-add-row:hover { border-color: #1677ff; color: #1677ff; background: #f0f7ff; }
.toc.edit-mode .toc-add-row { display: block; }
.toc-row-form {
display: flex; gap: 4px; align-items: center; padding: 4px 14px;
}
.toc-row-form input {
flex: 1; min-width: 0;
padding: 4px 6px; border: 1px solid #1677ff; border-radius: 3px;
font-size: 12px; font-family: inherit; outline: none;
}
.toc-row-form input.row-input { flex: 0 0 50px; }
.toc-row-form .save, .toc-row-form .cancel {
padding: 4px 8px; font-size: 12px; cursor: pointer;
border-radius: 3px; border: 1px solid;
}
.toc-row-form .save {
background: #1677ff; border-color: #1677ff; color: #fff;
}
.toc-row-form .cancel {
background: #fff; border-color: #d9d9d9; color: #555;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
</head>
<body>
<header>
<div>
<h1>Shudao <span class="sub">OCR 表格 → 可编辑在线表格</span></h1>
</div>
<div class="actions">
<div class="menu-anchor">
<button id="btn-import" class="primary">导入表格 ▾</button>
<div class="menu" id="import-menu">
<button class="menu-item" data-action="xlsx">
导入 .xlsx
<span class="desc">跳过 OCR,直接读取本地表格</span>
</button>
<button class="menu-item" data-action="json">
导入 .json
<span class="desc">读取阿里云 OCR 输出 JSON</span>
</button>
<button class="menu-item" data-action="demo">
加载示例(111.html / 111.xlsx
<span class="desc">使用项目内置的演示数据</span>
</button>
</div>
</div>
<button id="btn-toggle-upload">新建转换</button>
</div>
</header>
<input type="file" id="file-xlsx" accept=".xlsx" style="display:none" />
<input type="file" id="file-json" accept=".json" style="display:none" />
<section class="upload-panel" id="upload-panel">
<h2>上传图片并转换</h2>
<form id="upload-form">
<div class="grid">
<div class="field">
<label for="image-input">表格图片 *</label>
<input id="image-input" type="file" name="image" accept="image/*" required />
<div class="help">支持 PNG / JPG / JPEG,建议清晰可读</div>
</div>
<div class="field">
<label for="ak-id">阿里云 AccessKey ID *</label>
<input id="ak-id" type="text" name="access_key_id" required placeholder="LTAI..." autocomplete="off"/>
</div>
<div class="field">
<label for="ak-secret">阿里云 AccessKey Secret *</label>
<input id="ak-secret" type="password" name="access_key_secret" required placeholder="••••••" autocomplete="off"/>
</div>
</div>
<div class="actions">
<button type="submit" class="primary" id="btn-convert">开始转换</button>
<span class="status" id="upload-status"></span>
</div>
<pre id="error-detail" class="error-detail" style="display:none"></pre>
</form>
</section>
<section class="result-area" id="result-area">
<div class="spreadsheet-wrap">
<div id="xspreadsheet"></div>
</div>
<aside class="toc" id="toc">
<div class="toc-toolbar">
<h3>目录</h3>
<button id="btn-toc-add-top" title="添加顶级条目">+ 顶级</button>
<button id="btn-toc-edit-toggle" title="编辑/查看模式切换">编辑</button>
<button id="btn-toc-reset" title="重置为自动提取">重置</button>
</div>
<div id="toc-container"></div>
</aside>
</section>
<div class="loading" id="loading">
<div class="spinner"></div>
<div class="text" id="loading-text">正在识别表格…</div>
</div>
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
<script>
// ============================================================
// State
// ============================================================
let __ss = null; // spreadsheet instance (x-spreadsheet)
let __sheets = []; // normalized sheet array
let __tocState = []; // user-editable TOC, {sheetIdx, items: [nested]}
let __tocEditMode = false;
let __nextId = 1;
const $ = id => document.getElementById(id);
const uploadPanel = $('upload-panel');
const resultArea = $('result-area');
const loading = $('loading');
const loadingText = $('loading-text');
const statusEl = $('upload-status');
const tocContainer = $('toc-container');
const tocEl = $('toc');
const form = $('upload-form');
const importMenu = $('import-menu');
const fileXlsxInput = $('file-xlsx');
const fileJsonInput = $('file-json');
const newId = () => 'i' + (__nextId++) + '_' + Math.random().toString(36).slice(2, 6);
// ============================================================
// TOC model: { sheetIdx, items: [{id, label, row, children: [...]}] }
// ============================================================
function buildAutoToc(sheets) {
// Auto-extract from data (per sheet)
return sheets.map((sheet, sheetIdx) => ({
sheetIdx,
items: extractTocForSheet(sheet).map(it => ({ ...it, id: newId(), children: [] })),
}));
}
function extractTocForSheet(sheet) {
const items = [];
if (!sheet || !sheet.rows) return items;
const rows = sheet.rows;
const len = rows.len || 0;
const seen = new Set();
for (let i = 0; i < len; i++) {
const r = rows[i];
if (!r || !r.cells) continue;
const c0 = cellText(r, 0);
const c1 = cellText(r, 1);
const c2 = cellText(r, 2);
if (c0 && !seen.has('c0:' + c0)) {
items.push({ label: c0, row: i });
seen.add('c0:' + c0);
continue;
}
if (c1 && /^[0-9]+[A-Za-z]?$/.test(c1)) {
const label = c1 + (c2 ? ' ' + c2 : '');
if (!seen.has('c1:' + c1)) {
items.push({ label, row: i });
seen.add('c1:' + c1);
}
}
}
return items;
}
function cellText(row, col) {
if (!row || !row.cells) return null;
const c = row.cells[col];
if (!c) return null;
const t = (c.text || '').trim();
return t || null;
}
// ---- find/mutate helpers ----
function findItemById(items, id) {
for (const it of items) {
if (it.id === id) return { item: it, parent: items };
if (it.children && it.children.length) {
const found = findItemById(it.children, id);
if (found) return found;
}
}
return null;
}
function makeNewItem(label, row) {
return { id: newId(), label: label || '新条目', row: typeof row === 'number' ? row : 0, children: [] };
}
// ============================================================
// Render TOC
// ============================================================
function renderToc() {
tocContainer.innerHTML = '';
tocEl.classList.toggle('edit-mode', __tocEditMode);
$('btn-toc-edit-toggle').classList.toggle('active', __tocEditMode);
$('btn-toc-edit-toggle').textContent = __tocEditMode ? '完成' : '编辑';
let any = false;
__tocState.forEach(group => {
if (!group.items || !group.items.length) return;
any = true;
const ul = document.createElement('ul');
ul.className = 'toc-list';
group.items.forEach(item => ul.appendChild(buildItemEl(item, group.sheetIdx, 0)));
tocContainer.appendChild(ul);
});
if (!any) {
const e = document.createElement('div');
e.className = 'toc-empty';
e.textContent = '(此表未识别到目录项,点“+ 顶级”手动添加)';
tocContainer.appendChild(e);
}
}
function buildItemEl(item, sheetIdx, depth) {
const li = document.createElement('li');
li.className = 'toc-item';
li.dataset.id = item.id;
const row = document.createElement('div');
row.className = 'toc-item-row';
row.title = item.label + ' (row ' + item.row + ')';
row.addEventListener('click', (e) => {
if (e.target.closest('.toc-action-btn')) return; // action clicks don't navigate
if (__tocEditMode) return; // edit mode = no nav
scrollToRow(sheetIdx, item.row, row);
});
const lbl = document.createElement('span');
lbl.className = 'toc-label';
lbl.textContent = item.label;
const tag = document.createElement('span');
tag.className = 'row-tag';
tag.textContent = '·' + item.row;
row.appendChild(lbl);
row.appendChild(tag);
// edit actions
const actions = document.createElement('span');
actions.className = 'toc-actions';
actions.appendChild(iconBtn('✎', '编辑', () => startInlineEdit(item, row, sheetIdx)));
actions.appendChild(iconBtn('', '添加子项', () => addChild(item), 'primary'));
actions.appendChild(iconBtn('↑', '上移', () => moveItem(item, -1)));
actions.appendChild(iconBtn('↓', '下移', () => moveItem(item, +1)));
actions.appendChild(iconBtn('×', '删除', () => deleteItem(item), 'danger'));
row.appendChild(actions);
li.appendChild(row);
if (item.children && item.children.length) {
const sub = document.createElement('ul');
sub.className = 'toc-children';
item.children.forEach(c => sub.appendChild(buildItemEl(c, sheetIdx, depth + 1)));
li.appendChild(sub);
}
return li;
}
function iconBtn(text, title, onclick, kind) {
const b = document.createElement('button');
b.className = 'toc-action-btn' + (kind ? ' ' + kind : '');
b.textContent = text;
b.title = title;
b.addEventListener('click', (e) => { e.stopPropagation(); onclick(); });
return b;
}
function startInlineEdit(item, rowEl, sheetIdx) {
// Replace row contents with editable inputs
const formEl = document.createElement('div');
formEl.className = 'toc-row-form';
const lblInput = document.createElement('input');
lblInput.type = 'text';
lblInput.value = item.label;
const rowInput = document.createElement('input');
rowInput.type = 'number';
rowInput.className = 'row-input';
rowInput.value = item.row;
rowInput.min = 0;
const save = document.createElement('button');
save.className = 'save'; save.textContent = '✓';
const cancel = document.createElement('button');
cancel.className = 'cancel'; cancel.textContent = '×';
formEl.appendChild(lblInput);
formEl.appendChild(rowInput);
formEl.appendChild(save);
formEl.appendChild(cancel);
rowEl.replaceWith(formEl);
lblInput.focus();
lblInput.select();
const commit = () => {
item.label = lblInput.value.trim() || '未命名';
const newRow = parseInt(rowInput.value, 10);
if (!isNaN(newRow) && newRow >= 0) item.row = newRow;
renderToc();
};
save.addEventListener('click', commit);
cancel.addEventListener('click', renderToc);
lblInput.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') renderToc(); });
rowInput.addEventListener('keydown', e => { if (e.key === 'Enter') commit(); if (e.key === 'Escape') renderToc(); });
}
function moveItem(item, dir) {
const found = findItemById(__tocState.flatMap(g => g.items), item.id);
if (!found) return;
const arr = found.parent;
const idx = arr.indexOf(item);
const newIdx = idx + dir;
if (newIdx < 0 || newIdx >= arr.length) return;
arr.splice(idx, 1);
arr.splice(newIdx, 0, item);
renderToc();
}
function deleteItem(item) {
const all = __tocState.flatMap(g => g.items);
const found = findItemById(all, item.id);
if (!found) return;
if (!confirm('删除"' + item.label + '"及其子项?')) return;
found.parent.splice(found.parent.indexOf(item), 1);
renderToc();
}
function addChild(parentItem) {
parentItem.children = parentItem.children || [];
const newItem = makeNewItem('新子项', 0);
parentItem.children.push(newItem);
renderToc();
// immediately let user edit
setTimeout(() => {
const li = tocContainer.querySelector('[data-id="' + newItem.id + '"]');
if (li) {
const rowEl = li.querySelector('.toc-item-row');
if (rowEl) startInlineEdit(newItem, rowEl, 0);
}
}, 0);
}
function addTopLevel() {
if (!__tocState.length) {
__tocState.push({ sheetIdx: 0, items: [] });
}
const newItem = makeNewItem('新顶级项', 0);
__tocState[0].items.push(newItem);
renderToc();
setTimeout(() => {
const li = tocContainer.querySelector('[data-id="' + newItem.id + '"]');
if (li) {
const rowEl = li.querySelector('.toc-item-row');
if (rowEl) startInlineEdit(newItem, rowEl, 0);
}
}, 0);
}
function resetToc() {
if (!confirm('重置为自动提取的目录?自定义条目会丢失。')) return;
__tocState = buildAutoToc(__sheets);
renderToc();
}
// ============================================================
// Scroll to row (drives the library's vertical scrollbar)
// ============================================================
function getLiveSheetData(sheetIdx) {
if (!__ss) return null;
if (Array.isArray(__ss.datas) && __ss.datas[sheetIdx]) return __ss.datas[sheetIdx];
if (sheetIdx === 0 && __ss.data) return __ss.data;
return null;
}
function scrollToRow(sheetIdx, rowIndex, rowEl) {
if (!__ss) return;
document.querySelectorAll('.toc-item-row.active').forEach(el => el.classList.remove('active'));
if (rowEl) rowEl.classList.add('active');
try {
const live = getLiveSheetData(sheetIdx);
if (!live || !live.rows || typeof live.rows.sumHeight !== 'function') return;
const y = live.rows.sumHeight(0, rowIndex);
const vbar = __ss.sheet && __ss.sheet.verticalScrollbar;
if (vbar && vbar.el && vbar.el[0]) vbar.el[0].scrollTop = y;
} catch (e) { console.warn(e); }
}
// ============================================================
// Render spreadsheet
// ============================================================
function renderSheet(sheetData) {
const arr = Array.isArray(sheetData) ? sheetData : [sheetData];
__sheets = arr;
__tocState = buildAutoToc(arr);
if (typeof x_spreadsheet === 'undefined') {
alert('x-spreadsheet 库加载失败,请检查网络');
return;
}
document.getElementById('xspreadsheet').innerHTML = '';
__ss = x_spreadsheet('#xspreadsheet');
__ss.loadData(arr);
renderToc();
}
// ============================================================
// Common loader: fetch -> handle errors -> render
// ============================================================
async function loadFromUrl(url, opts) {
opts = opts || {};
setStatus(opts.startMsg || '加载中…');
hideError();
loading.classList.add('visible');
loadingText.textContent = opts.loadingText || '加载中…';
try {
const res = await fetch(url, opts.fetchOpts || {});
let data;
try { data = await res.json(); }
catch (e) {
setStatus('响应解析失败(HTTP ' + res.status + '', 'error');
showError('服务器返回的不是 JSON');
loading.classList.remove('visible');
return;
}
if (!res.ok || !data.ok) {
const stage = data.stage ? '[' + data.stage + '] ' : '';
setStatus('失败:' + stage + (data.error || res.statusText), 'error');
let detail = '';
if (data.error) detail += 'error: ' + data.error + '\n';
if (data.stage) detail += 'stage: ' + data.stage + '\n';
if (data.trace) detail += '\n--- trace ---\n' + data.trace;
if (detail) showError(detail);
loading.classList.remove('visible');
return;
}
renderSheet(data.sheet_data);
resultArea.classList.add('visible');
uploadPanel.classList.add('hidden');
setStatus((opts.okMsg || '完成') + ' ✓', 'ok');
loading.classList.remove('visible');
} catch (err) {
setStatus('网络错误:' + err.message, 'error');
showError(String(err && err.stack || err));
loading.classList.remove('visible');
}
}
// ============================================================
// Header buttons
// ============================================================
$('btn-toggle-upload').addEventListener('click', () => uploadPanel.classList.toggle('hidden'));
// Import menu toggle
$('btn-import').addEventListener('click', (e) => {
e.stopPropagation();
importMenu.classList.toggle('open');
});
document.addEventListener('click', () => importMenu.classList.remove('open'));
importMenu.addEventListener('click', (e) => e.stopPropagation());
importMenu.querySelectorAll('.menu-item').forEach(btn => {
btn.addEventListener('click', () => {
importMenu.classList.remove('open');
const action = btn.dataset.action;
if (action === 'xlsx') fileXlsxInput.click();
else if (action === 'json') fileJsonInput.click();
else if (action === 'demo') loadFromUrl('/api/load_demo', {
startMsg: '加载示例…', loadingText: '加载 111.html / 111.xlsx…', okMsg: '已加载示例',
});
});
});
// File pickers -> upload
fileXlsxInput.addEventListener('change', async () => {
if (!fileXlsxInput.files.length) return;
const fd = new FormData();
fd.append('file', fileXlsxInput.files[0]);
fileXlsxInput.value = '';
await loadFromUrl('/api/load_xlsx', {
fetchOpts: { method: 'POST', body: fd },
startMsg: '解析 xlsx…', loadingText: '正在解析 .xlsx…', okMsg: 'xlsx 已加载',
});
});
fileJsonInput.addEventListener('change', async () => {
if (!fileJsonInput.files.length) return;
const fd = new FormData();
fd.append('file', fileJsonInput.files[0]);
fileJsonInput.value = '';
await loadFromUrl('/api/load_json', {
fetchOpts: { method: 'POST', body: fd },
startMsg: '解析 json…', loadingText: '正在解析 .json…', okMsg: 'json 已加载',
});
});
// ============================================================
// OCR upload form
// ============================================================
form.addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(form);
if (!fd.get('image') || !fd.get('image').name) { setStatus('请选择图片', 'error'); return; }
if (!fd.get('access_key_id') || !fd.get('access_key_secret')) {
setStatus('请填写 AccessKey ID 和 Secret', 'error'); return;
}
await loadFromUrl('/api/convert', {
fetchOpts: { method: 'POST', body: fd },
startMsg: '上传中…', loadingText: '正在上传并调用 OCR…', okMsg: '转换完成',
});
});
// ============================================================
// TOC toolbar
// ============================================================
$('btn-toc-edit-toggle').addEventListener('click', () => {
__tocEditMode = !__tocEditMode;
renderToc();
});
$('btn-toc-add-top').addEventListener('click', addTopLevel);
$('btn-toc-reset').addEventListener('click', resetToc);
// ============================================================
// Status helpers
// ============================================================
function setStatus(msg, kind) {
statusEl.textContent = msg;
statusEl.className = 'status' + (kind ? ' ' + kind : '');
}
function showError(text) {
const el = $('error-detail');
el.textContent = text;
el.style.display = 'block';
}
function hideError() {
const el = $('error-detail');
el.textContent = '';
el.style.display = 'none';
}
</script>
</body>
</html>
+6 -2
View File
@@ -93,7 +93,11 @@ def json_to_excel(json_file_path, output_excel_path=None):
if __name__ == '__main__': if __name__ == '__main__':
json_path = 'sample.json' import sys
out_path = '111.xlsx' if len(sys.argv) < 2:
print('Usage: python json_to_excel.py <input.json> [output.xlsx]')
sys.exit(1)
json_path = sys.argv[1]
out_path = sys.argv[2] if len(sys.argv) > 2 else None
result = json_to_excel(json_path, out_path) result = json_to_excel(json_path, out_path)
print(f"Excel saved to: {result}") print(f"Excel saved to: {result}")
+200 -4
View File
@@ -6,18 +6,212 @@
<title>x-data-spreadsheet</title> <title>x-data-spreadsheet</title>
<style> <style>
body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; } body { margin:0; padding:20px; font-family:Arial,sans-serif; background-color:#f5f5f5; }
#xspreadsheet { width:100%; height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; } .layout { display:flex; gap:20px; align-items:flex-start; }
.main { flex:1 1 auto; min-width:0; }
#xspreadsheet { width:100%; height:calc(100vh - 140px); min-height:600px; box-shadow:0 2px 10px rgba(0,0,0,0.1); background-color:white; }
.toc-sidebar {
flex: 0 0 200px;
position: sticky;
top: 20px;
max-height: calc(100vh - 40px);
overflow-y: auto;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
padding: 12px 0;
box-sizing: border-box;
}
.toc-sidebar h3 {
margin: 0 14px 8px 0;
font-size: 12px;
font-weight: 600;
color: #333;
border-bottom: 1px solid #eee;
padding: 0 14px 8px;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.toc-sheet-group { margin-bottom: 10px; }
.toc-sheet-name {
font-size: 11px;
color: #999;
margin: 4px 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.toc-list { list-style: none; padding: 0; margin: 0; }
.toc-list li { margin: 0; }
.toc-link {
display: block;
padding: 5px 14px;
color: #555;
text-decoration: none;
font-size: 12.5px;
line-height: 1.5;
cursor: pointer;
border-left: 2px solid transparent;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toc-link:hover { background: #f0f7ff; color: #1677ff; }
.toc-link.active {
background: #e6f4ff;
color: #1677ff;
border-left-color: #1677ff;
font-weight: 600;
}
.toc-empty { font-size: 12px; color: #999; padding: 8px 14px; }
</style> </style>
<link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css"> <link rel="stylesheet" href="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.css">
</head> </head>
<body> <body>
<h1>x-data-spreadsheet</h1> <h1>x-data-spreadsheet</h1>
<div id="xspreadsheet"></div> <div class="layout">
<div class="main">
<div id="xspreadsheet"></div>
</div>
<aside class="toc-sidebar">
<h3>目录</h3>
<div id="toc-container"></div>
</aside>
</div>
<script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script> <script src="https://unpkg.com/x-data-spreadsheet@1.1.9/dist/xspreadsheet.js"></script>
<script> <script>
const sheetData = {{DATA}}; const rawData = {{DATA}};
// Normalize: x_spreadsheet.loadData accepts an array of sheets or a single sheet
const sheets = Array.isArray(rawData) ? rawData : [rawData];
// ---- TOC builder ----
function cellText(row, col) {
if (!row || !row.cells) return null;
const c = row.cells[col];
if (!c) return null;
return (c.text || '').trim() || null;
}
function extractToc(sheet) {
const items = [];
const rows = sheet.rows || {};
const len = rows.len || 0;
const seen = new Set();
for (let i = 0; i < len; i++) {
const r = rows[i];
if (!r || !r.cells) continue;
const c0 = cellText(r, 0);
const c1 = cellText(r, 1);
const c2 = cellText(r, 2);
// 1) Top-level labels in column 0 (基本要求 / 外观质量 / 工程质量等级评定 / ...)
if (c0 && !seen.has('c0:' + c0)) {
items.push({ label: c0, row: i });
seen.add('c0:' + c0);
continue;
}
// 2) Numbered items in column 1 (项次: 1A, 2A, 3, 4, 5, 6, 7, 8, ...)
if (c1 && /^[0-9]+[A-Za-z]?$/.test(c1)) {
const label = c1 + (c2 ? ' ' + c2 : '');
if (!seen.has('c1:' + c1)) {
items.push({ label, row: i });
seen.add('c1:' + c1);
}
continue;
}
}
return items;
}
function renderToc(sheets) {
const container = document.getElementById('toc-container');
container.innerHTML = '';
let hasAny = false;
sheets.forEach((sheet, sheetIdx) => {
const items = extractToc(sheet);
if (!items.length && sheets.length === 1) return;
hasAny = true;
const group = document.createElement('div');
group.className = 'toc-sheet-group';
if (sheets.length > 1) {
const name = document.createElement('div');
name.className = 'toc-sheet-name';
name.textContent = sheet.name || ('Sheet ' + (sheetIdx + 1));
group.appendChild(name);
}
const ul = document.createElement('ul');
ul.className = 'toc-list';
items.forEach(item => {
const li = document.createElement('li');
const a = document.createElement('a');
a.className = 'toc-link';
a.href = 'javascript:void(0)';
a.dataset.sheet = sheetIdx;
a.dataset.row = item.row;
a.title = item.label + ' (row ' + item.row + ')';
a.innerHTML = item.label + '<span class="row-tag" style="color:#bbb;font-size:10.5px;margin-left:4px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">·' + item.row + '</span>';
a.addEventListener('click', () => scrollToRow(sheetIdx, item.row, a));
li.appendChild(a);
ul.appendChild(li);
});
group.appendChild(ul);
container.appendChild(group);
});
if (!hasAny) {
const empty = document.createElement('div');
empty.className = 'toc-empty';
empty.textContent = '(未识别到目录项)';
container.appendChild(empty);
}
}
// ---- Scroll handling ----
// x-data-spreadsheet renders the grid on a canvas; rows are NOT in the DOM.
// We scroll by setting the vertical scrollbar's scrollTop, which fires
// the library's internal moveFn -> scrolly() -> re-render.
let __ssRef = null; // populated after load
function getSheetData(sheetIdx) {
if (!__ssRef) return null;
// spreadsheet.datas[] holds the pt instances; .data is shorthand for datas[0]
if (Array.isArray(__ssRef.datas) && __ssRef.datas[sheetIdx]) return __ssRef.datas[sheetIdx];
if (sheetIdx === 0 && __ssRef.data) return __ssRef.data;
return null;
}
function scrollToRow(sheetIdx, rowIndex, linkEl) {
if (!__ssRef) return;
// Mark active
document.querySelectorAll('.toc-link.active').forEach(el => el.classList.remove('active'));
if (linkEl) linkEl.classList.add('active');
try {
const liveData = getSheetData(sheetIdx);
if (!liveData || !liveData.rows || typeof liveData.rows.sumHeight !== 'function') {
console.warn('No live sheet data for index', sheetIdx);
return;
}
const y = liveData.rows.sumHeight(0, rowIndex);
const vbar = __ssRef.sheet && __ssRef.sheet.verticalScrollbar;
if (vbar && vbar.el && vbar.el[0]) {
vbar.el[0].scrollTop = y;
}
} catch (e) {
console.warn('scrollToRow failed:', e);
}
}
// ---- Init ----
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
if (typeof x_spreadsheet === 'undefined') { if (typeof x_spreadsheet === 'undefined') {
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>'; document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: x-spreadsheet library failed to load. Check internet connection.</p>';
@@ -25,7 +219,9 @@
} }
try { try {
const spreadsheet = x_spreadsheet('#xspreadsheet'); const spreadsheet = x_spreadsheet('#xspreadsheet');
spreadsheet.loadData(sheetData); spreadsheet.loadData(sheets);
__ssRef = spreadsheet;
renderToc(sheets);
} catch (e) { } catch (e) {
console.error('Error:', e); console.error('Error:', e);
document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>'; document.body.innerHTML += '<p style="color:red;margin-top:10px;">Error: ' + e.message + '</p>';