Files
competition-website/app/lib/publicitySheet.js
qubiaobiao 459f1adf6d feat(admin): R176-R180 公示表与新闻撤回 + 管理端多管理员权限(C 层)+ 交卷冻结
- R176 成绩公示表引擎(lib/publicitySheet.js + lib/assets/publicity-template.xlsx 官方模板基底)
  与指导教师回填(lib/teacherBackfill.js);赛项名称/编号改为配置项
- R177 新闻「发布 ↔ 撤回」互斥:撤回只置 is_draft=1,不删除数据
- R178 C 层 RBAC:lib/adminPermissions.js(13 权限点 / 70 条路由规则,唯一 SoT)
  + middleware/auth.js 三段判定(默认拒绝 · is_super 单点 · token_version)
  + controller/service/adminUser.js + lib/auditLog.js + model/adminAuditLog.js
- R178 B1 交卷冻结:service.exam.assertNotSubmitted(bizCode 4006 单一判据源)
- R178 B④ 提交口径拆分:formalSubmitted / submitted / answeredCount 三字段
- R178 成绩总览补小组编号:resolveTeamLabels(与选手管理同源,含兜底与三态)
- R181 生产迁移配套:admins +7 列、admin_audit_logs(scripts/migrate_r178_admin_rbac.py)
2026-10-20 11:06:29 +08:00

560 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* 成绩公示表导出引擎 —— 纯函数层(R176)
*
* 目标:以赛事官方参考模板(`lib/assets/publicity-template.xlsx`)为**基底**生成
* xlsx,只填数据、不改版式,人工后续补「获奖等级」即可直接用。
*
* 🔴 为什么不用 `@e965/xlsx` 直接改模板再写回(实测结论,2026-10-19):
* 读入模板 → 写一个单元格 → 写回,`sheet1.xml` 里的 `s=` 样式引用数
* 从 **214 → 0**、`styles.xml` 从 22773 → 1546 字节,合并单元格还在但
* 边框/字号/列宽全部丢失。该库 CE 版**不写出样式**,`cellStyles: true`
* 只影响读、不影响写。
* ⇒ 唯一能保住版式的做法 = 只替换 `xl/worksheets/sheet1.xml` 这一个 zip 条目,
* 其余条目**原始压缩字节照抄**(不解压不重压 ⇒ 连 CRC 都不用重算)。
*
* 🔴 零依赖约束:只用 Node 内置 `zlib`,CRC32 自己实现。这样 `--self-test`
* 可以在零 DB / 零后端 / 零网络的条件下直接 require 本文件跑矩阵。
*
* 🔴 数据行数可以超过模板预留的 27 行:模板数据区是 row 3..29,两条签字行在 row 30
* (裁判长)与 row 33(仲裁长)。新表按「新数据末行 + 1」与「新数据末行 + 4」
* 的相对偏移重排两条签字行(N=27 时正好复现 row 30 / row 33)。
* ⚠️ 备注块合并区 `H3:J8` 固定不动(H:J 列,与 A:G 的数据列不冲突),
* 但必须保证至少生成 6 个数据行,否则备注框的下边框会落空。
*/
const zlib = require('zlib');
// ==================== 常量(模板结构的单一描述源) ====================
/** 模板第 2 行的 7 个表头,仅用于自证断言与文档;实际写值不依赖它 */
const TEMPLATE_HEADERS = ['序号', '参赛单位名称', '参赛选手', '指导教师', '成绩排名', '总分数', '获奖等级'];
/** 数据区起止行(1 基,含端点)。row 2 = 表头,row 3 起才是数据 */
const DATA_FIRST_ROW = 3;
/**
* 🔴 数据区实际末行是 29,**不是 30**。
* 模板的 row 30 是「裁判长签字:」行(A30 / E30 各一个),row 33 是「仲裁长签字:」行。
* 把 30 当数据行会直接抹掉裁判长签字 —— 2026-10-19 实测踩过这个坑。
*/
const DATA_LAST_ROW = 29;
/** 签字行原始行号(模板里两条:裁判长在 30,仲裁长在 33) */
const SIGN_ROWS = [30, 33];
/** 模板预留的数据行数(row 3..29 = 27 行;模板里 row 29 没有 row 元素,不影响计数口径) */
const RESERVED_DATA_ROWS = DATA_LAST_ROW - DATA_FIRST_ROW + 1;
/** 末条签字行行号 */
const TEMPLATE_TAIL_ROW = SIGN_ROWS[SIGN_ROWS.length - 1];
/** 签字行相对「数据区末行」的偏移(33 - 29 = 4) */
const TAIL_OFFSET = TEMPLATE_TAIL_ROW - DATA_LAST_ROW;
/** 备注块 H3:J8 要求的最小数据行数(8 - 3 + 1 = 6) */
const MIN_DATA_ROWS_FOR_NOTE = 6;
/** 数据列 A..G */
const DATA_COLS = ['A', 'B', 'C', 'D', 'E', 'F', 'G'];
/** 备注块所在列(只补空样式单元格,撑住合并区边框) */
const NOTE_COLS = ['H', 'I', 'J'];
/** 模板标题里等待替换的两段占位文本 */
const TITLE_PLACEHOLDER_ITEM = '***赛项(选拔赛/国内决赛)比赛结果公示表';
const TITLE_PLACEHOLDER_CODE = '赛项编号:';
// ==================== CRC32 / zip 读写 ====================
let CRC_TABLE = null;
function crcTable() {
if (CRC_TABLE) return CRC_TABLE;
const t = new Int32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
}
t[n] = c;
}
CRC_TABLE = t;
return t;
}
/** 标准 CRC-32(IEEE 802.3),返回无符号 32 位 */
function crc32(buf) {
const t = crcTable();
let c = -1;
for (let i = 0; i < buf.length; i++) {
c = t[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
}
return (c ^ -1) >>> 0;
}
/** 从尾部找 End Of Central Directory。找不到返回 -1 */
function findEOCD(buf) {
const min = Math.max(0, buf.length - 65557);
for (let i = buf.length - 22; i >= min; i--) {
if (buf[i] === 0x50 && buf[i + 1] === 0x4b && buf[i + 2] === 0x05 && buf[i + 3] === 0x06) return i;
}
return -1;
}
/**
* 读 zip,返回每个条目的**原始压缩字节**(不解压)。
* 中央目录里的 compSize / crc 总是准确的(即使 local header 用了 data descriptor),
* 所以定位数据区一律以中央目录为准。
*/
function readZip(buf) {
if (!Buffer.isBuffer(buf) || buf.length < 22) throw new Error('不是有效的 zip/xlsx(内容过短)');
const eocd = findEOCD(buf);
if (eocd < 0) throw new Error('不是有效的 zip/xlsx(找不到 EOCD)');
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
if (off >= buf.length) throw new Error('中央目录偏移越界');
const entries = [];
for (let i = 0; i < count; i++) {
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) {
throw new Error(`中央目录第 ${i} 项签名不符 @${off}`);
}
const method = buf.readUInt16LE(off + 10);
const crc = buf.readUInt32LE(off + 16);
const compSize = buf.readUInt32LE(off + 20);
const uncompSize = buf.readUInt32LE(off + 24);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.slice(off + 46, off + 46 + nameLen).toString('utf8');
if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) {
throw new Error(`条目 ${name} 的本地头损坏`);
}
const lnLen = buf.readUInt16LE(localOff + 26);
const leLen = buf.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lnLen + leLen;
if (dataStart + compSize > buf.length) throw new Error(`条目 ${name} 的数据区越界`);
entries.push({
name,
method,
crc,
compSize,
uncompSize,
data: buf.slice(dataStart, dataStart + compSize),
});
off += 46 + nameLen + extraLen + commentLen;
}
return entries;
}
/** 把条目按原压缩字节重新打包成 zip(不重压缩 ⇒ CRC 无需重算) */
function writeZip(entries) {
const locals = [];
const centrals = [];
let offset = 0;
for (const e of entries) {
const nameBuf = Buffer.from(e.name, 'utf8');
const crc = e.crc >>> 0;
const compSize = e.data.length;
const uncompSize = e.uncompSize;
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4); // version needed to extract
lh.writeUInt16LE(0x0800, 6); // general purpose flag: UTF-8 名称
lh.writeUInt16LE(e.method, 8);
lh.writeUInt16LE(0, 10); // last mod time(不关心,固定 0)
lh.writeUInt16LE(0, 12); // last mod date
lh.writeUInt32LE(crc, 14);
lh.writeUInt32LE(compSize, 18);
lh.writeUInt32LE(uncompSize, 22);
lh.writeUInt16LE(nameBuf.length, 26);
lh.writeUInt16LE(0, 28); // extra len
locals.push(lh, nameBuf, e.data);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt16LE(20, 4); // version made by
ch.writeUInt16LE(20, 6); // version needed
ch.writeUInt16LE(0x0800, 8);
ch.writeUInt16LE(e.method, 10);
ch.writeUInt16LE(0, 12);
ch.writeUInt16LE(0, 14);
ch.writeUInt32LE(crc, 16);
ch.writeUInt32LE(compSize, 20);
ch.writeUInt32LE(uncompSize, 24);
ch.writeUInt16LE(nameBuf.length, 28);
ch.writeUInt16LE(0, 30); // extra
ch.writeUInt16LE(0, 32); // comment
ch.writeUInt16LE(0, 34); // disk number
ch.writeUInt16LE(0, 36); // internal attrs
ch.writeUInt32LE(0, 38); // external attrs
ch.writeUInt32LE(offset, 42);
centrals.push(ch, nameBuf);
offset += lh.length + nameBuf.length + e.data.length;
}
const centralStart = offset;
const centralBuf = Buffer.concat(centrals);
const eocd = Buffer.alloc(22);
eocd.writeUInt32LE(0x06054b50, 0);
eocd.writeUInt16LE(0, 4);
eocd.writeUInt16LE(0, 6);
eocd.writeUInt16LE(entries.length, 8);
eocd.writeUInt16LE(entries.length, 10);
eocd.writeUInt32LE(centralBuf.length, 12);
eocd.writeUInt32LE(centralStart, 16);
eocd.writeUInt16LE(0, 20);
return Buffer.concat([Buffer.concat(locals), centralBuf, eocd]);
}
/** 解压单个条目为 utf8 文本(method 0=store / 8=deflate) */
function entryText(entry) {
if (!entry) throw new Error('条目不存在');
const raw = entry.method === 0 ? entry.data : zlib.inflateRawSync(entry.data);
return raw.toString('utf8');
}
/** 用 utf8 文本替换某条目(重新 deflate + 重算 CRC) */
function replaceEntryText(entries, name, text) {
const raw = Buffer.from(text, 'utf8');
const deflated = zlib.deflateRawSync(raw);
let hit = 0;
const out = entries.map(e => {
if (e.name !== name) return e;
hit++;
return {
name: e.name,
method: 8,
crc: crc32(raw),
compSize: deflated.length,
uncompSize: raw.length,
data: deflated,
};
});
if (hit !== 1) throw new Error(`条目 ${name} 命中 ${hit} 次(应恰好 1 次)`);
return out;
}
// ==================== XML 片段工具 ====================
const XML_ESCAPE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' };
function esc(s) {
return String(s === null || s === undefined ? '' : s).replace(/[&<>"']/g, c => XML_ESCAPE[c]);
}
const ROW_RE = /<row\b[^>]*\/>|<row\b[^>]*>[\s\S]*?<\/row>/g;
const CELL_RE = /<c\b[^>]*\/>|<c\b[^>]*>[\s\S]*?<\/c>/g;
function rowNumber(rowXml) {
const m = /<row\b[^>]*\br="(\d+)"/.exec(rowXml);
return m ? parseInt(m[1], 10) : -1;
}
/** 从一行 XML 里取 { 列字母: s 值 } */
function rowStyles(rowXml) {
const out = {};
const cells = rowXml.match(CELL_RE) || [];
for (const c of cells) {
const rm = /\br="([A-Z]+)\d+"/.exec(c);
if (!rm) continue;
const sm = /\bs="(\d+)"/.exec(c);
if (sm) out[rm[1]] = sm[1];
}
return out;
}
/** 生成一个数据单元格(数值 / 文本 / 空) */
function cellXml(col, row, style, value) {
const ref = col + row;
const sAttr = style === undefined ? '' : ` s="${style}"`;
if (value === null || value === undefined || value === '') {
return `<c r="${ref}"${sAttr}/>`;
}
if (typeof value === 'number' && isFinite(value)) {
return `<c r="${ref}"${sAttr}><v>${value}</v></c>`;
}
// 用 inlineStr 而不是 sharedStrings:新引入的字符串不用再去维护共享表下标
return `<c r="${ref}"${sAttr} t="inlineStr"><is><t xml:space="preserve">${esc(value)}</t></is></c>`;
}
/** 选「最常见的数据行样式」作为新行模板,避免拿模板里被手工改过的那一行当标准 */
function pickDataRowStyles(dataRows) {
const counted = new Map();
for (const r of dataRows) {
const st = rowStyles(r.xml);
const sig = DATA_COLS.map(c => st[c] === undefined ? '-' : st[c]).join(',');
const cur = counted.get(sig);
if (cur) cur.n++;
else counted.set(sig, { n: 1, styles: st });
}
let best = null;
for (const [, v] of counted) {
if (!best || v.n > best.n) best = v;
}
return best ? best.styles : { A: '6', B: '7', C: '7', D: '8', E: '6', F: '6', G: '6' };
}
/**
* 生成新的 sheetData。
*
* @param {string} sheetXml 原 sheet1.xml
* @param {Array<Object>} rows buildPublicityRows 的产物
* @returns {{ xml: string, dataRows: number, tailRow: number, styles: Object }}
*/
function patchSheetData(sheetXml, rows) {
const sheetDataMatch = /<sheetData\b[^>]*>([\s\S]*?)<\/sheetData>/.exec(sheetXml);
if (!sheetDataMatch) throw new Error('模板缺少 <sheetData>');
const allRows = sheetDataMatch[1].match(ROW_RE) || [];
const head = [];
const data = [];
const tail = [];
for (const r of allRows) {
const n = rowNumber(r);
if (n <= 2) head.push(r);
else if (n >= SIGN_ROWS[0]) tail.push(r);
else data.push({ n, xml: r });
}
if (head.length !== 2) throw new Error(`模板表头行应为 2 行,实际 ${head.length}`);
if (data.length === 0) throw new Error('模板没有数据行可作样式参照');
// 🔴 必须同时守住两条签字行:只保留一条等于把「裁判长签字」或「仲裁长签字」丢了
if (tail.length !== SIGN_ROWS.length) {
throw new Error(`模板签字行应为 ${SIGN_ROWS.length} 条(row ${SIGN_ROWS.join(' / ')}),实际 ${tail.length} 条`);
}
const styles = pickDataRowStyles(data);
// 备注块 H3:J8 的空单元格样式:直接取模板数据行里 H/I/J 的 s(row 3 有备注文本,取 row 4 起)
const noteStyleFrom = {};
for (const d of data) {
if (d.n <= 8) {
const st = rowStyles(d.xml);
for (const c of NOTE_COLS) {
if (st[c] !== undefined && noteStyleFrom[c] === undefined) noteStyleFrom[c] = st[c];
}
}
}
const count = Math.max(rows.length, MIN_DATA_ROWS_FOR_NOTE);
const out = [];
for (const h of head) out.push(h);
for (let i = 0; i < count; i++) {
const r = DATA_FIRST_ROW + i;
const row = rows[i] || null;
const cells = [
cellXml('A', r, styles.A, row ? row.seq : null),
cellXml('B', r, styles.B, row ? row.org : null),
cellXml('C', r, styles.C, row ? row.players : null),
cellXml('D', r, styles.D, row ? row.teachers : null),
cellXml('E', r, styles.E, row ? row.rank : null),
cellXml('F', r, styles.F, row ? row.score : null),
// 获奖等级:用户裁定「空置人工填入」⇒ 永远不写值
cellXml('G', r, styles.G, null),
];
// 备注合并区 H3:J8 需要这些单元格存在才能撑住边框
if (r <= 8) {
for (const c of NOTE_COLS) cells.push(cellXml(c, r, noteStyleFrom[c], null));
}
out.push(`<row r="${r}" customHeight="1" spans="1:7">${cells.join('')}</row>`);
}
const delta = (count + (DATA_FIRST_ROW - 1)) - DATA_LAST_ROW; // 新数据末行 - 旧数据末行
const shiftRow = (xml, add) => {
if (add === 0) return xml;
return xml.replace(/(<row\b[^>]*\br=")(\d+)(")/, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3)
.replace(/(<c\b[^>]*\br="[A-Z]+)(\d+)(")/g, (m, p1, p2, p3) => p1 + (parseInt(p2, 10) + add) + p3);
};
for (const t of tail) out.push(shiftRow(t, delta));
const newSheetData = `<sheetData>${out.join('')}</sheetData>`;
let xml = sheetXml.replace(sheetDataMatch[0], newSheetData);
// dimension 同步(否则 Excel 认为表格只到旧末行,滚动区/打印区会截断)
const tailRow = TEMPLATE_TAIL_ROW + delta;
xml = xml.replace(/<dimension\b[^>]*\bref="[^"]*"/, `<dimension ref="A1:J${tailRow}"`);
return { xml, dataRows: count, tailRow, styles };
}
/**
* 替换标题里的赛项名与赛项编号。
*
* 标题是 A1 一个单元格里的三行文本(存在 sharedStrings 第 0 项),
* 所以改 sharedStrings 比改 sheet1 更准(A1 是 t="s" 引用,改 sheet 只能整体替换)。
*
* @param {string} xml sharedStrings.xml 原文
* @param {{itemName?: string, itemCode?: string}} meta
* @returns {{ xml: string, replacedItem: boolean, replacedCode: boolean }}
*/
function patchSharedStringsTitle(xml, meta) {
const itemName = meta && meta.itemName ? String(meta.itemName).trim() : '';
const itemCode = meta && meta.itemCode ? String(meta.itemCode).trim() : '';
let replacedItem = false;
let replacedCode = false;
// 定位第 1 个 <si>(标题)
const siMatch = /<si>([\s\S]*?)<\/si>/.exec(xml);
if (!siMatch) throw new Error('sharedStrings 里没有 <si>');
let inner = siMatch[1];
if (itemName && inner.indexOf(TITLE_PLACEHOLDER_ITEM) >= 0) {
// 赛项名替换掉「***」,保留「(选拔赛/国内决赛)比赛结果公示表」的公文后缀
inner = inner.replace(TITLE_PLACEHOLDER_ITEM, `${esc(itemName)}(选拔赛/国内决赛)比赛结果公示表`);
replacedItem = true;
}
if (itemCode && inner.indexOf(TITLE_PLACEHOLDER_CODE) >= 0) {
// 编号为空时不追加,避免留下一个孤零零的「赛项编号:」
inner = inner.replace(TITLE_PLACEHOLDER_CODE, `${TITLE_PLACEHOLDER_CODE}${esc(itemCode)}`);
replacedCode = true;
}
if (!replacedItem && !replacedCode) return { xml, replacedItem, replacedCode };
const newSi = `<si>${inner}</si>`;
return { xml: xml.replace(siMatch[0], newSi), replacedItem, replacedCode };
}
/**
* 完整流程:模板字节 + 数据行 + 标题信息 → 新 xlsx 字节。
* @param {Buffer} templateBuf
* @param {Array<Object>} rows
* @param {{itemName?: string, itemCode?: string}} [meta]
*/
function buildXlsx(templateBuf, rows, meta) {
const entries = readZip(templateBuf);
const sheetEntry = entries.find(e => e.name === 'xl/worksheets/sheet1.xml');
if (!sheetEntry) throw new Error('模板缺少 xl/worksheets/sheet1.xml');
const sheetXml = entryText(sheetEntry);
const patched = patchSheetData(sheetXml, rows);
let next = replaceEntryText(entries, 'xl/worksheets/sheet1.xml', patched.xml);
const ssEntry = next.find(e => e.name === 'xl/sharedStrings.xml');
let titleResult = { replacedItem: false, replacedCode: false };
if (ssEntry) {
const ssXml = entryText(ssEntry);
const r = patchSharedStringsTitle(ssXml, meta || {});
titleResult = { replacedItem: r.replacedItem, replacedCode: r.replacedCode };
if (r.replacedItem || r.replacedCode) {
next = replaceEntryText(next, 'xl/sharedStrings.xml', r.xml);
}
}
return {
buffer: writeZip(next),
dataRows: patched.dataRows,
tailRow: patched.tailRow,
title: titleResult,
styles: patched.styles,
};
}
// ==================== 数据构造(纯函数) ====================
/** 分数归一:null/''/NaN → null;字符串数字 → number */
function toScore(v) {
if (v === null || v === undefined || v === '') return null;
const n = Number(v);
return isFinite(n) ? Math.round(n * 100) / 100 : null;
}
/**
* 把「小组」数组排成公示表的行。
*
* 一行 = 一个小组(用户裁定:一组一人参赛、成绩全组共享 ⇒ 同一分数不该占多行)。
* 排序:有分的按分数**由高到低**,未评分的排最后;同分按团队编号升序保证稳定。
* 获奖等级不在此处产生(永远留空)。
*
* @param {Array<{teamUid?:number|string, teamId?:string, org?:string,
* players?:string[]|string, teachers?:string[]|string, score?:number|string}>} teams
* @returns {Array<{seq:number, org:string, players:string, teachers:string, rank:(number|null), score:(number|null), teamUid:any, teamId:string}>}
*/
function buildPublicityRows(teams) {
const list = Array.isArray(teams) ? teams : [];
const norm = list.map(t => {
const players = Array.isArray(t.players) ? t.players : (t.players ? [t.players] : []);
const teachers = Array.isArray(t.teachers) ? t.teachers : (t.teachers ? [t.teachers] : []);
return {
teamUid: t.teamUid === undefined ? null : t.teamUid,
teamId: t.teamId === undefined || t.teamId === null ? '' : String(t.teamId),
org: t.org === undefined || t.org === null ? '' : String(t.org),
players: uniqKeepOrder(players).join('、'),
teachers: uniqKeepOrder(teachers).join('、'),
score: toScore(t.score),
};
});
norm.sort((a, b) => {
const sa = a.score === null ? -Infinity : a.score;
const sb = b.score === null ? -Infinity : b.score;
if (sb !== sa) return sb - sa;
// 未评分的两个也按编号定序,避免顺序随查询波动
if (a.teamId < b.teamId) return -1;
if (a.teamId > b.teamId) return 1;
return 0;
});
let rank = 0;
return norm.map((t, i) => {
// 未评分不参与排名(否则会出现「0 分并列第 N」的假排名)
if (t.score !== null) rank++;
return {
seq: i + 1,
org: t.org,
players: t.players,
teachers: t.teachers,
rank: t.score === null ? null : rank,
score: t.score,
teamUid: t.teamUid,
teamId: t.teamId,
};
});
}
/** 去重保序(同组同名只留一份) */
function uniqKeepOrder(arr) {
const seen = new Set();
const out = [];
for (const x of arr) {
const s = String(x === null || x === undefined ? '' : x).trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
module.exports = {
TEMPLATE_HEADERS,
DATA_FIRST_ROW,
DATA_LAST_ROW,
SIGN_ROWS,
RESERVED_DATA_ROWS,
TEMPLATE_TAIL_ROW,
TAIL_OFFSET,
MIN_DATA_ROWS_FOR_NOTE,
DATA_COLS,
TITLE_PLACEHOLDER_ITEM,
TITLE_PLACEHOLDER_CODE,
crc32,
findEOCD,
readZip,
writeZip,
entryText,
replaceEntryText,
rowNumber,
rowStyles,
cellXml,
pickDataRowStyles,
patchSheetData,
patchSharedStringsTitle,
buildXlsx,
toScore,
buildPublicityRows,
uniqKeepOrder,
};