From 37aa621c6c8a1cea6dd61110de25691f5e5ea36b Mon Sep 17 00:00:00 2001 From: Geequlim Date: Sun, 17 May 2020 23:58:31 +0800 Subject: [PATCH] =?UTF-8?q?NodeJS=20=E8=BD=AC=E8=A1=A8=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- .vscode/launch.json | 5 +- README.md | 63 ++++- excel-exporter.json | 32 +++ package.json | 4 + .../ExcelExporterApplication.ts | 71 +++++ src/excel-exporter/TableExporter.ts | 46 ++++ src/excel-exporter/TableParser.ts | 255 ++++++++++++++++++ .../exporters/CSharpExporter.ts | 85 ++++++ src/excel-exporter/exporters/JSONExporter.ts | 67 +++++ src/main.ts | 16 +- 转表.bat | 2 + 转表.sh | 2 + 13 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 excel-exporter.json create mode 100644 src/excel-exporter/ExcelExporterApplication.ts create mode 100644 src/excel-exporter/TableExporter.ts create mode 100644 src/excel-exporter/TableParser.ts create mode 100644 src/excel-exporter/exporters/CSharpExporter.ts create mode 100644 src/excel-exporter/exporters/JSONExporter.ts create mode 100644 转表.bat create mode 100644 转表.sh diff --git a/.gitignore b/.gitignore index 498b0b2..0e393f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist/ node_modules/ -yarn.lock \ No newline at end of file +yarn.lock +*.xlsl \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index ea5b488..19dc753 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,10 @@ "skipFiles": [ "/**" ], - "program": "${workspaceFolder}/dist/binary.js" + "program": "${workspaceFolder}/dist/binary.js", + "args": [ + "./excel-exporter.json" + ] } ] } \ No newline at end of file diff --git a/README.md b/README.md index 570372d..da6c36e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,62 @@ -# NodeJS 起始项目 +# Excel 配置表数据导出工具 -搭建好 TypeScript NodeJS 的空项目,提供编译、调试流程,内置 tiny 使用代码库。 \ No newline at end of file +将 Excel 配置表中的数据导出为方便程序读取和使用的数据。 + +## 目前支持的导出格式有: +* JSON 文件 +* C# 类型声明 +~~* TypeScript 声明文件(需要配合 JSON 使用)~~ +~~* Godot 引擎的 GDScript 脚本文件~~ + +## 表格格式说明 + +* 每个 xlsl 文件中可以有多张表(Sheet),每张表会都导出一份数据文件,表名必须符合标识符规范 +* 表名为 `@skip` 或以 `@skip` 开头的表会被忽略,不会导出数据文件 +* 第一列值为 `@skip` 的行会被忽略,视为无效数据行 +* 整行所有列为空的行会被忽略,视为无效数据行 +* 每张表的**第一个有效数据行**用作字段名,决定了导出数据所拥有的属性,**字段名必须符合标识符命名规范** +* 字段名所在的行中不填名称的列视为空字段,该列的数据在导出时会被忽略 +* 相同名称的字段导出时会被合并为数组 +* 导出属性的数据类型由**整列所填写的数据类型**决定,支持以下数据类型 + * 字符串 + * 数值(优先使用整形) + * 布尔值 + * 空(`null`) +* 该工具设计原则是简单易用,表格字段可由策划自由调整, 不支持数据引用,暂不支持结构体 + +## Windows 安装 +安装 NodeJS, 注意勾选将 Node 添加到环境变量 `PATH` 中 + +## 使用 + 修改配置表 + 修改 excel-exporter.json 修改工具配置 + 双击 转表.bat 执行转换工作 + +### 配置示例 + +```json +{ + "input": [ + { "file": "装备表.xlsx", "encode": "GBK"}, + { "file": "关卡表.xlsx", "encode": "GBK"}, + ], + "parser": { + "first_row_as_field_comment": true + }, + "output": { + "json": { + "enabled": true, + "directory": "../../client/Assets/Resources/data/json", + "indent": "\t" + }, + "csharp": { + "enabled": true, + "directory": "../../client/Assets/Resources/data/csharp", + "namespace": "game.data", + "base_type": "tiny.data.UniqueIDObject", + "file_name": "data", + "ignore_id": true + } + } +} +``` \ No newline at end of file diff --git a/excel-exporter.json b/excel-exporter.json new file mode 100644 index 0000000..1cca0f7 --- /dev/null +++ b/excel-exporter.json @@ -0,0 +1,32 @@ +{ + "input": [ + { "file": "士兵表.xlsx", "encode": "GBK"}, + { "file": "统帅表.xlsx", "encode": "GBK"}, + { "file": "武器表.xlsx", "encode": "GBK"}, + { "file": "装备表.xlsx", "encode": "GBK"}, + { "file": "关卡表.xlsx", "encode": "GBK"}, + { "file": "箱子奖励招募表.xlsx", "encode": "GBK"}, + { "file": "僵尸表.xlsx", "encode": "GBK"}, + { "file": "任务表.xlsx", "encode": "GBK"}, + { "file": "伤害动作表.xlsx", "encode": "GBK"}, + { "file": "签到表.xlsx", "encode": "GBK"} + ], + "parser": { + "first_row_as_field_comment": true + }, + "output": { + "json": { + "enabled": true, + "directory": "../../client/Assets/Resources/data/json", + "indent": "\t" + }, + "csharp": { + "enabled": true, + "directory": "../../client/Assets/Resources/data/csharp", + "namespace": "game.data", + "base_type": "tiny.data.UniqueIDObject", + "file_name": "data", + "ignore_id": true + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index ad6c144..1a08588 100644 --- a/package.json +++ b/package.json @@ -14,5 +14,9 @@ "typescript": "^3.9.2", "webpack": "^4.43.0", "webpack-cli": "^3.3.11" + }, + "dependencies": { + "colors": "^1.4.0", + "xlsx": "^0.16.0" } } diff --git a/src/excel-exporter/ExcelExporterApplication.ts b/src/excel-exporter/ExcelExporterApplication.ts new file mode 100644 index 0000000..de42d16 --- /dev/null +++ b/src/excel-exporter/ExcelExporterApplication.ts @@ -0,0 +1,71 @@ +import { FileAccess, ModeFlags } from "tiny/io"; +import { ParserConfigs, TableParser, TableData } from "./TableParser"; +import { ExporterConfigs, TableExporter } from "./TableExporter"; +import { JSONExporter } from "./exporters/JSONExporter"; +import { CSharpExporter } from "./exporters/CSharpExporter"; +import * as colors from "colors"; + +export interface Configurations { + /** 解析配置 */ + parser?: ParserConfigs, + /** 要读取的 XLSL 文档 */ + input: {"file": string, encode: string}[], + /** 导出配置 */ + output: { [key: string]: ExporterConfigs } +} + + +const exporters: {[key:string]: new(config: ExporterConfigs) => TableExporter } = { + json: JSONExporter, + csharp: CSharpExporter, +} + + +export class ExcelExporterApplication { + + configs: Configurations = null; + parser: TableParser = null; + tables: { [key: string]: TableData } = {}; + exporters: TableExporter[] = []; + + constructor(config_file: string) { + let file = FileAccess.open(config_file, ModeFlags.READ); + this.configs = JSON.parse(file.get_as_utf8_string()) as Configurations; + file.close(); + this.parser = new TableParser(this.configs.parser); + + for (const key in this.configs.output) { + let cls = exporters[key]; + if (cls) { + const exporter = new cls(this.configs.output[key]); + exporter.name = key; + this.exporters.push(exporter); + } + } + } + + parse() { + for (const item of this.configs.input) { + console.log(colors.grey(`解析配表文件: ${item.file}`)); + let sheets = this.parser.parse_xlsl(item.file); + for (const name in sheets) { + this.tables[name] = sheets[name]; + } + } + console.log(colors.green(`解析所有配表文件完成`)); + console.log(); + } + + export() { + for (const exporter of this.exporters) { + if (exporter.configs.enabled) { + console.log(colors.white(`执行 ${exporter.name} 导出:`)); + for (const name in this.tables) { + exporter.export(name, this.tables[name]); + } + exporter.finalize(); + console.log(); + } + } + } +} \ No newline at end of file diff --git a/src/excel-exporter/TableExporter.ts b/src/excel-exporter/TableExporter.ts new file mode 100644 index 0000000..2c325e3 --- /dev/null +++ b/src/excel-exporter/TableExporter.ts @@ -0,0 +1,46 @@ +import { TableData } from "./TableParser"; +import { FileAccess, ModeFlags, DirAccess } from "tiny/io"; +import { path } from "tiny/path"; + +export interface ExporterConfigs { + enabled: boolean, + directory: string, +} + +export class TableExporter { + configs: ExporterConfigs = null; + name: string = ""; + + constructor(configs: ExporterConfigs) { + this.configs = configs; + } + + protected line(text = "", indent = 0) { + let line = ""; + for (let i = 0; i < indent; i++) { + line += "\t"; + } + line += text; + line += "\n"; + return line; + } + + protected save_text(file_path: string, text: string) { + let dir = path.dirname(file_path); + if (!DirAccess.exists(dir)) { + DirAccess.make_dir(dir, true); + } + let file = FileAccess.open(file_path, ModeFlags.WRITE); + file.save_as_utf8_string(text); + file.close(); + } + /** + * 导出配置表数据 + * @param name 表名称 + * @param table 表数据 + */ + export(name: string, table: TableData) { } + + /** 全部配置表导出完毕后保存文件 */ + finalize() {} +} \ No newline at end of file diff --git a/src/excel-exporter/TableParser.ts b/src/excel-exporter/TableParser.ts new file mode 100644 index 0000000..cb0bcf7 --- /dev/null +++ b/src/excel-exporter/TableParser.ts @@ -0,0 +1,255 @@ +import * as xlsl from "xlsx"; +import { FileAccess, ModeFlags } from "tiny/io"; +import * as colors from "colors"; + +type RawTableData = xlsl.CellObject[][]; + +export interface ParserConfigs { + /** 第一行作为注释 */ + first_row_as_field_comment: boolean; + /** 固定数组长度 */ + constant_array_length: boolean; +} + +export enum DataType { + null = 'null', + int = 'int', + bool = 'bool', + float = 'float', + string = 'string', +} + +export interface ColumnDescription { + type: DataType; + name: string; + is_array?: boolean; + comment?: string; +} + +export interface TableData { + headers: ColumnDescription[], + values: any[][] +} + +const SKIP_PREFIX = "@skip"; + +export class TableParser { + + configs: ParserConfigs = null; + + constructor(configs: ParserConfigs) { + this.configs = configs; + } + + public parse_xlsl(path) { + return this.load_raw_xlsl_data(path); + } + + protected load_raw_xlsl_data(path: string): { [key: string]: TableData } { + var file = FileAccess.open(path, ModeFlags.READ); + let wb = xlsl.read(file.get_as_array()); + file.close(); + let raw_tables: { [key: string]: RawTableData } = {}; + for (const name of wb.SheetNames) { + let sheet_name = name.trim(); + if (sheet_name.startsWith(SKIP_PREFIX)) continue; + raw_tables[sheet_name] = this.parse_sheet(wb.Sheets[name]); + } + + let tables: { [key: string]: TableData } = {}; + for (const name in raw_tables) { + console.log(colors.grey(`\t解析配置表 ${name}`)); + tables[name] = this.process_table(raw_tables[name]); + } + return tables; + } + + protected parse_sheet(sheet: xlsl.WorkSheet): RawTableData { + let range = xlsl.utils.decode_range(sheet['!ref']); + var rows: RawTableData = []; + for (let r = range.s.r; r <= range.e.r; r++) { + let R = xlsl.utils.encode_row(r); + let row: xlsl.CellObject[] = []; + for (let c = range.s.c; c <= range.e.c; c++) { + let C = xlsl.utils.encode_col(c); + let cell = sheet[`${C}${R}`] as xlsl.CellObject; + row.push(cell); + } + rows.push(row); + } + return rows; + } + + protected process_table(raw: RawTableData): TableData { + + let headers: ColumnDescription[] = []; + + let column_values: xlsl.CellObject[][] = []; + let ignored_columns = new Set(); + // 去除无用的列 + let rows: RawTableData = []; + for (const row of raw) { + if (this.is_valid_row(row)) { + rows.push(row); + } + } + + let column = 0; + for (let c = 0; c < rows[0].length; c++) { + let first = rows[0][c]; + if (this.get_data_type(first) != DataType.string) { + ignored_columns.add(c); + continue; + } + let column_cells = this.get_column(rows, c, 1); + let type = DataType.null; + let types = new Set(); + for (const cell of column_cells) { + types.add(this.get_data_type(cell)); + } + let type_order = [ DataType.string, DataType.float, DataType.int, DataType.bool ]; + for (const t of type_order) { + if (types.has(t)) { + type = t; + break; + } + } + let comment: string = undefined; + if (this.configs.first_row_as_field_comment) { + comment = this.get_cell_value(raw[0][c], DataType.string) as string; + } + headers.push({ + type, + comment, + name: first.v as string, + }); + + column_values.push([]); + for (const cell of column_cells) { + column_values[column].push(cell); + } + column += 1; + } + + let values: RawTableData = []; + for (let r = 0; r < rows.length - 1; r++) { + let row: any = []; + for (let c = 0; c < column_values.length; c++) { + row.push(column_values[c][r]) + } + values.push(row); + } + return this.parse_values(headers, values); + } + + + protected parse_values(raw_headers : ColumnDescription[], raw_values: RawTableData) { + type FiledInfo = { + column: ColumnDescription, + start: number, + indexes: number[] + }; + let field_maps = new Map(); + let field_list: FiledInfo[] = []; + let c_idx = 0; + for (const column of raw_headers) { + if (!field_maps.has(column.name)) { + const field = { + column, + start: c_idx, + indexes: [ c_idx ] + }; + field_list.push(field); + field_maps.set(column.name, field); + } else { + let field = field_maps.get(column.name); + field.column.is_array = true; + field.indexes.push(c_idx); + } + c_idx += 1; + } + let headers: ColumnDescription[] = []; + for (const filed of field_list) { + headers.push(filed.column); + } + let values: any[][] = []; + for (const raw_row of raw_values) { + let row: any[] = []; + for (const filed of field_list) { + if (filed.column.is_array) { + let arr = []; + for (const idx of filed.indexes) { + const cell = raw_row[idx]; + if (cell || this.configs.constant_array_length) { + arr.push(this.get_cell_value(cell, filed.column.type)); + } + } + row.push(arr); + } else { + const cell = raw_row[filed.start]; + row.push(this.get_cell_value(cell, filed.column.type)); + } + } + values.push(row); + } + + return { + headers, + values + } + } + + protected is_valid_row(row: xlsl.CellObject[]) { + let first = row[0]; + if (this.get_data_type(first) == DataType.string && (first.v as string).trim().startsWith(SKIP_PREFIX)) { + return false; + } + let all_empty = true; + for (const cell of row) { + all_empty = all_empty && this.get_data_type(cell) == DataType.null; + } + if (all_empty) return false; + return true; + } + + protected get_column(table: RawTableData, column: number, start_row: number = 0): xlsl.CellObject[] { + let cells: xlsl.CellObject[] = []; + for (let r = start_row; r < table.length; r++) { + const row = table[r]; + cells.push(row[column]); + } + return cells; + } + + protected get_data_type(cell: xlsl.CellObject): DataType { + if (!cell) return DataType.null; + switch (cell.t) { + case 'b': + return DataType.bool; + case 'n': + return Number.isInteger(cell.v as number) ? DataType.int : DataType.float; + case 's': + case 'd': + return DataType.string; + case 'e': + case 'z': + default: + return DataType.null; + } + } + + protected get_cell_value(cell: xlsl.CellObject, type: DataType) { + switch (type) { + case DataType.bool: + return cell.v as boolean == true; + case DataType.int: + return cell ? cell.v as number : 0; + case DataType.float: + return cell ? cell.v as number : 0; + case DataType.string: + return cell ? cell.v + '' : ''; + default: + return null; + } + } +} \ No newline at end of file diff --git a/src/excel-exporter/exporters/CSharpExporter.ts b/src/excel-exporter/exporters/CSharpExporter.ts new file mode 100644 index 0000000..cb6f4b5 --- /dev/null +++ b/src/excel-exporter/exporters/CSharpExporter.ts @@ -0,0 +1,85 @@ +import { TableExporter, ExporterConfigs } from "excel-exporter/TableExporter"; +import { TableData, DataType } from "excel-exporter/TableParser"; +import { path } from "tiny/path"; +import * as colors from "colors"; + +interface CSharpExporterConfigs extends ExporterConfigs { + namespace: string, + base_type: string, + file_name: string, + ignore_id: boolean +} + +export class CSharpExporter extends TableExporter { + protected declear_content = ""; + protected classes: string[] = []; + + constructor(configs: ExporterConfigs) { + super(configs); + if ( typeof ((this.configs as CSharpExporterConfigs).namespace) != 'string') { + (this.configs as CSharpExporterConfigs).namespace = "game.data"; + } + if ( typeof ((this.configs as CSharpExporterConfigs).base_type) != 'string') { + (this.configs as CSharpExporterConfigs).namespace = "object"; + } + if ( typeof ((this.configs as CSharpExporterConfigs).file_name) != 'string') { + (this.configs as CSharpExporterConfigs).file_name = "data"; + } + + this.declear_content += this.line("// Tool generated file DO NOT MODIFY"); + this.declear_content += this.line("using System;"); + this.declear_content += this.line(); + this.declear_content += this.line("namespace " + (this.configs as CSharpExporterConfigs).namespace + " {") + this.declear_content += this.line("%CLASSES%"); + this.declear_content += this.line("}"); + } + + + export(name: string, table: TableData) { + const base_type = (this.configs as CSharpExporterConfigs).base_type; + let body = ""; + for (const field of table.headers) { + if (field.name == 'id' && (this.configs as CSharpExporterConfigs).ignore_id) { + continue; + } + let type = "object"; + switch (field.type) { + case DataType.bool: + case DataType.float: + case DataType.string: + case DataType.int: + type = field.type; + break; + default: + type = "object"; + break; + } + if (field.is_array) { + type += "[]"; + } + if (field.comment) { + let comment = field.comment.split("\r\n").join("\t"); + comment = comment.split("\n").join("\t"); + body += this.line(`/// ${comment}`, 1); + } + body += this.line(`${type} ${field.name};`, 1); + } + let class_text = this.line(`public class ${name} : ${base_type} {\n${body}\n}`); + this.classes.push(class_text); + } + + finalize() { + let class_text = ""; + for (const cls of this.classes) { + class_text += cls; + class_text += this.line(); + } + + let file = path.join(this.configs.directory, (this.configs as CSharpExporterConfigs).file_name); + if (!file.endsWith(".cs")) { + file += ".cs"; + } + this.save_text(file, this.declear_content.replace("%CLASSES%", class_text)); + console.log(colors.green(`\t${file}`)); + } +} \ No newline at end of file diff --git a/src/excel-exporter/exporters/JSONExporter.ts b/src/excel-exporter/exporters/JSONExporter.ts new file mode 100644 index 0000000..c7883d4 --- /dev/null +++ b/src/excel-exporter/exporters/JSONExporter.ts @@ -0,0 +1,67 @@ +import { TableExporter, ExporterConfigs } from "excel-exporter/TableExporter"; +import { TableData } from "excel-exporter/TableParser"; +import { path } from "tiny/path"; +import * as colors from "colors"; + +interface JSONExporterConfigs extends ExporterConfigs { + /** 缩进字符 */ + indent: string; +} + +export class JSONExporter extends TableExporter { + + constructor(configs: ExporterConfigs) { + super(configs); + if ( typeof ((this.configs as JSONExporterConfigs).indent) != 'string') { + (this.configs as JSONExporterConfigs).indent = " "; + } + } + + protected recursively_order_keys(unordered: object | Array) { + // If it's an array - recursively order any + // dictionary items within the array + if (Array.isArray(unordered)) { + unordered.forEach((item, index) => { + unordered[index] = this.recursively_order_keys(item); + }); + return unordered; + } + // If it's an object - let's order the keys + if (typeof unordered === 'object' && unordered != null) { + var ordered = {}; + Object.keys(unordered).sort().forEach((key) => { + ordered[key] = this.recursively_order_keys(unordered[key]); + }); + return ordered; + } + return unordered; + } + + export(name: string, table: TableData) { + const file = path.join(this.configs.directory, name + ".json"); + let headers = table.headers; + let values = []; + for (const row of table.values) { + let new_row = {}; + for (let i = 0; i < headers.length; i++) { + const field = headers[i]; + new_row[field.name] = row[i]; + } + values.push(new_row); + } + let indent = ""; + const configs = (this.configs as JSONExporterConfigs); + if (configs.indent) { + if (typeof (configs.indent) == 'number') { + for (let i = 0; i < configs.indent; i++) { + indent += " "; + } + } else if (typeof configs.indent == 'string') { + indent = configs.indent; + } + } + const text = JSON.stringify(this.recursively_order_keys(values), null, indent); + this.save_text(file, text); + console.log(colors.green(`\t ${name} ==> ${file}`)); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts index a6dd14d..433a779 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,18 @@ import { get_startup_arguments } from "./tiny/env"; +import { ExcelExporterApplication } from "excel-exporter/ExcelExporterApplication"; +import { FileAccess } from "tiny/io"; +import * as colors from "colors"; + (async function main(argv: string[]) { - console.log(argv); + + let config_file = argv[argv.length - 1]; + if (config_file.endsWith(".json") && FileAccess.exists(config_file)) { + let app = new ExcelExporterApplication(config_file); + app.parse(); + app.export(); + console.log(colors.green("All Done")); + } else { + console.log(colors.red("请传入配置文件作为参数")); + } + })(get_startup_arguments()); diff --git a/转表.bat b/转表.bat new file mode 100644 index 0000000..525a60f --- /dev/null +++ b/转表.bat @@ -0,0 +1,2 @@ +call node ./dist/binary.js ./excel-exporter.json +pause \ No newline at end of file diff --git a/转表.sh b/转表.sh new file mode 100644 index 0000000..c99699b --- /dev/null +++ b/转表.sh @@ -0,0 +1,2 @@ +#!/bin/bash +node ./dist/binary.js ./excel-exporter.json \ No newline at end of file