dsh-redteam-report 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/WORKSPACE-REPORTS.md +48 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +747 -0
- package/lib/host.js +119 -0
- package/lib/parts/client.head.js +15 -0
- package/lib/parts/client.shim.js +45 -0
- package/lib/parts/client.tail.js +5 -0
- package/lib/parts/host.head.js +80 -0
- package/lib/parts/host.tail.js +37 -0
- package/package.json +68 -0
- package/src/client.js +602 -0
- package/src/docx.js +776 -0
- package/src/host.js +1176 -0
- package/src/workspace-client.js +108 -0
- package/src/workspace-evidence.js +310 -0
- package/src/workspace-install.js +60 -0
- package/src/workspace-runtime.js +409 -0
- package/tools/build-lib.mjs +120 -0
- package/tools/prepare-gh-packages.mjs +66 -0
package/src/docx.js
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
// 红队报告 · Markdown → 自包含 HTML / 真·DOCX(纯 JS,零依赖)
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独成文件:这部分逻辑有测试价值,而 src/host.js 只是 applyHost 的「函数体片段」,
|
|
4
|
+
// 没法被测试 import。本文件按仓库约定写成 ESM,顶层只用 `export function` / `export const`,
|
|
5
|
+
// tools/build-lib.mjs 拼接时剥掉行首的 `export `,于是同一份源码既能被 node 测试 import,
|
|
6
|
+
// 也能原样落进 lib/host.js 的函数体。
|
|
7
|
+
//
|
|
8
|
+
// 由此得出三条不能破的约束(改这里之前先读一遍):
|
|
9
|
+
// 1. 不 import / 不 require / 不 export default / 不写 `export { a, b }` 聚合导出;
|
|
10
|
+
// 2. 所有顶层名字带 rpt / RPT_ 前缀 —— 它们会和宿主函数体里已有的局部变量同处一个作用域;
|
|
11
|
+
// 3. 不依赖 btoa / Buffer / TextEncoder / DOM —— 宿主沙箱里这些不保证存在,
|
|
12
|
+
// 所以 UTF-8 编码与 base64 都是手写实现,ZIP 与 CRC-32 也是。
|
|
13
|
+
//
|
|
14
|
+
// 三份输出(块结构 / HTML / DOCX)共用同一个 rptParseMarkdown,行内格式只解析一次,
|
|
15
|
+
// 这样「网页预览」与「Word 交付件」不可能出现粗体、链接对不上的情况。
|
|
16
|
+
|
|
17
|
+
// 报告 HTML 的内联样式。与 rptBuildHtml 同源,导出是为了让调用方(面板预览)能只取样式。
|
|
18
|
+
export const RPT_REPORT_STYLES = `
|
|
19
|
+
:root { color-scheme: light; }
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
body {
|
|
22
|
+
margin: 0; padding: 40px 24px; background: #f5f6f8; color: #1f2329;
|
|
23
|
+
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", "Helvetica Neue", Arial, sans-serif;
|
|
24
|
+
font-size: 15px; line-height: 1.75;
|
|
25
|
+
}
|
|
26
|
+
.rpt { max-width: 880px; margin: 0 auto; background: #fff; padding: 48px 56px 64px; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
|
27
|
+
.rpt-title { margin: 0 0 6px; font-size: 30px; line-height: 1.35; font-weight: 700; letter-spacing: .5px; }
|
|
28
|
+
.rpt-meta { width: 100%; border-collapse: collapse; margin: 18px 0 30px; font-size: 14px; }
|
|
29
|
+
.rpt-meta th, .rpt-meta td { border: 1px solid #d0d5dd; padding: 7px 10px; text-align: left; }
|
|
30
|
+
.rpt-meta th { width: 8em; background: #f2f4f7; font-weight: 600; white-space: nowrap; }
|
|
31
|
+
h1, h2, h3, h4 { line-height: 1.4; margin: 28px 0 12px; font-weight: 700; }
|
|
32
|
+
h1 { font-size: 24px; } h2 { font-size: 20px; } h3 { font-size: 17px; } h4 { font-size: 15px; }
|
|
33
|
+
p { margin: 12px 0; }
|
|
34
|
+
.rpt-list { margin: 12px 0; padding-left: 26px; }
|
|
35
|
+
.rpt-list li { margin: 4px 0; }
|
|
36
|
+
.rpt-code {
|
|
37
|
+
margin: 16px 0; padding: 14px 16px; overflow: auto; white-space: pre; border-radius: 6px;
|
|
38
|
+
background: #1f2329; color: #e8e8e8; font-size: 13px; line-height: 1.6;
|
|
39
|
+
font-family: Consolas, "SFMono-Regular", Menlo, Consolas, monospace;
|
|
40
|
+
}
|
|
41
|
+
.rpt-code code { background: none; color: inherit; padding: 0; font-size: inherit; }
|
|
42
|
+
code { background: #f0f1f3; padding: 1px 5px; border-radius: 4px; font-size: .92em; font-family: Consolas, Menlo, monospace; }
|
|
43
|
+
blockquote { margin: 16px 0; padding: 8px 16px; border-left: 4px solid #9aa4b2; background: #f7f8fa; color: #4b5563; }
|
|
44
|
+
blockquote p { margin: 0; }
|
|
45
|
+
.rpt-table { width: 100%; border-collapse: collapse; margin: 18px 0; font-size: 14px; }
|
|
46
|
+
.rpt-table th, .rpt-table td { border: 1px solid #d0d5dd; padding: 8px 10px; text-align: left; vertical-align: top; }
|
|
47
|
+
.rpt-table thead th { background: #f2f4f7; font-weight: 600; }
|
|
48
|
+
a { color: #1a56db; }
|
|
49
|
+
@media print {
|
|
50
|
+
body { background: #fff; padding: 0; }
|
|
51
|
+
.rpt { box-shadow: none; max-width: none; padding: 0; }
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
|
|
55
|
+
// base64 字母表(手写实现用,避免 btoa/Buffer)
|
|
56
|
+
const RPT_BASE64_TABLE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
57
|
+
|
|
58
|
+
// ZIP 里所有条目都用固定时间戳:同一份 markdown 必须产出逐字节相同的 docx,
|
|
59
|
+
// 否则「产物比对」这类测试与缓存都会失效。
|
|
60
|
+
const RPT_DOS_TIME = 0
|
|
61
|
+
const RPT_DOS_DATE = 0x21 // 1980-01-01
|
|
62
|
+
|
|
63
|
+
// 超链接关系从 rId2 起编号:rId1 固定留给 styles.xml。
|
|
64
|
+
const RPT_FIRST_LINK_RID = 2
|
|
65
|
+
|
|
66
|
+
// ── 编码原语 ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
// 手写 UTF-8:宿主沙箱里没有 TextEncoder,而且必须正确处理 emoji 的代理对。
|
|
69
|
+
// 内容里的孤立代理项会产出非法 UTF-8 字节,调用方(转义层)负责先过滤掉。
|
|
70
|
+
export function rptUtf8Encode(str) {
|
|
71
|
+
const s = String(str == null ? '' : str)
|
|
72
|
+
const out = []
|
|
73
|
+
for (let i = 0; i < s.length; i++) {
|
|
74
|
+
let cp = s.charCodeAt(i)
|
|
75
|
+
if (cp >= 0xd800 && cp <= 0xdbff && i + 1 < s.length) {
|
|
76
|
+
const lo = s.charCodeAt(i + 1)
|
|
77
|
+
// 高低代理项配对后是一个 BMP 之外的码点,占 4 字节
|
|
78
|
+
if (lo >= 0xdc00 && lo <= 0xdfff) {
|
|
79
|
+
cp = 0x10000 + ((cp - 0xd800) << 10) + (lo - 0xdc00)
|
|
80
|
+
i++
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (cp < 0x80) out.push(cp)
|
|
84
|
+
else if (cp < 0x800) out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f))
|
|
85
|
+
else if (cp < 0x10000) out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))
|
|
86
|
+
else out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f))
|
|
87
|
+
}
|
|
88
|
+
return Uint8Array.from(out)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 手写 base64:不换行,标准 +/ 字母表,末组补 =。
|
|
92
|
+
export function rptBytesToBase64(bytes) {
|
|
93
|
+
const b = bytes || []
|
|
94
|
+
const n = b.length
|
|
95
|
+
let out = ''
|
|
96
|
+
let i = 0
|
|
97
|
+
for (; i + 3 <= n; i += 3) {
|
|
98
|
+
const v = (b[i] << 16) | (b[i + 1] << 8) | b[i + 2]
|
|
99
|
+
out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] +
|
|
100
|
+
RPT_BASE64_TABLE[(v >>> 6) & 63] + RPT_BASE64_TABLE[v & 63]
|
|
101
|
+
}
|
|
102
|
+
const rest = n - i
|
|
103
|
+
if (rest === 1) {
|
|
104
|
+
const v = b[i] << 16
|
|
105
|
+
out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] + '=='
|
|
106
|
+
} else if (rest === 2) {
|
|
107
|
+
const v = (b[i] << 16) | (b[i + 1] << 8)
|
|
108
|
+
out += RPT_BASE64_TABLE[(v >>> 18) & 63] + RPT_BASE64_TABLE[(v >>> 12) & 63] +
|
|
109
|
+
RPT_BASE64_TABLE[(v >>> 6) & 63] + '='
|
|
110
|
+
}
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ── 文本与行内格式 ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
// HTML 转义。markdown 是外部输入(可能来自目标站点或抓取结果),
|
|
117
|
+
// 不转义就等于把「报告里的 <script>」直接变成活代码,所以这里连同引号一起转。
|
|
118
|
+
function rptEscapeHtml(text) {
|
|
119
|
+
return String(text == null ? '' : text)
|
|
120
|
+
.replace(/&/g, '&')
|
|
121
|
+
.replace(/</g, '<')
|
|
122
|
+
.replace(/>/g, '>')
|
|
123
|
+
.replace(/"/g, '"')
|
|
124
|
+
.replace(/'/g, ''')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 剔掉 XML 1.0 不允许的字符(除 \t \n \r 外的 C0 控制符、孤立代理项、0xFFFE/0xFFFF)。
|
|
128
|
+
// 不做这步 Word 会直接报「文档已损坏」——它比标签配对错误更难定位。
|
|
129
|
+
function rptXmlClean(text) {
|
|
130
|
+
const s = String(text == null ? '' : text)
|
|
131
|
+
let out = ''
|
|
132
|
+
for (let i = 0; i < s.length; i++) {
|
|
133
|
+
const c = s.charCodeAt(i)
|
|
134
|
+
if (c === 0x9 || c === 0xa || c === 0xd || (c >= 0x20 && c <= 0xd7ff) || (c >= 0xe000 && c <= 0xfffd)) {
|
|
135
|
+
out += s[i]
|
|
136
|
+
} else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
|
|
137
|
+
const lo = s.charCodeAt(i + 1)
|
|
138
|
+
if (lo >= 0xdc00 && lo <= 0xdfff) { out += s[i] + s[i + 1]; i++ }
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return out
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 引号也转:同一个函数同时用于文本节点与 r:id/链接 Target 等属性值。
|
|
145
|
+
function rptXmlEscape(text) {
|
|
146
|
+
return rptXmlClean(text)
|
|
147
|
+
.replace(/&/g, '&')
|
|
148
|
+
.replace(/</g, '<')
|
|
149
|
+
.replace(/>/g, '>')
|
|
150
|
+
.replace(/"/g, '"')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 只放行明确安全的协议:报告里的链接同样来自外部数据,
|
|
154
|
+
// javascript:/data: 这类在 Word 与浏览器里都是可执行面,一律降级成纯文本。
|
|
155
|
+
function rptSafeUrl(url) {
|
|
156
|
+
const u = String(url == null ? '' : url).replace(/[\u0000-\u0020\u007f]/g, '').trim()
|
|
157
|
+
if (u === '') return ''
|
|
158
|
+
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(u)
|
|
159
|
+
if (!m) return u // 相对路径
|
|
160
|
+
const scheme = m[1].toLowerCase()
|
|
161
|
+
return (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'ftp') ? u : ''
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function rptRunsToText(runs) {
|
|
165
|
+
let out = ''
|
|
166
|
+
for (const r of runs || []) out += r && r.text != null ? r.text : ''
|
|
167
|
+
return out
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 行内解析:**粗体** / `行内代码` / [文字](url)。
|
|
171
|
+
// 刻意不做嵌套(单趟扫描,非重叠匹配)——报告里的行内格式几乎不会嵌套,
|
|
172
|
+
// 而支持嵌套会让 HTML 与 DOCX 两条渲染路径的分支数翻倍、更难保持一致。
|
|
173
|
+
function rptParseInline(text) {
|
|
174
|
+
const src = String(text == null ? '' : text)
|
|
175
|
+
const runs = []
|
|
176
|
+
const re = /\*\*([\s\S]+?)\*\*|`([^`]+)`|\[([^\]]*)\]\(([^)\s]*)(?:\s+"[^"]*")?\)/g
|
|
177
|
+
let last = 0
|
|
178
|
+
let m
|
|
179
|
+
while ((m = re.exec(src)) !== null) {
|
|
180
|
+
if (m.index > last) runs.push({ text: src.slice(last, m.index) })
|
|
181
|
+
if (m[1] !== undefined) runs.push({ text: m[1], bold: true })
|
|
182
|
+
else if (m[2] !== undefined) runs.push({ text: m[2], code: true })
|
|
183
|
+
else runs.push({ text: m[3], href: rptSafeUrl(m[4]) })
|
|
184
|
+
last = m.index + m[0].length
|
|
185
|
+
}
|
|
186
|
+
if (last < src.length) runs.push({ text: src.slice(last) })
|
|
187
|
+
if (runs.length === 0) runs.push({ text: '' })
|
|
188
|
+
return runs
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// text 一律是「去掉行内标记的纯文本」,runs 才是结构化形式:
|
|
192
|
+
// 调用方想直接拿文本做检索/摘要时不该还看到 ** 和 [](),而两条渲染路径都只吃 runs。
|
|
193
|
+
function rptTextBlock(type, text, ordered) {
|
|
194
|
+
const runs = rptParseInline(text)
|
|
195
|
+
const block = { type: type, text: rptRunsToText(runs), runs: runs }
|
|
196
|
+
if (ordered !== undefined) block.ordered = ordered
|
|
197
|
+
return block
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── Markdown 块解析 ─────────────────────────────────────────────────────────
|
|
201
|
+
|
|
202
|
+
function rptSplitRow(line) {
|
|
203
|
+
let t = String(line).trim()
|
|
204
|
+
if (t.charAt(0) === '|') t = t.slice(1)
|
|
205
|
+
if (t.charAt(t.length - 1) === '|') t = t.slice(0, -1)
|
|
206
|
+
return t.split('|').map(function (c) { return c.trim() })
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function rptIsTableSeparator(line) {
|
|
210
|
+
const t = String(line).trim()
|
|
211
|
+
if (t.indexOf('-') < 0) return false
|
|
212
|
+
if (!/^\|?[\s:|-]+\|?$/.test(t)) return false
|
|
213
|
+
const cells = rptSplitRow(t)
|
|
214
|
+
if (cells.length === 0) return false
|
|
215
|
+
for (const c of cells) if (!/^:?-+:?$/.test(c.replace(/\s/g, ''))) return false
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function rptIsFence(line) {
|
|
220
|
+
return /^(```|~~~)/.test(String(line).trim())
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function rptIsHeading(line) {
|
|
224
|
+
return /^#{1,6}\s+/.test(String(line).trim())
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function rptIsList(line) {
|
|
228
|
+
const t = String(line).trim()
|
|
229
|
+
return /^[-*]\s+/.test(t) || /^\d+[.)]\s+/.test(t)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rptIsQuote(line) {
|
|
233
|
+
return /^\s*>\s?/.test(String(line))
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 段落收集时的「下一行是否另起块」判断:段落与表格行都以 | 出现,只能靠后一行是不是分隔行区分。
|
|
237
|
+
function rptStartsNewBlock(lines, i) {
|
|
238
|
+
const line = lines[i]
|
|
239
|
+
if (rptIsHeading(line) || rptIsFence(line) || rptIsList(line) || rptIsQuote(line)) return true
|
|
240
|
+
const t = String(line).trim()
|
|
241
|
+
if (t.indexOf('|') >= 0 && i + 1 < lines.length && rptIsTableSeparator(lines[i + 1])) return true
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function rptTableRows(rows) {
|
|
246
|
+
let width = 0
|
|
247
|
+
for (const r of rows) if (r.length > width) width = r.length
|
|
248
|
+
const out = []
|
|
249
|
+
for (const r of rows) {
|
|
250
|
+
const cells = r.slice()
|
|
251
|
+
while (cells.length < width) cells.push('')
|
|
252
|
+
out.push(cells)
|
|
253
|
+
}
|
|
254
|
+
return out
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// markdown → 结构化块。返回块类型:
|
|
258
|
+
// h1..h4 | p | li | oli | code | quote | table
|
|
259
|
+
// 段落/列表项给出 text(纯文本)与 runs(行内结构);表格给出 rows(纯文本)、
|
|
260
|
+
// cellRuns(每格的行内结构)与 text(表头行)。DOCX 与 HTML 都只消费 runs/cellRuns,
|
|
261
|
+
// 因此「网页预览」与「Word 交付件」在粗体、链接上不可能对不上。
|
|
262
|
+
export function rptParseMarkdown(md) {
|
|
263
|
+
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n')
|
|
264
|
+
const blocks = []
|
|
265
|
+
let i = 0
|
|
266
|
+
while (i < lines.length) {
|
|
267
|
+
const raw = lines[i]
|
|
268
|
+
const trimmed = raw.trim()
|
|
269
|
+
if (trimmed === '') { i++; continue }
|
|
270
|
+
|
|
271
|
+
// 围栏代码块:内容逐字保留,语言标记丢弃(两种输出都不做语法高亮)
|
|
272
|
+
const fence = /^(```|~~~)/.exec(trimmed)
|
|
273
|
+
if (fence) {
|
|
274
|
+
const mark = fence[1]
|
|
275
|
+
const body = []
|
|
276
|
+
i++
|
|
277
|
+
while (i < lines.length && lines[i].trim().indexOf(mark) !== 0) { body.push(lines[i]); i++ }
|
|
278
|
+
if (i < lines.length) i++ // 吃掉收尾围栏;未闭合时就是到文件末尾
|
|
279
|
+
blocks.push({ type: 'code', lines: body, text: body.join('\n') })
|
|
280
|
+
continue
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(trimmed)
|
|
284
|
+
if (heading) {
|
|
285
|
+
// #### 及以上统一按 h4:报告排版到四级标题就够,再深也只是字号差异
|
|
286
|
+
const level = Math.min(heading[1].length, 4)
|
|
287
|
+
blocks.push(rptTextBlock('h' + level, heading[2].trim()))
|
|
288
|
+
i++
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 表格必须先于段落判断:表格行同样是普通文本行,只有「下一行是分隔行」能区分
|
|
293
|
+
if (trimmed.indexOf('|') >= 0 && i + 1 < lines.length && rptIsTableSeparator(lines[i + 1])) {
|
|
294
|
+
const rows = [rptSplitRow(trimmed)]
|
|
295
|
+
i += 2
|
|
296
|
+
while (i < lines.length && lines[i].trim() !== '' && lines[i].trim().indexOf('|') >= 0) {
|
|
297
|
+
rows.push(rptSplitRow(lines[i].trim()))
|
|
298
|
+
i++
|
|
299
|
+
}
|
|
300
|
+
const normalized = rptTableRows(rows)
|
|
301
|
+
const cellRuns = normalized.map(function (r) { return r.map(rptParseInline) })
|
|
302
|
+
const plain = cellRuns.map(function (r) { return r.map(rptRunsToText) })
|
|
303
|
+
blocks.push({
|
|
304
|
+
type: 'table',
|
|
305
|
+
rows: plain,
|
|
306
|
+
cellRuns: cellRuns,
|
|
307
|
+
text: plain[0].join(' | '),
|
|
308
|
+
})
|
|
309
|
+
continue
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (rptIsQuote(raw)) {
|
|
313
|
+
const parts = []
|
|
314
|
+
while (i < lines.length && rptIsQuote(lines[i])) {
|
|
315
|
+
parts.push(lines[i].replace(/^\s*>\s?/, ''))
|
|
316
|
+
i++
|
|
317
|
+
}
|
|
318
|
+
blocks.push(rptTextBlock('quote', parts.join(' ').trim()))
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const ul = /^[-*]\s+(.*)$/.exec(trimmed)
|
|
323
|
+
if (ul) {
|
|
324
|
+
blocks.push(rptTextBlock('li', ul[1].trim(), false))
|
|
325
|
+
i++
|
|
326
|
+
continue
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const ol = /^\d+[.)]\s+(.*)$/.exec(trimmed)
|
|
330
|
+
if (ol) {
|
|
331
|
+
blocks.push(rptTextBlock('oli', ol[1].trim(), true))
|
|
332
|
+
i++
|
|
333
|
+
continue
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// 普通段落:连续非空行合并为一段,段内换行按 markdown 语义转成空格
|
|
337
|
+
const para = []
|
|
338
|
+
while (i < lines.length && lines[i].trim() !== '' && !rptStartsNewBlock(lines, i)) {
|
|
339
|
+
para.push(lines[i].trim())
|
|
340
|
+
i++
|
|
341
|
+
}
|
|
342
|
+
blocks.push(rptTextBlock('p', para.join(' ')))
|
|
343
|
+
}
|
|
344
|
+
return blocks
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── HTML 渲染 ───────────────────────────────────────────────────────────────
|
|
348
|
+
|
|
349
|
+
function rptRunsHtml(runs) {
|
|
350
|
+
let out = ''
|
|
351
|
+
for (const r of runs || []) {
|
|
352
|
+
const text = rptEscapeHtml(r && r.text != null ? r.text : '')
|
|
353
|
+
if (r && r.code) out += '<code>' + text + '</code>'
|
|
354
|
+
else if (r && r.href) out += '<a href="' + rptEscapeHtml(r.href) + '" rel="noopener noreferrer">' + text + '</a>'
|
|
355
|
+
else if (r && r.bold) out += '<strong>' + text + '</strong>'
|
|
356
|
+
else out += text
|
|
357
|
+
}
|
|
358
|
+
return out
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function rptCellRuns(cell) {
|
|
362
|
+
// rptParseMarkdown 已经给出 cellRuns;这里只兜底手工构造的 rows
|
|
363
|
+
if (Array.isArray(cell)) return cell
|
|
364
|
+
return rptParseInline(cell)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function rptTableHtml(rows, headerRow) {
|
|
368
|
+
const body = (rows || []).map(function (row, ri) {
|
|
369
|
+
const tag = headerRow && ri === 0 ? 'th' : 'td'
|
|
370
|
+
const cells = row.map(function (c) { return '<' + tag + '>' + rptRunsHtml(rptCellRuns(c)) + '</' + tag + '>' })
|
|
371
|
+
return '<tr>' + cells.join('') + '</tr>'
|
|
372
|
+
})
|
|
373
|
+
if (body.length === 0) return ''
|
|
374
|
+
const head = headerRow && body.length > 0 ? '<thead>' + body[0] + '</thead>' : ''
|
|
375
|
+
const tail = (headerRow ? body.slice(1) : body).join('')
|
|
376
|
+
return '<table class="rpt-table">' + head + (tail ? '<tbody>' + tail + '</tbody>' : '') + '</table>'
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function rptMetaHtml(meta) {
|
|
380
|
+
const keys = meta ? Object.keys(meta) : []
|
|
381
|
+
if (keys.length === 0) return ''
|
|
382
|
+
const rows = keys.map(function (k) {
|
|
383
|
+
return '<tr><th>' + rptEscapeHtml(k) + '</th><td>' + rptEscapeHtml(String(meta[k])) + '</td></tr>'
|
|
384
|
+
})
|
|
385
|
+
return '<table class="rpt-meta"><tbody>' + rows.join('') + '</tbody></table>'
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function rptBlocksHtml(blocks) {
|
|
389
|
+
const out = []
|
|
390
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
391
|
+
const b = blocks[i]
|
|
392
|
+
if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3' || b.type === 'h4') {
|
|
393
|
+
out.push('<' + b.type + '>' + rptRunsHtml(b.runs) + '</' + b.type + '>')
|
|
394
|
+
continue
|
|
395
|
+
}
|
|
396
|
+
if (b.type === 'p') { out.push('<p>' + rptRunsHtml(b.runs) + '</p>'); continue }
|
|
397
|
+
if (b.type === 'quote') { out.push('<blockquote><p>' + rptRunsHtml(b.runs) + '</p></blockquote>'); continue }
|
|
398
|
+
if (b.type === 'code') {
|
|
399
|
+
out.push('<pre class="rpt-code"><code>' + rptEscapeHtml((b.lines || []).join('\n')) + '</code></pre>')
|
|
400
|
+
continue
|
|
401
|
+
}
|
|
402
|
+
if (b.type === 'table') {
|
|
403
|
+
out.push(rptTableHtml(b.cellRuns || b.rows || [], true))
|
|
404
|
+
continue
|
|
405
|
+
}
|
|
406
|
+
if (b.type === 'li' || b.type === 'oli') {
|
|
407
|
+
// 相邻同类列表项合并成一个 <ul>/<ol>,否则每项都会被迫套一层列表
|
|
408
|
+
const tag = b.type === 'oli' ? 'ol' : 'ul'
|
|
409
|
+
const items = []
|
|
410
|
+
let j = i
|
|
411
|
+
while (j < blocks.length && blocks[j].type === b.type) {
|
|
412
|
+
items.push('<li>' + rptRunsHtml(blocks[j].runs) + '</li>')
|
|
413
|
+
j++
|
|
414
|
+
}
|
|
415
|
+
out.push('<' + tag + ' class="rpt-list">' + items.join('') + '</' + tag + '>')
|
|
416
|
+
i = j - 1
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return out.join('\n')
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// markdown → 完整自包含 HTML(无外部资源,可直接进浏览器或另存为 .html)。
|
|
424
|
+
// 必须是完整文档而不是片段:交付时经常直接 base64 内联或写盘双击打开,
|
|
425
|
+
// 片段在那种场景下会因缺 <meta charset> 而把中文显示成乱码。
|
|
426
|
+
export function rptBuildHtml(options) {
|
|
427
|
+
const opts = options || {}
|
|
428
|
+
const title = String(opts.title == null ? '' : opts.title)
|
|
429
|
+
const meta = opts.meta && typeof opts.meta === 'object' ? opts.meta : null
|
|
430
|
+
const blocks = rptParseMarkdown(opts.markdown == null ? '' : opts.markdown)
|
|
431
|
+
const parts = [
|
|
432
|
+
'<!doctype html>',
|
|
433
|
+
'<html lang="zh-CN">',
|
|
434
|
+
'<head>',
|
|
435
|
+
'<meta charset="utf-8">',
|
|
436
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
437
|
+
'<title>' + rptEscapeHtml(title) + '</title>',
|
|
438
|
+
'<style>' + RPT_REPORT_STYLES + '</style>',
|
|
439
|
+
'</head>',
|
|
440
|
+
'<body>',
|
|
441
|
+
'<article class="rpt">',
|
|
442
|
+
'<h1 class="rpt-title">' + rptEscapeHtml(title) + '</h1>',
|
|
443
|
+
rptMetaHtml(meta),
|
|
444
|
+
rptBlocksHtml(blocks),
|
|
445
|
+
'</article>',
|
|
446
|
+
'</body>',
|
|
447
|
+
'</html>',
|
|
448
|
+
]
|
|
449
|
+
return parts.filter(function (p) { return p !== '' }).join('\n') + '\n'
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ── DOCX(OOXML)渲染 ───────────────────────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
// 链接关系表:同一 URL 只建一条关系(Word 对重复 rId 目标不报错,但产物会无谓膨胀)。
|
|
455
|
+
// links 是 { id, target } 数组,渲染 document.xml 时按需追加。
|
|
456
|
+
function rptLinkId(links, target) {
|
|
457
|
+
for (const l of links) if (l.target === target) return l.id
|
|
458
|
+
const id = 'rId' + (links.length + RPT_FIRST_LINK_RID)
|
|
459
|
+
links.push({ id: id, target: target })
|
|
460
|
+
return id
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function rptDocxRun(run, links, mono) {
|
|
464
|
+
const r = run || {}
|
|
465
|
+
const rPr = []
|
|
466
|
+
if (mono || r.code) rPr.push('<w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/>')
|
|
467
|
+
if (r.bold) rPr.push('<w:b/>')
|
|
468
|
+
if (r.href) rPr.push('<w:color w:val="0563C1"/><w:u w:val="single"/>')
|
|
469
|
+
const text = '<w:t xml:space="preserve">' + rptXmlEscape(r.text == null ? '' : r.text) + '</w:t>'
|
|
470
|
+
const inner = '<w:r>' + (rPr.length ? '<w:rPr>' + rPr.join('') + '</w:rPr>' : '') + text + '</w:r>'
|
|
471
|
+
if (r.href) {
|
|
472
|
+
const id = rptLinkId(links, r.href)
|
|
473
|
+
return '<w:hyperlink r:id="' + id + '">' + inner + '</w:hyperlink>'
|
|
474
|
+
}
|
|
475
|
+
return inner
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function rptDocxRuns(runs, links, mono) {
|
|
479
|
+
let out = ''
|
|
480
|
+
for (const r of runs || []) out += rptDocxRun(r, links, mono)
|
|
481
|
+
return out
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function rptDocxPara(style, runs, links) {
|
|
485
|
+
const body = rptDocxRuns(runs, links, false)
|
|
486
|
+
const pPr = style ? '<w:pPr><w:pStyle w:val="' + style + '"/></w:pPr>' : ''
|
|
487
|
+
if (body === '') return '<w:p>' + pPr + '</w:p>'
|
|
488
|
+
return '<w:p>' + pPr + body + '</w:p>'
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// 代码块:每行一个等宽段落。用段落而不是 <w:br/>,因为报告里的行号/缩进对齐
|
|
492
|
+
// 在段落下更稳,且复制到别处时仍是按行的。
|
|
493
|
+
function rptDocxCode(lines, links) {
|
|
494
|
+
const out = []
|
|
495
|
+
const src = lines && lines.length ? lines : ['']
|
|
496
|
+
for (const line of src) {
|
|
497
|
+
const run = line === '' ? '' : rptDocxRun({ text: line }, links, true)
|
|
498
|
+
out.push('<w:p><w:pPr><w:pStyle w:val="Code"/></w:pPr>' + run + '</w:p>')
|
|
499
|
+
}
|
|
500
|
+
return out.join('')
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const RPT_TBL_BORDERS =
|
|
504
|
+
'<w:tblBorders>' +
|
|
505
|
+
'<w:top w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
506
|
+
'<w:left w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
507
|
+
'<w:bottom w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
508
|
+
'<w:right w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
509
|
+
'<w:insideH w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
510
|
+
'<w:insideV w:val="single" w:sz="4" w:space="0" w:color="999999"/>' +
|
|
511
|
+
'</w:tblBorders>'
|
|
512
|
+
|
|
513
|
+
function rptDocxTable(rows, links, headerBold, totalWidth) {
|
|
514
|
+
const src = rows || []
|
|
515
|
+
if (src.length === 0) return ''
|
|
516
|
+
let cols = 0
|
|
517
|
+
for (const r of src) if (r.length > cols) cols = r.length
|
|
518
|
+
if (cols === 0) return ''
|
|
519
|
+
const colW = Math.floor((totalWidth || 9000) / cols)
|
|
520
|
+
const grid = []
|
|
521
|
+
for (let c = 0; c < cols; c++) grid.push('<w:gridCol w:w="' + colW + '"/>')
|
|
522
|
+
const trs = []
|
|
523
|
+
for (let ri = 0; ri < src.length; ri++) {
|
|
524
|
+
const row = src[ri]
|
|
525
|
+
const tcs = []
|
|
526
|
+
for (let ci = 0; ci < cols; ci++) {
|
|
527
|
+
const cell = row[ci]
|
|
528
|
+
let runs = rptCellRuns(cell === undefined ? '' : cell)
|
|
529
|
+
if (headerBold && ri === 0) runs = runs.map(function (r) { return { text: r.text, bold: true, code: r.code, href: r.href } })
|
|
530
|
+
// 单元格至少要有一个块级元素,空 <w:tc/> 会让 Word 判为损坏
|
|
531
|
+
const para = '<w:p>' + rptDocxRuns(runs, links, false) + '</w:p>'
|
|
532
|
+
tcs.push('<w:tc><w:tcPr><w:tcW w:w="' + colW + '" w:type="dxa"/></w:tcPr>' + para + '</w:tc>')
|
|
533
|
+
}
|
|
534
|
+
trs.push('<w:tr>' + tcs.join('') + '</w:tr>')
|
|
535
|
+
}
|
|
536
|
+
return '<w:tbl><w:tblPr><w:tblW w:w="' + (totalWidth || 9000) + '" w:type="dxa"/>' + RPT_TBL_BORDERS +
|
|
537
|
+
'</w:tblPr><w:tblGrid>' + grid.join('') + '</w:tblGrid>' + trs.join('') + '</w:tbl>'
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// styles.xml 里所有 w:pStyle 引用到的样式都必须在这里有定义,
|
|
541
|
+
// 否则 Word 会按「样式不存在」处理(内容还在,但版式静默丢失)。
|
|
542
|
+
const RPT_STYLES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
543
|
+
'<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">' +
|
|
544
|
+
'<w:docDefaults><w:rPrDefault><w:rPr>' +
|
|
545
|
+
'<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/><w:sz w:val="21"/><w:szCs w:val="21"/>' +
|
|
546
|
+
'</w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="120" w:line="320" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>' +
|
|
547
|
+
'<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/></w:style>' +
|
|
548
|
+
'<w:style w:type="paragraph" w:styleId="Title"><w:name w:val="Title"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
|
|
549
|
+
'<w:pPr><w:jc w:val="center"/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="44"/><w:szCs w:val="44"/></w:rPr></w:style>' +
|
|
550
|
+
'<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
|
|
551
|
+
'<w:pPr><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:b/><w:sz w:val="32"/><w:szCs w:val="32"/></w:rPr></w:style>' +
|
|
552
|
+
'<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
|
|
553
|
+
'<w:pPr><w:outlineLvl w:val="1"/></w:pPr><w:rPr><w:b/><w:sz w:val="28"/><w:szCs w:val="28"/></w:rPr></w:style>' +
|
|
554
|
+
'<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
|
|
555
|
+
'<w:pPr><w:outlineLvl w:val="2"/></w:pPr><w:rPr><w:b/><w:sz w:val="24"/><w:szCs w:val="24"/></w:rPr></w:style>' +
|
|
556
|
+
'<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Normal"/><w:qFormat/>' +
|
|
557
|
+
'<w:pPr><w:outlineLvl w:val="3"/></w:pPr><w:rPr><w:b/><w:sz w:val="22"/><w:szCs w:val="22"/></w:rPr></w:style>' +
|
|
558
|
+
'<w:style w:type="paragraph" w:styleId="Code"><w:name w:val="Report Code"/><w:basedOn w:val="Normal"/>' +
|
|
559
|
+
'<w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/><w:shd w:val="clear" w:color="auto" w:fill="F5F5F5"/></w:pPr>' +
|
|
560
|
+
'<w:rPr><w:rFonts w:ascii="Consolas" w:hAnsi="Consolas"/><w:sz w:val="18"/><w:szCs w:val="18"/></w:rPr></w:style>' +
|
|
561
|
+
'<w:style w:type="paragraph" w:styleId="Quote"><w:name w:val="Report Quote"/><w:basedOn w:val="Normal"/>' +
|
|
562
|
+
'<w:pPr><w:ind w:left="420"/></w:pPr><w:rPr><w:i/><w:color w:val="4B5563"/></w:rPr></w:style>' +
|
|
563
|
+
'</w:styles>'
|
|
564
|
+
|
|
565
|
+
const RPT_CONTENT_TYPES_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
566
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' +
|
|
567
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' +
|
|
568
|
+
'<Default Extension="xml" ContentType="application/xml"/>' +
|
|
569
|
+
'<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>' +
|
|
570
|
+
'<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>' +
|
|
571
|
+
'</Types>'
|
|
572
|
+
|
|
573
|
+
const RPT_ROOT_RELS_XML = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
574
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
575
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>' +
|
|
576
|
+
'</Relationships>'
|
|
577
|
+
|
|
578
|
+
const RPT_DOC_RELS_HEAD = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
579
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' +
|
|
580
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>'
|
|
581
|
+
|
|
582
|
+
// 文档级关系:正文里的每个外链都要在这里登记一条 TargetMode="External" 的关系,
|
|
583
|
+
// 否则 <w:hyperlink r:id> 指向不存在的 rId,Word 会丢掉链接(或被判为损坏)。
|
|
584
|
+
function rptDocRelsXml(links) {
|
|
585
|
+
let out = RPT_DOC_RELS_HEAD
|
|
586
|
+
for (const l of links) {
|
|
587
|
+
out += '<Relationship Id="' + l.id + '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="' +
|
|
588
|
+
rptXmlEscape(l.target) + '" TargetMode="External"/>'
|
|
589
|
+
}
|
|
590
|
+
return out + '</Relationships>'
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const RPT_SECT_PR =
|
|
594
|
+
'<w:sectPr><w:pgSz w:w="11906" w:h="16838"/>' +
|
|
595
|
+
'<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="851" w:footer="992" w:gutter="0"/>' +
|
|
596
|
+
'</w:sectPr>'
|
|
597
|
+
|
|
598
|
+
// markdown → 真·Word .docx 字节流。返回 Uint8Array(未压缩 ZIP,纯 JS 写)。
|
|
599
|
+
export function rptBuildDocx(options) {
|
|
600
|
+
const opts = options || {}
|
|
601
|
+
const title = String(opts.title == null ? '' : opts.title)
|
|
602
|
+
const meta = opts.meta && typeof opts.meta === 'object' ? opts.meta : null
|
|
603
|
+
const blocks = rptParseMarkdown(opts.markdown == null ? '' : opts.markdown)
|
|
604
|
+
const links = []
|
|
605
|
+
const parts = []
|
|
606
|
+
|
|
607
|
+
if (title !== '') parts.push(rptDocxPara('Title', [{ text: title }], links))
|
|
608
|
+
|
|
609
|
+
if (meta) {
|
|
610
|
+
const keys = Object.keys(meta)
|
|
611
|
+
if (keys.length > 0) {
|
|
612
|
+
const rows = keys.map(function (k) { return [k, String(meta[k])] })
|
|
613
|
+
parts.push(rptDocxTable(rows, links, true, 9000))
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let listIndex = 0
|
|
618
|
+
let prevType = ''
|
|
619
|
+
let lastWasTable = false
|
|
620
|
+
for (const b of blocks) {
|
|
621
|
+
if (b.type !== prevType) listIndex = 0
|
|
622
|
+
prevType = b.type
|
|
623
|
+
if (b.type === 'h1' || b.type === 'h2' || b.type === 'h3' || b.type === 'h4') {
|
|
624
|
+
parts.push(rptDocxPara('Heading' + b.type.slice(1), b.runs, links))
|
|
625
|
+
} else if (b.type === 'p') {
|
|
626
|
+
parts.push(rptDocxPara('Normal', b.runs, links))
|
|
627
|
+
} else if (b.type === 'quote') {
|
|
628
|
+
parts.push(rptDocxPara('Quote', b.runs, links))
|
|
629
|
+
} else if (b.type === 'li') {
|
|
630
|
+
listIndex++
|
|
631
|
+
// 真项目符号要 numbering.xml + 额外的 content-type/关系,为一份交付用报告不值得;
|
|
632
|
+
// 直接把符号写进文本,Word 里看起来一样。
|
|
633
|
+
parts.push(rptDocxPara('Normal', [{ text: '• ' }].concat(b.runs), links))
|
|
634
|
+
} else if (b.type === 'oli') {
|
|
635
|
+
listIndex++
|
|
636
|
+
parts.push(rptDocxPara('Normal', [{ text: listIndex + '. ' }].concat(b.runs), links))
|
|
637
|
+
} else if (b.type === 'code') {
|
|
638
|
+
parts.push(rptDocxCode(b.lines || [], links))
|
|
639
|
+
} else if (b.type === 'table') {
|
|
640
|
+
parts.push(rptDocxTable(b.cellRuns || b.rows || [], links, true, 9000))
|
|
641
|
+
}
|
|
642
|
+
lastWasTable = b.type === 'table'
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// OOXML 规定正文最后一个块不能是表格(表格后必须跟段落,否则 Word 判损坏)
|
|
646
|
+
if (lastWasTable) parts.push('<w:p/>')
|
|
647
|
+
|
|
648
|
+
const documentXml = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
|
|
649
|
+
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" ' +
|
|
650
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
|
|
651
|
+
'<w:body>' + parts.join('') + RPT_SECT_PR + '</w:body></w:document>'
|
|
652
|
+
|
|
653
|
+
return rptZipBuild([
|
|
654
|
+
{ name: '[Content_Types].xml', data: rptUtf8Encode(RPT_CONTENT_TYPES_XML) },
|
|
655
|
+
{ name: '_rels/.rels', data: rptUtf8Encode(RPT_ROOT_RELS_XML) },
|
|
656
|
+
{ name: 'word/_rels/document.xml.rels', data: rptUtf8Encode(rptDocRelsXml(links)) },
|
|
657
|
+
{ name: 'word/document.xml', data: rptUtf8Encode(documentXml) },
|
|
658
|
+
{ name: 'word/styles.xml', data: rptUtf8Encode(RPT_STYLES_XML) },
|
|
659
|
+
])
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── ZIP(stored,无压缩) ───────────────────────────────────────────────────
|
|
663
|
+
|
|
664
|
+
// 表驱动 CRC-32(IEEE 反射多项式 0xEDB88320)。表只建一次,报告里常有几十个条目。
|
|
665
|
+
let rptCrcTable = null
|
|
666
|
+
function rptCrc32(bytes) {
|
|
667
|
+
if (rptCrcTable === null) {
|
|
668
|
+
const t = []
|
|
669
|
+
for (let n = 0; n < 256; n++) {
|
|
670
|
+
let c = n
|
|
671
|
+
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1)
|
|
672
|
+
t.push(c >>> 0)
|
|
673
|
+
}
|
|
674
|
+
rptCrcTable = t
|
|
675
|
+
}
|
|
676
|
+
let crc = 0xffffffff
|
|
677
|
+
for (let i = 0; i < bytes.length; i++) crc = rptCrcTable[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8)
|
|
678
|
+
return (crc ^ 0xffffffff) >>> 0
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ZIP 里所有多字节整数都是小端
|
|
682
|
+
function rptPutU16(arr, at, value) {
|
|
683
|
+
arr[at] = value & 0xff
|
|
684
|
+
arr[at + 1] = (value >>> 8) & 0xff
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function rptPutU32(arr, at, value) {
|
|
688
|
+
arr[at] = value & 0xff
|
|
689
|
+
arr[at + 1] = (value >>> 8) & 0xff
|
|
690
|
+
arr[at + 2] = (value >>> 16) & 0xff
|
|
691
|
+
arr[at + 3] = (value >>> 24) & 0xff
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// flag bit 11 = 文件名为 UTF-8。本模块的文件名全是 ASCII,设上无副作用,
|
|
695
|
+
// 但万一以后加了中文部件名,这里不用再改。
|
|
696
|
+
const RPT_ZIP_FLAG = 0x0800
|
|
697
|
+
|
|
698
|
+
function rptZipLocalHeader(nameBytes, crc, size) {
|
|
699
|
+
const h = new Uint8Array(30 + nameBytes.length)
|
|
700
|
+
rptPutU32(h, 0, 0x04034b50)
|
|
701
|
+
rptPutU16(h, 4, 20) // version needed
|
|
702
|
+
rptPutU16(h, 6, RPT_ZIP_FLAG)
|
|
703
|
+
rptPutU16(h, 8, 0) // method 0 = stored
|
|
704
|
+
rptPutU16(h, 10, RPT_DOS_TIME)
|
|
705
|
+
rptPutU16(h, 12, RPT_DOS_DATE)
|
|
706
|
+
rptPutU32(h, 14, crc)
|
|
707
|
+
rptPutU32(h, 18, size) // compressed size == uncompressed size(stored)
|
|
708
|
+
rptPutU32(h, 22, size)
|
|
709
|
+
rptPutU16(h, 26, nameBytes.length)
|
|
710
|
+
rptPutU16(h, 28, 0) // extra field length
|
|
711
|
+
h.set(nameBytes, 30)
|
|
712
|
+
return h
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function rptZipCentralHeader(nameBytes, crc, size, offset) {
|
|
716
|
+
const h = new Uint8Array(46 + nameBytes.length)
|
|
717
|
+
rptPutU32(h, 0, 0x02014b50)
|
|
718
|
+
rptPutU16(h, 4, 20) // version made by(MS-DOS / 2.0)
|
|
719
|
+
rptPutU16(h, 6, 20) // version needed
|
|
720
|
+
rptPutU16(h, 8, RPT_ZIP_FLAG)
|
|
721
|
+
rptPutU16(h, 10, 0)
|
|
722
|
+
rptPutU16(h, 12, RPT_DOS_TIME)
|
|
723
|
+
rptPutU16(h, 14, RPT_DOS_DATE)
|
|
724
|
+
rptPutU32(h, 16, crc)
|
|
725
|
+
rptPutU32(h, 20, size)
|
|
726
|
+
rptPutU32(h, 24, size)
|
|
727
|
+
rptPutU16(h, 28, nameBytes.length)
|
|
728
|
+
rptPutU16(h, 30, 0) // extra
|
|
729
|
+
rptPutU16(h, 32, 0) // comment
|
|
730
|
+
rptPutU16(h, 34, 0) // disk number start
|
|
731
|
+
rptPutU16(h, 36, 0) // internal attrs
|
|
732
|
+
rptPutU32(h, 38, 0) // external attrs
|
|
733
|
+
rptPutU32(h, 42, offset) // 本条目 local header 的偏移
|
|
734
|
+
h.set(nameBytes, 46)
|
|
735
|
+
return h
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function rptZipEocd(count, cdSize, cdOffset) {
|
|
739
|
+
const h = new Uint8Array(22)
|
|
740
|
+
rptPutU32(h, 0, 0x06054b50)
|
|
741
|
+
rptPutU16(h, 4, 0) // 本磁盘号
|
|
742
|
+
rptPutU16(h, 6, 0) // 中央目录起始磁盘号
|
|
743
|
+
rptPutU16(h, 8, count) // 本磁盘条目数
|
|
744
|
+
rptPutU16(h, 10, count) // 总条目数
|
|
745
|
+
rptPutU32(h, 12, cdSize)
|
|
746
|
+
rptPutU32(h, 16, cdOffset)
|
|
747
|
+
rptPutU16(h, 20, 0) // 注释长度
|
|
748
|
+
return h
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// entries: [{ name, data: Uint8Array }] → 完整 ZIP 字节流。
|
|
752
|
+
// 故意用 stored:报告产物只有几十 KB,压缩省不下多少,却要多一条 inflate 实现与
|
|
753
|
+
// 一大类「解压出来不对」的失败面。真需要压缩时应交给调用方而不是这里。
|
|
754
|
+
function rptZipBuild(entries) {
|
|
755
|
+
const local = []
|
|
756
|
+
const central = []
|
|
757
|
+
let offset = 0
|
|
758
|
+
for (const e of entries) {
|
|
759
|
+
const nameBytes = rptUtf8Encode(e.name)
|
|
760
|
+
const data = e.data
|
|
761
|
+
const crc = rptCrc32(data)
|
|
762
|
+
const head = rptZipLocalHeader(nameBytes, crc, data.length)
|
|
763
|
+
local.push(head, data)
|
|
764
|
+
central.push(rptZipCentralHeader(nameBytes, crc, data.length, offset))
|
|
765
|
+
offset += head.length + data.length
|
|
766
|
+
}
|
|
767
|
+
let cdSize = 0
|
|
768
|
+
for (const c of central) cdSize += c.length
|
|
769
|
+
const chunks = local.concat(central, [rptZipEocd(entries.length, cdSize, offset)])
|
|
770
|
+
let total = 0
|
|
771
|
+
for (const c of chunks) total += c.length
|
|
772
|
+
const out = new Uint8Array(total)
|
|
773
|
+
let pos = 0
|
|
774
|
+
for (const c of chunks) { out.set(c, pos); pos += c.length }
|
|
775
|
+
return out
|
|
776
|
+
}
|