@xiaobuyu/nodesign 0.0.1 → 0.0.2
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/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +22 -22
- package/server/api/assets/notes.js +144 -144
- package/server/api/assets.js +896 -896
- package/server/api/board.js +57 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +464 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1024 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +339 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +155 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +103 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +110 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
- package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +60 -60
- package/server/lib/board-kind-sizes.js +62 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-store.js +640 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +54 -54
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +36 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
- package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
- package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
- package/web/dist/index.html +17 -17
- package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
package/server/lib/docx/build.js
CHANGED
|
@@ -1,490 +1,490 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* build.js — token JSON + 内容块 → 完整 docx(Buffer)。
|
|
3
|
-
*
|
|
4
|
-
* 所有属性容器(pPr/rPr/sectPr/style/…)都过 order.sortChildren(),
|
|
5
|
-
* 出厂前可用 order.checkOrder() 体检;对外证据是 OpenXmlValidator 0 错。
|
|
6
|
-
*
|
|
7
|
-
* 内容块模型(generation 路线的雏形,编辑路线不走这里):
|
|
8
|
-
* {t:'p', style?, blocks 见 buildPara} 段落
|
|
9
|
-
* {t:'table', widthsTwip:[..], rows:[[cell]]} 表格;cell = string | {text, style?}
|
|
10
|
-
* {t:'pageBreak'}
|
|
11
|
-
* 段落 runs:string | {text, bold?, italic?, color?, font?, sizePt?, em?, underline?}
|
|
12
|
-
* | {fld:'PAGE'|'NUMPAGES'}
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { elem, textNode, serializeNode } from './xml.js';
|
|
16
|
-
import { sortChildren } from './order.js';
|
|
17
|
-
import { ptToHalf, ptToTwip, sizePt as toPt, PAGE_SIZES } from './units.js';
|
|
18
|
-
import { buildNumberingXml, numIdsFor } from './numbering.js';
|
|
19
|
-
|
|
20
|
-
const W = 'xmlns:w';
|
|
21
|
-
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
22
|
-
const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
23
|
-
const DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
24
|
-
|
|
25
|
-
/* ── 低层小件 ─────────────────────────────────── */
|
|
26
|
-
|
|
27
|
-
function val(name, v) { return elem(name, [['w:val', v]]); }
|
|
28
|
-
function flag(name, on) { return on === false ? elem(name, [['w:val', '0']]) : elem(name); }
|
|
29
|
-
|
|
30
|
-
function resolveFont(tokens, ref) {
|
|
31
|
-
if (ref == null) return null;
|
|
32
|
-
const f = typeof ref === 'string' ? tokens.fonts?.[ref] : ref;
|
|
33
|
-
if (!f) throw new Error(`unknown font slot: ${ref}`);
|
|
34
|
-
return f;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function rFontsEl(f) {
|
|
38
|
-
const attrs = [];
|
|
39
|
-
if (f.ascii) attrs.push(['w:ascii', f.ascii]);
|
|
40
|
-
if (f.eastAsia) attrs.push(['w:eastAsia', f.eastAsia]);
|
|
41
|
-
attrs.push(['w:hAnsi', f.hAnsi ?? f.ascii ?? f.eastAsia]);
|
|
42
|
-
if (f.cs ?? f.ascii) attrs.push(['w:cs', f.cs ?? f.ascii]);
|
|
43
|
-
return elem('w:rFonts', attrs);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** run token → w:rPr(无内容时返回 null) */
|
|
47
|
-
export function buildRPr(tokens, run, { forStyle = false } = {}) {
|
|
48
|
-
if (!run) return null;
|
|
49
|
-
const kids = [];
|
|
50
|
-
const f = resolveFont(tokens, run.font);
|
|
51
|
-
if (f) kids.push(rFontsEl(f));
|
|
52
|
-
if (run.bold != null) kids.push(flag('w:b', run.bold));
|
|
53
|
-
if (run.italic != null) kids.push(flag('w:i', run.italic));
|
|
54
|
-
if (run.caps) kids.push(elem('w:caps'));
|
|
55
|
-
if (run.smallCaps) kids.push(elem('w:smallCaps'));
|
|
56
|
-
if (run.strike) kids.push(elem('w:strike'));
|
|
57
|
-
if (run.color) kids.push(val('w:color', run.color));
|
|
58
|
-
if (run.spacingTwip != null) kids.push(val('w:spacing', run.spacingTwip));
|
|
59
|
-
if (run.kernPt != null) kids.push(val('w:kern', ptToHalf(run.kernPt)));
|
|
60
|
-
if (run.sizePt != null) {
|
|
61
|
-
kids.push(val('w:sz', ptToHalf(toPt(run.sizePt))));
|
|
62
|
-
kids.push(val('w:szCs', ptToHalf(toPt(run.sizePt))));
|
|
63
|
-
}
|
|
64
|
-
if (run.highlight) kids.push(val('w:highlight', run.highlight));
|
|
65
|
-
if (run.underline) kids.push(val('w:u', run.underline === true ? 'single' : run.underline));
|
|
66
|
-
if (run.vertAlign) kids.push(val('w:vertAlign', run.vertAlign));
|
|
67
|
-
if (run.em) kids.push(val('w:em', run.em));
|
|
68
|
-
if (!kids.length) return null;
|
|
69
|
-
return sortChildren(elem('w:rPr', [], kids));
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const CJK_FLAGS = [
|
|
73
|
-
['kinsoku', 'w:kinsoku'], ['wordWrap', 'w:wordWrap'], ['overflowPunct', 'w:overflowPunct'],
|
|
74
|
-
['autoSpaceDE', 'w:autoSpaceDE'], ['autoSpaceDN', 'w:autoSpaceDN'],
|
|
75
|
-
['adjustRightInd', 'w:adjustRightInd'], ['snapToGrid', 'w:snapToGrid'],
|
|
76
|
-
];
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* 沿 basedOn 链算风格的有效字号(磅)。块级 run 覆盖 > 风格链 > base。
|
|
80
|
-
*/
|
|
81
|
-
export function effectiveSizePt(tokens, styleId, runSizePt) {
|
|
82
|
-
if (runSizePt != null) return toPt(runSizePt);
|
|
83
|
-
let id = styleId;
|
|
84
|
-
const seen = new Set();
|
|
85
|
-
while (id && tokens.styles?.[id] && !seen.has(id)) {
|
|
86
|
-
seen.add(id);
|
|
87
|
-
const st = tokens.styles[id];
|
|
88
|
-
if (st.run?.sizePt != null) return toPt(st.run.sizePt);
|
|
89
|
-
id = st.basedOn;
|
|
90
|
-
}
|
|
91
|
-
return toPt(tokens.base?.sizePt ?? 12);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* 【实测军规】LO 25.2 无视单独的 w:firstLineChars/leftChars(lab/02 A/B:
|
|
96
|
-
* chars-only 缩进 0pt,chars+twip 精确 32pt)。Word 里 chars 优先于 twip。
|
|
97
|
-
* 所以字符单位缩进一律双发:chars(Word 真语义,随字号缩放)+ 按有效字号
|
|
98
|
-
* 算好的 twip 兜底(LO/QC 与旧阅读器用)。
|
|
99
|
-
*/
|
|
100
|
-
function indentAttrs(i, sizePt) {
|
|
101
|
-
const attrs = [];
|
|
102
|
-
const chars2twip = (chars) => Math.round((chars / 100) * sizePt * 20);
|
|
103
|
-
if (i.leftChars != null) {
|
|
104
|
-
attrs.push(['w:left', i.leftTwip ?? chars2twip(i.leftChars)]);
|
|
105
|
-
attrs.push(['w:leftChars', i.leftChars]);
|
|
106
|
-
} else if (i.leftTwip != null) attrs.push(['w:left', i.leftTwip]);
|
|
107
|
-
if (i.rightChars != null) {
|
|
108
|
-
attrs.push(['w:right', i.rightTwip ?? chars2twip(i.rightChars)]);
|
|
109
|
-
attrs.push(['w:rightChars', i.rightChars]);
|
|
110
|
-
} else if (i.rightTwip != null) attrs.push(['w:right', i.rightTwip]);
|
|
111
|
-
if (i.hangingChars != null) {
|
|
112
|
-
attrs.push(['w:hanging', i.hangingTwip ?? chars2twip(i.hangingChars)]);
|
|
113
|
-
attrs.push(['w:hangingChars', i.hangingChars]);
|
|
114
|
-
} else if (i.hangingTwip != null) attrs.push(['w:hanging', i.hangingTwip]);
|
|
115
|
-
if (i.firstLineChars != null) {
|
|
116
|
-
attrs.push(['w:firstLine', i.firstLineTwip ?? chars2twip(i.firstLineChars)]);
|
|
117
|
-
attrs.push(['w:firstLineChars', i.firstLineChars]);
|
|
118
|
-
} else if (i.firstLineTwip != null) attrs.push(['w:firstLine', i.firstLineTwip]);
|
|
119
|
-
return attrs;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** para token → w:pPr 子元素数组(不含 pStyle/rPr,调用方拼)
|
|
123
|
-
* ctx.sizePt = 本段有效字号,字符单位缩进换算 twip 兜底用 */
|
|
124
|
-
function paraProps(para, ctx = { sizePt: 12 }) {
|
|
125
|
-
const kids = [];
|
|
126
|
-
if (!para) return kids;
|
|
127
|
-
if (para.keepNext) kids.push(elem('w:keepNext'));
|
|
128
|
-
if (para.keepLines) kids.push(elem('w:keepLines'));
|
|
129
|
-
if (para.pageBreakBefore) kids.push(elem('w:pageBreakBefore'));
|
|
130
|
-
if (para.widowControl != null) kids.push(flag('w:widowControl', para.widowControl));
|
|
131
|
-
if (para.list) {
|
|
132
|
-
// agent 按**名字**引用编号(`list: {name:'条款', ilvl:1}` 或简写 `list:'条款'`),
|
|
133
|
-
// numId 是 OOXML 的实现细节,这里现算。名字对不上是 token 校验的活,
|
|
134
|
-
// 到这儿只可能是内部不一致,宁可产一个显式错误也别产一个悬空 numId。
|
|
135
|
-
const spec = typeof para.list === 'string' ? { name: para.list } : para.list;
|
|
136
|
-
const numId = ctx.numIds?.get(spec.name);
|
|
137
|
-
if (numId == null) throw new Error(`para.list: 没有名为 '${spec.name}' 的编号定义`);
|
|
138
|
-
kids.push(elem('w:numPr', [], [val('w:ilvl', spec.ilvl ?? 0), val('w:numId', numId)]));
|
|
139
|
-
}
|
|
140
|
-
if (para.borders) {
|
|
141
|
-
const sides = [];
|
|
142
|
-
for (const side of ['top', 'left', 'bottom', 'right']) {
|
|
143
|
-
const b = para.borders[side];
|
|
144
|
-
if (!b) continue;
|
|
145
|
-
sides.push(elem(`w:${side}`, [
|
|
146
|
-
['w:val', b.style ?? 'single'], ['w:sz', b.sizePt8 ?? 4],
|
|
147
|
-
['w:space', b.spacePt ?? 0], ['w:color', b.color ?? '000000'],
|
|
148
|
-
]));
|
|
149
|
-
}
|
|
150
|
-
if (sides.length) kids.push(sortChildren(elem('w:pBdr', [], sides)));
|
|
151
|
-
}
|
|
152
|
-
if (para.shading) {
|
|
153
|
-
kids.push(elem('w:shd', [['w:val', 'clear'], ['w:color', 'auto'], ['w:fill', para.shading]]));
|
|
154
|
-
}
|
|
155
|
-
if (para.tabs?.length) {
|
|
156
|
-
kids.push(elem('w:tabs', [], para.tabs.map((t) => elem('w:tab', [
|
|
157
|
-
['w:val', t.val ?? 'left'], ...(t.leader ? [['w:leader', t.leader]] : []), ['w:pos', t.pos],
|
|
158
|
-
]))));
|
|
159
|
-
}
|
|
160
|
-
for (const [key, name] of CJK_FLAGS) {
|
|
161
|
-
if (para.cjk && para.cjk[key] != null) kids.push(flag(name, para.cjk[key]));
|
|
162
|
-
}
|
|
163
|
-
if (para.spacing) {
|
|
164
|
-
const s = para.spacing;
|
|
165
|
-
const attrs = [];
|
|
166
|
-
if (s.beforePt != null) attrs.push(['w:before', ptToTwip(s.beforePt)]);
|
|
167
|
-
if (s.beforeLines != null) attrs.push(['w:beforeLines', s.beforeLines]);
|
|
168
|
-
if (s.afterPt != null) attrs.push(['w:after', ptToTwip(s.afterPt)]);
|
|
169
|
-
if (s.afterLines != null) attrs.push(['w:afterLines', s.afterLines]);
|
|
170
|
-
if (s.line != null) {
|
|
171
|
-
const rule = s.lineRule ?? 'multiple';
|
|
172
|
-
attrs.push(['w:line', rule === 'multiple' ? Math.round(s.line * 240) : ptToTwip(s.line)]);
|
|
173
|
-
attrs.push(['w:lineRule', rule === 'multiple' ? 'auto' : rule]);
|
|
174
|
-
}
|
|
175
|
-
if (attrs.length) kids.push(elem('w:spacing', attrs));
|
|
176
|
-
}
|
|
177
|
-
if (para.indent) {
|
|
178
|
-
const attrs = indentAttrs(para.indent, ctx.sizePt);
|
|
179
|
-
if (attrs.length) kids.push(elem('w:ind', attrs));
|
|
180
|
-
}
|
|
181
|
-
if (para.contextualSpacing) kids.push(elem('w:contextualSpacing'));
|
|
182
|
-
if (para.align) kids.push(val('w:jc', para.align));
|
|
183
|
-
if (para.outlineLevel != null) kids.push(val('w:outlineLvl', para.outlineLevel));
|
|
184
|
-
return kids;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/* ── styles.xml ───────────────────────────────── */
|
|
188
|
-
|
|
189
|
-
export function buildStylesXml(tokens) {
|
|
190
|
-
const base = tokens.base ?? {};
|
|
191
|
-
const rDef = [];
|
|
192
|
-
const bf = resolveFont(tokens, base.font ?? 'body');
|
|
193
|
-
if (bf) rDef.push(rFontsEl(bf));
|
|
194
|
-
if (base.kernPt != null) rDef.push(val('w:kern', ptToHalf(base.kernPt)));
|
|
195
|
-
if (base.sizePt != null) {
|
|
196
|
-
rDef.push(val('w:sz', ptToHalf(toPt(base.sizePt))));
|
|
197
|
-
rDef.push(val('w:szCs', ptToHalf(toPt(base.sizePt))));
|
|
198
|
-
}
|
|
199
|
-
if (base.color && base.color !== '000000') rDef.push(val('w:color', base.color));
|
|
200
|
-
if (tokens.lang) {
|
|
201
|
-
rDef.push(elem('w:lang', [
|
|
202
|
-
['w:val', tokens.lang.latin ?? 'en-US'],
|
|
203
|
-
['w:eastAsia', tokens.lang.eastAsia ?? 'zh-CN'],
|
|
204
|
-
['w:bidi', 'ar-SA'],
|
|
205
|
-
]));
|
|
206
|
-
}
|
|
207
|
-
const pDefKids = [];
|
|
208
|
-
for (const [key, name] of CJK_FLAGS) {
|
|
209
|
-
if (base.cjk && base.cjk[key] != null) pDefKids.push(flag(name, base.cjk[key]));
|
|
210
|
-
}
|
|
211
|
-
if (base.spacing) pDefKids.push(...paraProps({ spacing: base.spacing }));
|
|
212
|
-
|
|
213
|
-
const styleEls = [];
|
|
214
|
-
for (const [id, st] of Object.entries(tokens.styles ?? {})) {
|
|
215
|
-
const kids = [val('w:name', st.name ?? id)];
|
|
216
|
-
if (st.basedOn) kids.push(val('w:basedOn', st.basedOn));
|
|
217
|
-
if (st.next) kids.push(val('w:next', st.next));
|
|
218
|
-
if (st.link) kids.push(val('w:link', st.link));
|
|
219
|
-
if (st.uiPriority != null) kids.push(val('w:uiPriority', st.uiPriority));
|
|
220
|
-
if (st.qFormat) kids.push(elem('w:qFormat'));
|
|
221
|
-
const pKids = paraProps(st.para, { sizePt: effectiveSizePt(tokens, id), numIds: numIdsFor(tokens) });
|
|
222
|
-
if (pKids.length) kids.push(sortChildren(elem('w:pPr', [], pKids)));
|
|
223
|
-
const rPr = buildRPr(tokens, st.run, { forStyle: true });
|
|
224
|
-
if (rPr) kids.push(rPr);
|
|
225
|
-
const el = sortChildren(elem('w:style', [['w:type', st.type ?? 'paragraph'], ['w:styleId', id]], kids));
|
|
226
|
-
styleEls.push(el);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const root = elem('w:styles', [[W, NS_W]], [
|
|
230
|
-
elem('w:docDefaults', [], [
|
|
231
|
-
elem('w:rPrDefault', [], rDef.length ? [sortChildren(elem('w:rPr', [], rDef))] : []),
|
|
232
|
-
elem('w:pPrDefault', [], pDefKids.length ? [sortChildren(elem('w:pPr', [], pDefKids))] : []),
|
|
233
|
-
]),
|
|
234
|
-
...styleEls,
|
|
235
|
-
]);
|
|
236
|
-
return DECL + serializeNode(root);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
/* ── document.xml ─────────────────────────────── */
|
|
240
|
-
|
|
241
|
-
function buildRun(tokens, r, links) {
|
|
242
|
-
if (typeof r === 'string') r = { text: r };
|
|
243
|
-
const kids = [];
|
|
244
|
-
const rPr = buildRPr(tokens, r);
|
|
245
|
-
if (rPr) kids.push(rPr);
|
|
246
|
-
if (r.fld) {
|
|
247
|
-
return elem('w:fldSimple', [['w:instr', ` ${r.fld} `]], [
|
|
248
|
-
elem('w:r', [], rPr ? [rPr, elem('w:t', [], [textNode('1')])] : [elem('w:t', [], [textNode('1')])]),
|
|
249
|
-
]);
|
|
250
|
-
}
|
|
251
|
-
if (r.br) kids.push(elem('w:br', r.br === true ? [] : [['w:type', r.br]]));
|
|
252
|
-
if (r.text != null) {
|
|
253
|
-
const t = String(r.text);
|
|
254
|
-
const attrs = /^\s|\s$/.test(t) ? [['xml:space', 'preserve']] : [];
|
|
255
|
-
kids.push(elem('w:t', attrs, [textNode(t)]));
|
|
256
|
-
}
|
|
257
|
-
const runEl = elem('w:r', [], kids);
|
|
258
|
-
// 超链接:run 包进 w:hyperlink,目标在关系表里(TargetMode="External")。
|
|
259
|
-
// 视觉刻意不动 —— 中文简历那种「可点但不染色」不该被迫对抗默认蓝下划线;
|
|
260
|
-
// 要经典观感就照常写 color/underline
|
|
261
|
-
if (r.link) {
|
|
262
|
-
const id = links?.get(r.link);
|
|
263
|
-
// 到这儿查不到只可能是收集和构建不一致 —— 宁可炸也不产悬空 r:id(Word 报文档损坏)
|
|
264
|
-
if (id == null) throw new Error(`run.link 没登记进关系表:${r.link}`);
|
|
265
|
-
return elem('w:hyperlink', [['r:id', id], ['w:history', '1']], [runEl]);
|
|
266
|
-
}
|
|
267
|
-
return runEl;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
export function buildPara(tokens, block, links) {
|
|
271
|
-
const pKids = [];
|
|
272
|
-
const propKids = [];
|
|
273
|
-
if (block.style) propKids.push(val('w:pStyle', block.style));
|
|
274
|
-
const firstRun = (block.runs ?? []).find((r) => typeof r === 'object' && r.sizePt != null);
|
|
275
|
-
const ctx = { sizePt: effectiveSizePt(tokens, block.style, block.sizePt ?? firstRun?.sizePt), numIds: numIdsFor(tokens) };
|
|
276
|
-
propKids.push(...paraProps(block, ctx)); // 直接格式(block 上的 align/indent/... 覆盖)
|
|
277
|
-
if (propKids.length) pKids.push(sortChildren(elem('w:pPr', [], propKids)));
|
|
278
|
-
const runs = block.runs ?? (block.text != null ? [block.text] : []);
|
|
279
|
-
for (const r of runs) pKids.push(buildRun(tokens, r, links));
|
|
280
|
-
return elem('w:p', [], pKids);
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
function buildTable(tokens, block, links) {
|
|
284
|
-
const widths = block.widthsTwip;
|
|
285
|
-
const kids = [
|
|
286
|
-
sortChildren(elem('w:tblPr', [], [
|
|
287
|
-
elem('w:tblW', [['w:w', widths.reduce((a, b) => a + b, 0)], ['w:type', 'dxa']]),
|
|
288
|
-
elem('w:tblBorders', [], ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map(
|
|
289
|
-
(s) => elem(`w:${s}`, [['w:val', 'single'], ['w:sz', 4], ['w:space', 0], ['w:color', '000000']]),
|
|
290
|
-
)),
|
|
291
|
-
elem('w:tblLayout', [['w:type', 'fixed']]),
|
|
292
|
-
])),
|
|
293
|
-
elem('w:tblGrid', [], widths.map((w) => elem('w:gridCol', [['w:w', w]]))),
|
|
294
|
-
];
|
|
295
|
-
for (const row of block.rows) {
|
|
296
|
-
const tcs = row.map((cell, i) => {
|
|
297
|
-
const c = typeof cell === 'string' ? { text: cell } : cell;
|
|
298
|
-
return elem('w:tc', [], [
|
|
299
|
-
sortChildren(elem('w:tcPr', [], [
|
|
300
|
-
elem('w:tcW', [['w:w', widths[i]], ['w:type', 'dxa']]),
|
|
301
|
-
...(c.shading ? [elem('w:shd', [['w:val', 'clear'], ['w:color', 'auto'], ['w:fill', c.shading]])] : []),
|
|
302
|
-
val('w:vAlign', 'center'),
|
|
303
|
-
])),
|
|
304
|
-
buildPara(tokens, { ...c, indent: c.indent ?? {} }, links),
|
|
305
|
-
]);
|
|
306
|
-
});
|
|
307
|
-
kids.push(elem('w:tr', [], tcs));
|
|
308
|
-
}
|
|
309
|
-
return elem('w:tbl', [], kids);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
export function buildBody(tokens, content, rels, links) {
|
|
313
|
-
const kids = [];
|
|
314
|
-
for (const block of content) {
|
|
315
|
-
if (block.t === 'p') kids.push(buildPara(tokens, block, links));
|
|
316
|
-
else if (block.t === 'table') kids.push(buildTable(tokens, block, links));
|
|
317
|
-
else if (block.t === 'pageBreak') {
|
|
318
|
-
kids.push(elem('w:p', [], [elem('w:r', [], [elem('w:br', [['w:type', 'page']])])]));
|
|
319
|
-
} else throw new Error(`unknown block: ${block.t}`);
|
|
320
|
-
}
|
|
321
|
-
// sectPr 最后
|
|
322
|
-
const page = tokens.page ?? {};
|
|
323
|
-
const size = typeof page.size === 'string' ? PAGE_SIZES[page.size] : page.size ?? PAGE_SIZES.A4;
|
|
324
|
-
const m = page.marginsTwip ?? { top: 1440, bottom: 1440, left: 1800, right: 1800, header: 851, footer: 992, gutter: 0 };
|
|
325
|
-
const sect = [];
|
|
326
|
-
if (rels.header) sect.push(elem('w:headerReference', [['w:type', 'default'], ['r:id', rels.header]]));
|
|
327
|
-
if (rels.footer) sect.push(elem('w:footerReference', [['w:type', 'default'], ['r:id', rels.footer]]));
|
|
328
|
-
sect.push(elem('w:pgSz', [
|
|
329
|
-
['w:w', page.landscape ? size.hTwip : size.wTwip],
|
|
330
|
-
['w:h', page.landscape ? size.wTwip : size.hTwip],
|
|
331
|
-
...(page.landscape ? [['w:orient', 'landscape']] : []),
|
|
332
|
-
]));
|
|
333
|
-
sect.push(elem('w:pgMar', [
|
|
334
|
-
['w:top', m.top], ['w:right', m.right], ['w:bottom', m.bottom], ['w:left', m.left],
|
|
335
|
-
['w:header', m.header ?? 851], ['w:footer', m.footer ?? 992], ['w:gutter', m.gutter ?? 0],
|
|
336
|
-
]));
|
|
337
|
-
if (page.docGrid) {
|
|
338
|
-
sect.push(elem('w:docGrid', [
|
|
339
|
-
['w:type', page.docGrid.type ?? 'lines'],
|
|
340
|
-
['w:linePitch', page.docGrid.linePitchTwip],
|
|
341
|
-
...(page.docGrid.charSpace != null ? [['w:charSpace', page.docGrid.charSpace]] : []),
|
|
342
|
-
]));
|
|
343
|
-
}
|
|
344
|
-
kids.push(sortChildren(elem('w:sectPr', [], sect)));
|
|
345
|
-
return elem('w:body', [], kids);
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
export function buildDocumentXml(tokens, content, rels = {}, links = null) {
|
|
349
|
-
const root = elem('w:document', [[W, NS_W], ['xmlns:r', NS_R]], [buildBody(tokens, content, rels, links)]);
|
|
350
|
-
return DECL + serializeNode(root);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/** 正文(含表格单元格)里出现过的超链接目标,按出现顺序去重。
|
|
354
|
-
* 只扫 content —— 页眉页脚的 link 在校验层就被拒了(要单独关系表,没做) */
|
|
355
|
-
export function collectLinks(content) {
|
|
356
|
-
const urls = [];
|
|
357
|
-
const seen = new Set();
|
|
358
|
-
const fromRuns = (runs) => {
|
|
359
|
-
for (const r of runs ?? []) {
|
|
360
|
-
if (r && typeof r === 'object' && typeof r.link === 'string' && r.link && !seen.has(r.link)) {
|
|
361
|
-
seen.add(r.link);
|
|
362
|
-
urls.push(r.link);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
};
|
|
366
|
-
for (const b of content ?? []) {
|
|
367
|
-
if (!b || typeof b !== 'object') continue;
|
|
368
|
-
if (b.t === 'p') fromRuns(b.runs);
|
|
369
|
-
else if (b.t === 'table') {
|
|
370
|
-
for (const row of b.rows ?? []) {
|
|
371
|
-
for (const cell of row ?? []) {
|
|
372
|
-
if (cell && typeof cell === 'object') fromRuns(cell.runs);
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
return urls;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
function buildHdrFtr(tokens, name, blocks) {
|
|
381
|
-
const root = elem(name, [[W, NS_W], ['xmlns:r', NS_R]],
|
|
382
|
-
blocks.map((b) => buildPara(tokens, b)));
|
|
383
|
-
return DECL + serializeNode(root);
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
/* ── 容器组装 ─────────────────────────────────── */
|
|
387
|
-
|
|
388
|
-
const CT = (over) => `${DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`
|
|
389
|
-
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
390
|
-
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
|
391
|
-
+ over.map(([p, t]) => `<Override PartName="${p}" ContentType="${t}"/>`).join('')
|
|
392
|
-
+ '</Types>';
|
|
393
|
-
|
|
394
|
-
/** 关系表是字符串拼的,超链接 Target 是外来 URL —— & 这类字符不转义就是坏 XML */
|
|
395
|
-
const escAttr = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
396
|
-
|
|
397
|
-
const REL = (rels) => `${DECL}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`
|
|
398
|
-
+ rels.map(([id, type, target, mode]) => `<Relationship Id="${id}" Type="${type}" Target="${escAttr(target)}"${mode ? ` TargetMode="${mode}"` : ''}/>`).join('')
|
|
399
|
-
+ '</Relationships>';
|
|
400
|
-
|
|
401
|
-
const T_DOC = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml';
|
|
402
|
-
const T_STY = 'application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml';
|
|
403
|
-
const T_SET = 'application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml';
|
|
404
|
-
const T_HDR = 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
|
|
405
|
-
const T_FTR = 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
|
|
406
|
-
const T_NUM = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml';
|
|
407
|
-
const R_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
408
|
-
|
|
409
|
-
export function buildSettingsXml(tokens) {
|
|
410
|
-
const kids = [
|
|
411
|
-
elem('w:defaultTabStop', [['w:val', 420]]),
|
|
412
|
-
elem('w:characterSpacingControl', [['w:val', 'compressPunctuation']]),
|
|
413
|
-
elem('w:compat', [], [
|
|
414
|
-
elem('w:compatSetting', [['w:name', 'compatibilityMode'],
|
|
415
|
-
['w:uri', 'http://schemas.microsoft.com/office/word'], ['w:val', '15']]),
|
|
416
|
-
elem('w:compatSetting', [['w:name', 'useWord2013TrackBottomHyphenation'],
|
|
417
|
-
['w:uri', 'http://schemas.microsoft.com/office/word'], ['w:val', '0']]),
|
|
418
|
-
]),
|
|
419
|
-
elem('w:themeFontLang', [['w:val', tokens.lang?.latin ?? 'en-US'], ['w:eastAsia', tokens.lang?.eastAsia ?? 'zh-CN']]),
|
|
420
|
-
];
|
|
421
|
-
const root = elem('w:settings', [[W, NS_W]], kids);
|
|
422
|
-
return DECL + serializeNode(root);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* @param {object} tokens token JSON
|
|
427
|
-
* @param {Array} content 内容块
|
|
428
|
-
* @param {object} opts {header?: blocks, footer?: blocks, coreProps?: {title, creator}}
|
|
429
|
-
* @returns {Buffer} docx
|
|
430
|
-
*/
|
|
431
|
-
export async function buildDocx(tokens, content, opts = {}) {
|
|
432
|
-
const { addEntry, writeZip } = await import('./rawzip.js');
|
|
433
|
-
const zip = { entries: new Map(), order: [] };
|
|
434
|
-
|
|
435
|
-
const overrides = [
|
|
436
|
-
['/word/document.xml', T_DOC],
|
|
437
|
-
['/word/styles.xml', T_STY],
|
|
438
|
-
['/word/settings.xml', T_SET],
|
|
439
|
-
];
|
|
440
|
-
const docRels = [
|
|
441
|
-
['rId1', `${R_BASE}/styles`, 'styles.xml'],
|
|
442
|
-
['rId2', `${R_BASE}/settings`, 'settings.xml'],
|
|
443
|
-
];
|
|
444
|
-
const rels = {};
|
|
445
|
-
let nextId = 3;
|
|
446
|
-
// 自动编号(2026-08-18):有定义才产这个 part。空的 numbering.xml 是合法的但
|
|
447
|
-
// 毫无意义,而 Word 对"声明了 rel 却没有 part"是硬报错,所以三处登记要么全有
|
|
448
|
-
// 要么全没有 —— 这就是为什么它们挤在一个 if 里而不是散在三处。
|
|
449
|
-
const numberingXml = buildNumberingXml(tokens.numbering);
|
|
450
|
-
if (numberingXml) {
|
|
451
|
-
docRels.push([`rId${nextId}`, `${R_BASE}/numbering`, 'numbering.xml']);
|
|
452
|
-
overrides.push(['/word/numbering.xml', T_NUM]);
|
|
453
|
-
nextId += 1;
|
|
454
|
-
}
|
|
455
|
-
if (opts.header) {
|
|
456
|
-
rels.header = `rId${nextId}`;
|
|
457
|
-
docRels.push([`rId${nextId}`, `${R_BASE}/header`, 'header1.xml']);
|
|
458
|
-
overrides.push(['/word/header1.xml', T_HDR]);
|
|
459
|
-
nextId += 1;
|
|
460
|
-
}
|
|
461
|
-
if (opts.footer) {
|
|
462
|
-
rels.footer = `rId${nextId}`;
|
|
463
|
-
docRels.push([`rId${nextId}`, `${R_BASE}/footer`, 'footer1.xml']);
|
|
464
|
-
overrides.push(['/word/footer1.xml', T_FTR]);
|
|
465
|
-
nextId += 1;
|
|
466
|
-
}
|
|
467
|
-
// 超链接(2026-08-19):rId 从同一个计数器发 —— 撞上 styles/settings/numbering/
|
|
468
|
-
// header/footer 已占的号段,Word 打开直接报「文档已损坏」(agent 手写后处理时
|
|
469
|
-
// 实踩过)。同一 URL 复用同一个 rId
|
|
470
|
-
const links = new Map();
|
|
471
|
-
for (const url of collectLinks(content)) {
|
|
472
|
-
const id = `rId${nextId}`;
|
|
473
|
-
nextId += 1;
|
|
474
|
-
links.set(url, id);
|
|
475
|
-
docRels.push([id, `${R_BASE}/hyperlink`, url, 'External']);
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
addEntry(zip, '[Content_Types].xml', CT(overrides));
|
|
479
|
-
addEntry(zip, '_rels/.rels', REL([
|
|
480
|
-
['rId1', `${R_BASE}/officeDocument`, 'word/document.xml'],
|
|
481
|
-
]));
|
|
482
|
-
addEntry(zip, 'word/_rels/document.xml.rels', REL(docRels));
|
|
483
|
-
if (numberingXml) addEntry(zip, 'word/numbering.xml', numberingXml);
|
|
484
|
-
addEntry(zip, 'word/styles.xml', buildStylesXml(tokens));
|
|
485
|
-
addEntry(zip, 'word/settings.xml', buildSettingsXml(tokens));
|
|
486
|
-
if (opts.header) addEntry(zip, 'word/header1.xml', buildHdrFtr(tokens, 'w:hdr', opts.header));
|
|
487
|
-
if (opts.footer) addEntry(zip, 'word/footer1.xml', buildHdrFtr(tokens, 'w:ftr', opts.footer));
|
|
488
|
-
addEntry(zip, 'word/document.xml', buildDocumentXml(tokens, content, rels, links));
|
|
489
|
-
return writeZip(zip);
|
|
490
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* build.js — token JSON + 内容块 → 完整 docx(Buffer)。
|
|
3
|
+
*
|
|
4
|
+
* 所有属性容器(pPr/rPr/sectPr/style/…)都过 order.sortChildren(),
|
|
5
|
+
* 出厂前可用 order.checkOrder() 体检;对外证据是 OpenXmlValidator 0 错。
|
|
6
|
+
*
|
|
7
|
+
* 内容块模型(generation 路线的雏形,编辑路线不走这里):
|
|
8
|
+
* {t:'p', style?, blocks 见 buildPara} 段落
|
|
9
|
+
* {t:'table', widthsTwip:[..], rows:[[cell]]} 表格;cell = string | {text, style?}
|
|
10
|
+
* {t:'pageBreak'}
|
|
11
|
+
* 段落 runs:string | {text, bold?, italic?, color?, font?, sizePt?, em?, underline?}
|
|
12
|
+
* | {fld:'PAGE'|'NUMPAGES'}
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { elem, textNode, serializeNode } from './xml.js';
|
|
16
|
+
import { sortChildren } from './order.js';
|
|
17
|
+
import { ptToHalf, ptToTwip, sizePt as toPt, PAGE_SIZES } from './units.js';
|
|
18
|
+
import { buildNumberingXml, numIdsFor } from './numbering.js';
|
|
19
|
+
|
|
20
|
+
const W = 'xmlns:w';
|
|
21
|
+
const NS_W = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main';
|
|
22
|
+
const NS_R = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
23
|
+
const DECL = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n';
|
|
24
|
+
|
|
25
|
+
/* ── 低层小件 ─────────────────────────────────── */
|
|
26
|
+
|
|
27
|
+
function val(name, v) { return elem(name, [['w:val', v]]); }
|
|
28
|
+
function flag(name, on) { return on === false ? elem(name, [['w:val', '0']]) : elem(name); }
|
|
29
|
+
|
|
30
|
+
function resolveFont(tokens, ref) {
|
|
31
|
+
if (ref == null) return null;
|
|
32
|
+
const f = typeof ref === 'string' ? tokens.fonts?.[ref] : ref;
|
|
33
|
+
if (!f) throw new Error(`unknown font slot: ${ref}`);
|
|
34
|
+
return f;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function rFontsEl(f) {
|
|
38
|
+
const attrs = [];
|
|
39
|
+
if (f.ascii) attrs.push(['w:ascii', f.ascii]);
|
|
40
|
+
if (f.eastAsia) attrs.push(['w:eastAsia', f.eastAsia]);
|
|
41
|
+
attrs.push(['w:hAnsi', f.hAnsi ?? f.ascii ?? f.eastAsia]);
|
|
42
|
+
if (f.cs ?? f.ascii) attrs.push(['w:cs', f.cs ?? f.ascii]);
|
|
43
|
+
return elem('w:rFonts', attrs);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** run token → w:rPr(无内容时返回 null) */
|
|
47
|
+
export function buildRPr(tokens, run, { forStyle = false } = {}) {
|
|
48
|
+
if (!run) return null;
|
|
49
|
+
const kids = [];
|
|
50
|
+
const f = resolveFont(tokens, run.font);
|
|
51
|
+
if (f) kids.push(rFontsEl(f));
|
|
52
|
+
if (run.bold != null) kids.push(flag('w:b', run.bold));
|
|
53
|
+
if (run.italic != null) kids.push(flag('w:i', run.italic));
|
|
54
|
+
if (run.caps) kids.push(elem('w:caps'));
|
|
55
|
+
if (run.smallCaps) kids.push(elem('w:smallCaps'));
|
|
56
|
+
if (run.strike) kids.push(elem('w:strike'));
|
|
57
|
+
if (run.color) kids.push(val('w:color', run.color));
|
|
58
|
+
if (run.spacingTwip != null) kids.push(val('w:spacing', run.spacingTwip));
|
|
59
|
+
if (run.kernPt != null) kids.push(val('w:kern', ptToHalf(run.kernPt)));
|
|
60
|
+
if (run.sizePt != null) {
|
|
61
|
+
kids.push(val('w:sz', ptToHalf(toPt(run.sizePt))));
|
|
62
|
+
kids.push(val('w:szCs', ptToHalf(toPt(run.sizePt))));
|
|
63
|
+
}
|
|
64
|
+
if (run.highlight) kids.push(val('w:highlight', run.highlight));
|
|
65
|
+
if (run.underline) kids.push(val('w:u', run.underline === true ? 'single' : run.underline));
|
|
66
|
+
if (run.vertAlign) kids.push(val('w:vertAlign', run.vertAlign));
|
|
67
|
+
if (run.em) kids.push(val('w:em', run.em));
|
|
68
|
+
if (!kids.length) return null;
|
|
69
|
+
return sortChildren(elem('w:rPr', [], kids));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const CJK_FLAGS = [
|
|
73
|
+
['kinsoku', 'w:kinsoku'], ['wordWrap', 'w:wordWrap'], ['overflowPunct', 'w:overflowPunct'],
|
|
74
|
+
['autoSpaceDE', 'w:autoSpaceDE'], ['autoSpaceDN', 'w:autoSpaceDN'],
|
|
75
|
+
['adjustRightInd', 'w:adjustRightInd'], ['snapToGrid', 'w:snapToGrid'],
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 沿 basedOn 链算风格的有效字号(磅)。块级 run 覆盖 > 风格链 > base。
|
|
80
|
+
*/
|
|
81
|
+
export function effectiveSizePt(tokens, styleId, runSizePt) {
|
|
82
|
+
if (runSizePt != null) return toPt(runSizePt);
|
|
83
|
+
let id = styleId;
|
|
84
|
+
const seen = new Set();
|
|
85
|
+
while (id && tokens.styles?.[id] && !seen.has(id)) {
|
|
86
|
+
seen.add(id);
|
|
87
|
+
const st = tokens.styles[id];
|
|
88
|
+
if (st.run?.sizePt != null) return toPt(st.run.sizePt);
|
|
89
|
+
id = st.basedOn;
|
|
90
|
+
}
|
|
91
|
+
return toPt(tokens.base?.sizePt ?? 12);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 【实测军规】LO 25.2 无视单独的 w:firstLineChars/leftChars(lab/02 A/B:
|
|
96
|
+
* chars-only 缩进 0pt,chars+twip 精确 32pt)。Word 里 chars 优先于 twip。
|
|
97
|
+
* 所以字符单位缩进一律双发:chars(Word 真语义,随字号缩放)+ 按有效字号
|
|
98
|
+
* 算好的 twip 兜底(LO/QC 与旧阅读器用)。
|
|
99
|
+
*/
|
|
100
|
+
function indentAttrs(i, sizePt) {
|
|
101
|
+
const attrs = [];
|
|
102
|
+
const chars2twip = (chars) => Math.round((chars / 100) * sizePt * 20);
|
|
103
|
+
if (i.leftChars != null) {
|
|
104
|
+
attrs.push(['w:left', i.leftTwip ?? chars2twip(i.leftChars)]);
|
|
105
|
+
attrs.push(['w:leftChars', i.leftChars]);
|
|
106
|
+
} else if (i.leftTwip != null) attrs.push(['w:left', i.leftTwip]);
|
|
107
|
+
if (i.rightChars != null) {
|
|
108
|
+
attrs.push(['w:right', i.rightTwip ?? chars2twip(i.rightChars)]);
|
|
109
|
+
attrs.push(['w:rightChars', i.rightChars]);
|
|
110
|
+
} else if (i.rightTwip != null) attrs.push(['w:right', i.rightTwip]);
|
|
111
|
+
if (i.hangingChars != null) {
|
|
112
|
+
attrs.push(['w:hanging', i.hangingTwip ?? chars2twip(i.hangingChars)]);
|
|
113
|
+
attrs.push(['w:hangingChars', i.hangingChars]);
|
|
114
|
+
} else if (i.hangingTwip != null) attrs.push(['w:hanging', i.hangingTwip]);
|
|
115
|
+
if (i.firstLineChars != null) {
|
|
116
|
+
attrs.push(['w:firstLine', i.firstLineTwip ?? chars2twip(i.firstLineChars)]);
|
|
117
|
+
attrs.push(['w:firstLineChars', i.firstLineChars]);
|
|
118
|
+
} else if (i.firstLineTwip != null) attrs.push(['w:firstLine', i.firstLineTwip]);
|
|
119
|
+
return attrs;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** para token → w:pPr 子元素数组(不含 pStyle/rPr,调用方拼)
|
|
123
|
+
* ctx.sizePt = 本段有效字号,字符单位缩进换算 twip 兜底用 */
|
|
124
|
+
function paraProps(para, ctx = { sizePt: 12 }) {
|
|
125
|
+
const kids = [];
|
|
126
|
+
if (!para) return kids;
|
|
127
|
+
if (para.keepNext) kids.push(elem('w:keepNext'));
|
|
128
|
+
if (para.keepLines) kids.push(elem('w:keepLines'));
|
|
129
|
+
if (para.pageBreakBefore) kids.push(elem('w:pageBreakBefore'));
|
|
130
|
+
if (para.widowControl != null) kids.push(flag('w:widowControl', para.widowControl));
|
|
131
|
+
if (para.list) {
|
|
132
|
+
// agent 按**名字**引用编号(`list: {name:'条款', ilvl:1}` 或简写 `list:'条款'`),
|
|
133
|
+
// numId 是 OOXML 的实现细节,这里现算。名字对不上是 token 校验的活,
|
|
134
|
+
// 到这儿只可能是内部不一致,宁可产一个显式错误也别产一个悬空 numId。
|
|
135
|
+
const spec = typeof para.list === 'string' ? { name: para.list } : para.list;
|
|
136
|
+
const numId = ctx.numIds?.get(spec.name);
|
|
137
|
+
if (numId == null) throw new Error(`para.list: 没有名为 '${spec.name}' 的编号定义`);
|
|
138
|
+
kids.push(elem('w:numPr', [], [val('w:ilvl', spec.ilvl ?? 0), val('w:numId', numId)]));
|
|
139
|
+
}
|
|
140
|
+
if (para.borders) {
|
|
141
|
+
const sides = [];
|
|
142
|
+
for (const side of ['top', 'left', 'bottom', 'right']) {
|
|
143
|
+
const b = para.borders[side];
|
|
144
|
+
if (!b) continue;
|
|
145
|
+
sides.push(elem(`w:${side}`, [
|
|
146
|
+
['w:val', b.style ?? 'single'], ['w:sz', b.sizePt8 ?? 4],
|
|
147
|
+
['w:space', b.spacePt ?? 0], ['w:color', b.color ?? '000000'],
|
|
148
|
+
]));
|
|
149
|
+
}
|
|
150
|
+
if (sides.length) kids.push(sortChildren(elem('w:pBdr', [], sides)));
|
|
151
|
+
}
|
|
152
|
+
if (para.shading) {
|
|
153
|
+
kids.push(elem('w:shd', [['w:val', 'clear'], ['w:color', 'auto'], ['w:fill', para.shading]]));
|
|
154
|
+
}
|
|
155
|
+
if (para.tabs?.length) {
|
|
156
|
+
kids.push(elem('w:tabs', [], para.tabs.map((t) => elem('w:tab', [
|
|
157
|
+
['w:val', t.val ?? 'left'], ...(t.leader ? [['w:leader', t.leader]] : []), ['w:pos', t.pos],
|
|
158
|
+
]))));
|
|
159
|
+
}
|
|
160
|
+
for (const [key, name] of CJK_FLAGS) {
|
|
161
|
+
if (para.cjk && para.cjk[key] != null) kids.push(flag(name, para.cjk[key]));
|
|
162
|
+
}
|
|
163
|
+
if (para.spacing) {
|
|
164
|
+
const s = para.spacing;
|
|
165
|
+
const attrs = [];
|
|
166
|
+
if (s.beforePt != null) attrs.push(['w:before', ptToTwip(s.beforePt)]);
|
|
167
|
+
if (s.beforeLines != null) attrs.push(['w:beforeLines', s.beforeLines]);
|
|
168
|
+
if (s.afterPt != null) attrs.push(['w:after', ptToTwip(s.afterPt)]);
|
|
169
|
+
if (s.afterLines != null) attrs.push(['w:afterLines', s.afterLines]);
|
|
170
|
+
if (s.line != null) {
|
|
171
|
+
const rule = s.lineRule ?? 'multiple';
|
|
172
|
+
attrs.push(['w:line', rule === 'multiple' ? Math.round(s.line * 240) : ptToTwip(s.line)]);
|
|
173
|
+
attrs.push(['w:lineRule', rule === 'multiple' ? 'auto' : rule]);
|
|
174
|
+
}
|
|
175
|
+
if (attrs.length) kids.push(elem('w:spacing', attrs));
|
|
176
|
+
}
|
|
177
|
+
if (para.indent) {
|
|
178
|
+
const attrs = indentAttrs(para.indent, ctx.sizePt);
|
|
179
|
+
if (attrs.length) kids.push(elem('w:ind', attrs));
|
|
180
|
+
}
|
|
181
|
+
if (para.contextualSpacing) kids.push(elem('w:contextualSpacing'));
|
|
182
|
+
if (para.align) kids.push(val('w:jc', para.align));
|
|
183
|
+
if (para.outlineLevel != null) kids.push(val('w:outlineLvl', para.outlineLevel));
|
|
184
|
+
return kids;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ── styles.xml ───────────────────────────────── */
|
|
188
|
+
|
|
189
|
+
export function buildStylesXml(tokens) {
|
|
190
|
+
const base = tokens.base ?? {};
|
|
191
|
+
const rDef = [];
|
|
192
|
+
const bf = resolveFont(tokens, base.font ?? 'body');
|
|
193
|
+
if (bf) rDef.push(rFontsEl(bf));
|
|
194
|
+
if (base.kernPt != null) rDef.push(val('w:kern', ptToHalf(base.kernPt)));
|
|
195
|
+
if (base.sizePt != null) {
|
|
196
|
+
rDef.push(val('w:sz', ptToHalf(toPt(base.sizePt))));
|
|
197
|
+
rDef.push(val('w:szCs', ptToHalf(toPt(base.sizePt))));
|
|
198
|
+
}
|
|
199
|
+
if (base.color && base.color !== '000000') rDef.push(val('w:color', base.color));
|
|
200
|
+
if (tokens.lang) {
|
|
201
|
+
rDef.push(elem('w:lang', [
|
|
202
|
+
['w:val', tokens.lang.latin ?? 'en-US'],
|
|
203
|
+
['w:eastAsia', tokens.lang.eastAsia ?? 'zh-CN'],
|
|
204
|
+
['w:bidi', 'ar-SA'],
|
|
205
|
+
]));
|
|
206
|
+
}
|
|
207
|
+
const pDefKids = [];
|
|
208
|
+
for (const [key, name] of CJK_FLAGS) {
|
|
209
|
+
if (base.cjk && base.cjk[key] != null) pDefKids.push(flag(name, base.cjk[key]));
|
|
210
|
+
}
|
|
211
|
+
if (base.spacing) pDefKids.push(...paraProps({ spacing: base.spacing }));
|
|
212
|
+
|
|
213
|
+
const styleEls = [];
|
|
214
|
+
for (const [id, st] of Object.entries(tokens.styles ?? {})) {
|
|
215
|
+
const kids = [val('w:name', st.name ?? id)];
|
|
216
|
+
if (st.basedOn) kids.push(val('w:basedOn', st.basedOn));
|
|
217
|
+
if (st.next) kids.push(val('w:next', st.next));
|
|
218
|
+
if (st.link) kids.push(val('w:link', st.link));
|
|
219
|
+
if (st.uiPriority != null) kids.push(val('w:uiPriority', st.uiPriority));
|
|
220
|
+
if (st.qFormat) kids.push(elem('w:qFormat'));
|
|
221
|
+
const pKids = paraProps(st.para, { sizePt: effectiveSizePt(tokens, id), numIds: numIdsFor(tokens) });
|
|
222
|
+
if (pKids.length) kids.push(sortChildren(elem('w:pPr', [], pKids)));
|
|
223
|
+
const rPr = buildRPr(tokens, st.run, { forStyle: true });
|
|
224
|
+
if (rPr) kids.push(rPr);
|
|
225
|
+
const el = sortChildren(elem('w:style', [['w:type', st.type ?? 'paragraph'], ['w:styleId', id]], kids));
|
|
226
|
+
styleEls.push(el);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const root = elem('w:styles', [[W, NS_W]], [
|
|
230
|
+
elem('w:docDefaults', [], [
|
|
231
|
+
elem('w:rPrDefault', [], rDef.length ? [sortChildren(elem('w:rPr', [], rDef))] : []),
|
|
232
|
+
elem('w:pPrDefault', [], pDefKids.length ? [sortChildren(elem('w:pPr', [], pDefKids))] : []),
|
|
233
|
+
]),
|
|
234
|
+
...styleEls,
|
|
235
|
+
]);
|
|
236
|
+
return DECL + serializeNode(root);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ── document.xml ─────────────────────────────── */
|
|
240
|
+
|
|
241
|
+
function buildRun(tokens, r, links) {
|
|
242
|
+
if (typeof r === 'string') r = { text: r };
|
|
243
|
+
const kids = [];
|
|
244
|
+
const rPr = buildRPr(tokens, r);
|
|
245
|
+
if (rPr) kids.push(rPr);
|
|
246
|
+
if (r.fld) {
|
|
247
|
+
return elem('w:fldSimple', [['w:instr', ` ${r.fld} `]], [
|
|
248
|
+
elem('w:r', [], rPr ? [rPr, elem('w:t', [], [textNode('1')])] : [elem('w:t', [], [textNode('1')])]),
|
|
249
|
+
]);
|
|
250
|
+
}
|
|
251
|
+
if (r.br) kids.push(elem('w:br', r.br === true ? [] : [['w:type', r.br]]));
|
|
252
|
+
if (r.text != null) {
|
|
253
|
+
const t = String(r.text);
|
|
254
|
+
const attrs = /^\s|\s$/.test(t) ? [['xml:space', 'preserve']] : [];
|
|
255
|
+
kids.push(elem('w:t', attrs, [textNode(t)]));
|
|
256
|
+
}
|
|
257
|
+
const runEl = elem('w:r', [], kids);
|
|
258
|
+
// 超链接:run 包进 w:hyperlink,目标在关系表里(TargetMode="External")。
|
|
259
|
+
// 视觉刻意不动 —— 中文简历那种「可点但不染色」不该被迫对抗默认蓝下划线;
|
|
260
|
+
// 要经典观感就照常写 color/underline
|
|
261
|
+
if (r.link) {
|
|
262
|
+
const id = links?.get(r.link);
|
|
263
|
+
// 到这儿查不到只可能是收集和构建不一致 —— 宁可炸也不产悬空 r:id(Word 报文档损坏)
|
|
264
|
+
if (id == null) throw new Error(`run.link 没登记进关系表:${r.link}`);
|
|
265
|
+
return elem('w:hyperlink', [['r:id', id], ['w:history', '1']], [runEl]);
|
|
266
|
+
}
|
|
267
|
+
return runEl;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function buildPara(tokens, block, links) {
|
|
271
|
+
const pKids = [];
|
|
272
|
+
const propKids = [];
|
|
273
|
+
if (block.style) propKids.push(val('w:pStyle', block.style));
|
|
274
|
+
const firstRun = (block.runs ?? []).find((r) => typeof r === 'object' && r.sizePt != null);
|
|
275
|
+
const ctx = { sizePt: effectiveSizePt(tokens, block.style, block.sizePt ?? firstRun?.sizePt), numIds: numIdsFor(tokens) };
|
|
276
|
+
propKids.push(...paraProps(block, ctx)); // 直接格式(block 上的 align/indent/... 覆盖)
|
|
277
|
+
if (propKids.length) pKids.push(sortChildren(elem('w:pPr', [], propKids)));
|
|
278
|
+
const runs = block.runs ?? (block.text != null ? [block.text] : []);
|
|
279
|
+
for (const r of runs) pKids.push(buildRun(tokens, r, links));
|
|
280
|
+
return elem('w:p', [], pKids);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function buildTable(tokens, block, links) {
|
|
284
|
+
const widths = block.widthsTwip;
|
|
285
|
+
const kids = [
|
|
286
|
+
sortChildren(elem('w:tblPr', [], [
|
|
287
|
+
elem('w:tblW', [['w:w', widths.reduce((a, b) => a + b, 0)], ['w:type', 'dxa']]),
|
|
288
|
+
elem('w:tblBorders', [], ['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map(
|
|
289
|
+
(s) => elem(`w:${s}`, [['w:val', 'single'], ['w:sz', 4], ['w:space', 0], ['w:color', '000000']]),
|
|
290
|
+
)),
|
|
291
|
+
elem('w:tblLayout', [['w:type', 'fixed']]),
|
|
292
|
+
])),
|
|
293
|
+
elem('w:tblGrid', [], widths.map((w) => elem('w:gridCol', [['w:w', w]]))),
|
|
294
|
+
];
|
|
295
|
+
for (const row of block.rows) {
|
|
296
|
+
const tcs = row.map((cell, i) => {
|
|
297
|
+
const c = typeof cell === 'string' ? { text: cell } : cell;
|
|
298
|
+
return elem('w:tc', [], [
|
|
299
|
+
sortChildren(elem('w:tcPr', [], [
|
|
300
|
+
elem('w:tcW', [['w:w', widths[i]], ['w:type', 'dxa']]),
|
|
301
|
+
...(c.shading ? [elem('w:shd', [['w:val', 'clear'], ['w:color', 'auto'], ['w:fill', c.shading]])] : []),
|
|
302
|
+
val('w:vAlign', 'center'),
|
|
303
|
+
])),
|
|
304
|
+
buildPara(tokens, { ...c, indent: c.indent ?? {} }, links),
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
307
|
+
kids.push(elem('w:tr', [], tcs));
|
|
308
|
+
}
|
|
309
|
+
return elem('w:tbl', [], kids);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function buildBody(tokens, content, rels, links) {
|
|
313
|
+
const kids = [];
|
|
314
|
+
for (const block of content) {
|
|
315
|
+
if (block.t === 'p') kids.push(buildPara(tokens, block, links));
|
|
316
|
+
else if (block.t === 'table') kids.push(buildTable(tokens, block, links));
|
|
317
|
+
else if (block.t === 'pageBreak') {
|
|
318
|
+
kids.push(elem('w:p', [], [elem('w:r', [], [elem('w:br', [['w:type', 'page']])])]));
|
|
319
|
+
} else throw new Error(`unknown block: ${block.t}`);
|
|
320
|
+
}
|
|
321
|
+
// sectPr 最后
|
|
322
|
+
const page = tokens.page ?? {};
|
|
323
|
+
const size = typeof page.size === 'string' ? PAGE_SIZES[page.size] : page.size ?? PAGE_SIZES.A4;
|
|
324
|
+
const m = page.marginsTwip ?? { top: 1440, bottom: 1440, left: 1800, right: 1800, header: 851, footer: 992, gutter: 0 };
|
|
325
|
+
const sect = [];
|
|
326
|
+
if (rels.header) sect.push(elem('w:headerReference', [['w:type', 'default'], ['r:id', rels.header]]));
|
|
327
|
+
if (rels.footer) sect.push(elem('w:footerReference', [['w:type', 'default'], ['r:id', rels.footer]]));
|
|
328
|
+
sect.push(elem('w:pgSz', [
|
|
329
|
+
['w:w', page.landscape ? size.hTwip : size.wTwip],
|
|
330
|
+
['w:h', page.landscape ? size.wTwip : size.hTwip],
|
|
331
|
+
...(page.landscape ? [['w:orient', 'landscape']] : []),
|
|
332
|
+
]));
|
|
333
|
+
sect.push(elem('w:pgMar', [
|
|
334
|
+
['w:top', m.top], ['w:right', m.right], ['w:bottom', m.bottom], ['w:left', m.left],
|
|
335
|
+
['w:header', m.header ?? 851], ['w:footer', m.footer ?? 992], ['w:gutter', m.gutter ?? 0],
|
|
336
|
+
]));
|
|
337
|
+
if (page.docGrid) {
|
|
338
|
+
sect.push(elem('w:docGrid', [
|
|
339
|
+
['w:type', page.docGrid.type ?? 'lines'],
|
|
340
|
+
['w:linePitch', page.docGrid.linePitchTwip],
|
|
341
|
+
...(page.docGrid.charSpace != null ? [['w:charSpace', page.docGrid.charSpace]] : []),
|
|
342
|
+
]));
|
|
343
|
+
}
|
|
344
|
+
kids.push(sortChildren(elem('w:sectPr', [], sect)));
|
|
345
|
+
return elem('w:body', [], kids);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function buildDocumentXml(tokens, content, rels = {}, links = null) {
|
|
349
|
+
const root = elem('w:document', [[W, NS_W], ['xmlns:r', NS_R]], [buildBody(tokens, content, rels, links)]);
|
|
350
|
+
return DECL + serializeNode(root);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** 正文(含表格单元格)里出现过的超链接目标,按出现顺序去重。
|
|
354
|
+
* 只扫 content —— 页眉页脚的 link 在校验层就被拒了(要单独关系表,没做) */
|
|
355
|
+
export function collectLinks(content) {
|
|
356
|
+
const urls = [];
|
|
357
|
+
const seen = new Set();
|
|
358
|
+
const fromRuns = (runs) => {
|
|
359
|
+
for (const r of runs ?? []) {
|
|
360
|
+
if (r && typeof r === 'object' && typeof r.link === 'string' && r.link && !seen.has(r.link)) {
|
|
361
|
+
seen.add(r.link);
|
|
362
|
+
urls.push(r.link);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
for (const b of content ?? []) {
|
|
367
|
+
if (!b || typeof b !== 'object') continue;
|
|
368
|
+
if (b.t === 'p') fromRuns(b.runs);
|
|
369
|
+
else if (b.t === 'table') {
|
|
370
|
+
for (const row of b.rows ?? []) {
|
|
371
|
+
for (const cell of row ?? []) {
|
|
372
|
+
if (cell && typeof cell === 'object') fromRuns(cell.runs);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return urls;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function buildHdrFtr(tokens, name, blocks) {
|
|
381
|
+
const root = elem(name, [[W, NS_W], ['xmlns:r', NS_R]],
|
|
382
|
+
blocks.map((b) => buildPara(tokens, b)));
|
|
383
|
+
return DECL + serializeNode(root);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/* ── 容器组装 ─────────────────────────────────── */
|
|
387
|
+
|
|
388
|
+
const CT = (over) => `${DECL}<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`
|
|
389
|
+
+ '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
390
|
+
+ '<Default Extension="xml" ContentType="application/xml"/>'
|
|
391
|
+
+ over.map(([p, t]) => `<Override PartName="${p}" ContentType="${t}"/>`).join('')
|
|
392
|
+
+ '</Types>';
|
|
393
|
+
|
|
394
|
+
/** 关系表是字符串拼的,超链接 Target 是外来 URL —— & 这类字符不转义就是坏 XML */
|
|
395
|
+
const escAttr = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
396
|
+
|
|
397
|
+
const REL = (rels) => `${DECL}<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">`
|
|
398
|
+
+ rels.map(([id, type, target, mode]) => `<Relationship Id="${id}" Type="${type}" Target="${escAttr(target)}"${mode ? ` TargetMode="${mode}"` : ''}/>`).join('')
|
|
399
|
+
+ '</Relationships>';
|
|
400
|
+
|
|
401
|
+
const T_DOC = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml';
|
|
402
|
+
const T_STY = 'application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml';
|
|
403
|
+
const T_SET = 'application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml';
|
|
404
|
+
const T_HDR = 'application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml';
|
|
405
|
+
const T_FTR = 'application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml';
|
|
406
|
+
const T_NUM = 'application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml';
|
|
407
|
+
const R_BASE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships';
|
|
408
|
+
|
|
409
|
+
export function buildSettingsXml(tokens) {
|
|
410
|
+
const kids = [
|
|
411
|
+
elem('w:defaultTabStop', [['w:val', 420]]),
|
|
412
|
+
elem('w:characterSpacingControl', [['w:val', 'compressPunctuation']]),
|
|
413
|
+
elem('w:compat', [], [
|
|
414
|
+
elem('w:compatSetting', [['w:name', 'compatibilityMode'],
|
|
415
|
+
['w:uri', 'http://schemas.microsoft.com/office/word'], ['w:val', '15']]),
|
|
416
|
+
elem('w:compatSetting', [['w:name', 'useWord2013TrackBottomHyphenation'],
|
|
417
|
+
['w:uri', 'http://schemas.microsoft.com/office/word'], ['w:val', '0']]),
|
|
418
|
+
]),
|
|
419
|
+
elem('w:themeFontLang', [['w:val', tokens.lang?.latin ?? 'en-US'], ['w:eastAsia', tokens.lang?.eastAsia ?? 'zh-CN']]),
|
|
420
|
+
];
|
|
421
|
+
const root = elem('w:settings', [[W, NS_W]], kids);
|
|
422
|
+
return DECL + serializeNode(root);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @param {object} tokens token JSON
|
|
427
|
+
* @param {Array} content 内容块
|
|
428
|
+
* @param {object} opts {header?: blocks, footer?: blocks, coreProps?: {title, creator}}
|
|
429
|
+
* @returns {Buffer} docx
|
|
430
|
+
*/
|
|
431
|
+
export async function buildDocx(tokens, content, opts = {}) {
|
|
432
|
+
const { addEntry, writeZip } = await import('./rawzip.js');
|
|
433
|
+
const zip = { entries: new Map(), order: [] };
|
|
434
|
+
|
|
435
|
+
const overrides = [
|
|
436
|
+
['/word/document.xml', T_DOC],
|
|
437
|
+
['/word/styles.xml', T_STY],
|
|
438
|
+
['/word/settings.xml', T_SET],
|
|
439
|
+
];
|
|
440
|
+
const docRels = [
|
|
441
|
+
['rId1', `${R_BASE}/styles`, 'styles.xml'],
|
|
442
|
+
['rId2', `${R_BASE}/settings`, 'settings.xml'],
|
|
443
|
+
];
|
|
444
|
+
const rels = {};
|
|
445
|
+
let nextId = 3;
|
|
446
|
+
// 自动编号(2026-08-18):有定义才产这个 part。空的 numbering.xml 是合法的但
|
|
447
|
+
// 毫无意义,而 Word 对"声明了 rel 却没有 part"是硬报错,所以三处登记要么全有
|
|
448
|
+
// 要么全没有 —— 这就是为什么它们挤在一个 if 里而不是散在三处。
|
|
449
|
+
const numberingXml = buildNumberingXml(tokens.numbering);
|
|
450
|
+
if (numberingXml) {
|
|
451
|
+
docRels.push([`rId${nextId}`, `${R_BASE}/numbering`, 'numbering.xml']);
|
|
452
|
+
overrides.push(['/word/numbering.xml', T_NUM]);
|
|
453
|
+
nextId += 1;
|
|
454
|
+
}
|
|
455
|
+
if (opts.header) {
|
|
456
|
+
rels.header = `rId${nextId}`;
|
|
457
|
+
docRels.push([`rId${nextId}`, `${R_BASE}/header`, 'header1.xml']);
|
|
458
|
+
overrides.push(['/word/header1.xml', T_HDR]);
|
|
459
|
+
nextId += 1;
|
|
460
|
+
}
|
|
461
|
+
if (opts.footer) {
|
|
462
|
+
rels.footer = `rId${nextId}`;
|
|
463
|
+
docRels.push([`rId${nextId}`, `${R_BASE}/footer`, 'footer1.xml']);
|
|
464
|
+
overrides.push(['/word/footer1.xml', T_FTR]);
|
|
465
|
+
nextId += 1;
|
|
466
|
+
}
|
|
467
|
+
// 超链接(2026-08-19):rId 从同一个计数器发 —— 撞上 styles/settings/numbering/
|
|
468
|
+
// header/footer 已占的号段,Word 打开直接报「文档已损坏」(agent 手写后处理时
|
|
469
|
+
// 实踩过)。同一 URL 复用同一个 rId
|
|
470
|
+
const links = new Map();
|
|
471
|
+
for (const url of collectLinks(content)) {
|
|
472
|
+
const id = `rId${nextId}`;
|
|
473
|
+
nextId += 1;
|
|
474
|
+
links.set(url, id);
|
|
475
|
+
docRels.push([id, `${R_BASE}/hyperlink`, url, 'External']);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
addEntry(zip, '[Content_Types].xml', CT(overrides));
|
|
479
|
+
addEntry(zip, '_rels/.rels', REL([
|
|
480
|
+
['rId1', `${R_BASE}/officeDocument`, 'word/document.xml'],
|
|
481
|
+
]));
|
|
482
|
+
addEntry(zip, 'word/_rels/document.xml.rels', REL(docRels));
|
|
483
|
+
if (numberingXml) addEntry(zip, 'word/numbering.xml', numberingXml);
|
|
484
|
+
addEntry(zip, 'word/styles.xml', buildStylesXml(tokens));
|
|
485
|
+
addEntry(zip, 'word/settings.xml', buildSettingsXml(tokens));
|
|
486
|
+
if (opts.header) addEntry(zip, 'word/header1.xml', buildHdrFtr(tokens, 'w:hdr', opts.header));
|
|
487
|
+
if (opts.footer) addEntry(zip, 'word/footer1.xml', buildHdrFtr(tokens, 'w:ftr', opts.footer));
|
|
488
|
+
addEntry(zip, 'word/document.xml', buildDocumentXml(tokens, content, rels, links));
|
|
489
|
+
return writeZip(zip);
|
|
490
|
+
}
|