mixdog 0.9.151 → 0.9.153
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/LICENSES/browser-import-NOTICE.txt +11 -0
- package/NOTICE.md +11 -0
- package/README.md +108 -68
- package/package.json +17 -4
- package/scripts/computer-task-cost-report.mjs +144 -0
- package/scripts/run-office-live-tests.mjs +32 -0
- package/scripts/tool-stress.mjs +0 -2
- package/src/agents/front-worker/AGENT.md +13 -0
- package/src/agents/front-worker/agent.json +6 -0
- package/src/agents/heavy-worker/AGENT.md +10 -11
- package/src/agents/heavy-worker/agent.json +1 -1
- package/src/agents/maintainer/AGENT.md +0 -2
- package/src/agents/reviewer/AGENT.md +9 -13
- package/src/agents/reviewer/agent.json +1 -1
- package/src/agents/security/AGENT.md +14 -0
- package/src/agents/security/agent.json +6 -0
- package/src/agents/worker/AGENT.md +8 -8
- package/src/agents/worker/agent.json +1 -1
- package/src/agents/writer/AGENT.md +14 -0
- package/src/agents/writer/agent.json +6 -0
- package/src/defaults/skills/setup/SKILL.md +18 -27
- package/src/headless-exec.mjs +3 -10
- package/src/headless-exec.test.mjs +2 -7
- package/src/rules/agent/00-common.md +4 -7
- package/src/rules/lead/01-general.md +0 -3
- package/src/rules/shared/10-tool-workflow.md +10 -2
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +183 -26
- package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.test.mjs +378 -0
- package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +4 -0
- package/src/runtime/agent/orchestrator/cache-break-trace.mjs +109 -0
- package/src/runtime/agent/orchestrator/cache-break-trace.test.mjs +115 -0
- package/src/runtime/agent/orchestrator/config.mjs +13 -68
- package/src/runtime/agent/orchestrator/context/collect-skills.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/context/collect.mjs +49 -45
- package/src/runtime/agent/orchestrator/mcp/client.mjs +242 -15
- package/src/runtime/agent/orchestrator/mcp/features.test.mjs +66 -0
- package/src/runtime/agent/orchestrator/mcp/security.test.mjs +36 -20
- package/src/runtime/agent/orchestrator/providers/admission-scheduler-metrics.test.mjs +47 -0
- package/src/runtime/agent/orchestrator/providers/admission-scheduler.mjs +16 -3
- package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +54 -33
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +1 -0
- package/src/runtime/agent/orchestrator/providers/antigravity-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-schema.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/gemini-stream.mjs +3 -8
- package/src/runtime/agent/orchestrator/providers/gemini.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +19 -3
- package/src/runtime/agent/orchestrator/providers/lib/provider-replay.mjs +50 -0
- package/src/runtime/agent/orchestrator/providers/lib/sse-framing.mjs +93 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -9
- package/src/runtime/agent/orchestrator/providers/media-parity.test.mjs +59 -0
- package/src/runtime/agent/orchestrator/providers/model-list-sanitize.mjs +3 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +18 -26
- package/src/runtime/agent/orchestrator/providers/openai-compat-presets.mjs +8 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +25 -2
- package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +35 -4
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +47 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +103 -12
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +13 -0
- package/src/runtime/agent/orchestrator/providers/openai-turn-state.mjs +68 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta-parity.test.mjs +122 -6
- package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +36 -11
- package/src/runtime/agent/orchestrator/providers/openai-ws-pool.mjs +144 -93
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +18 -13
- package/src/runtime/agent/orchestrator/providers/provider-replay.test.mjs +288 -0
- package/src/runtime/agent/orchestrator/providers/sse-framing.test.mjs +719 -0
- package/src/runtime/agent/orchestrator/providers/stream-json-pool.mjs +399 -39
- package/src/runtime/agent/orchestrator/providers/stream-json-worker.mjs +26 -0
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +86 -7
- package/src/runtime/agent/orchestrator/session/agent-loop.test.mjs +222 -0
- package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.mjs +61 -0
- package/src/runtime/agent/orchestrator/session/browser-snapshot-supersession.test.mjs +83 -0
- package/src/runtime/agent/orchestrator/session/cache/read-cache.mjs +13 -0
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +26 -2
- package/src/runtime/agent/orchestrator/session/compact-policy.test.mjs +112 -0
- package/src/runtime/agent/orchestrator/session/compaction-read-reset.test.mjs +192 -0
- package/src/runtime/agent/orchestrator/session/context-usage-restart.test.mjs +72 -0
- package/src/runtime/agent/orchestrator/session/context-utils.mjs +100 -15
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +17 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.test.mjs +74 -0
- package/src/runtime/agent/orchestrator/session/image-strip-recovery.mjs +12 -8
- package/src/runtime/agent/orchestrator/session/image-strip-recovery.test.mjs +20 -1
- package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +110 -2
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +3 -16
- package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.test.mjs +28 -0
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/loop/steering.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +6 -0
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +111 -39
- package/src/runtime/agent/orchestrator/session/manager/ask-session.test.mjs +46 -0
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +30 -6
- package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +27 -3
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +26 -54
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +63 -1
- package/src/runtime/agent/orchestrator/session/manager/status-telemetry.mjs +3 -0
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +4 -110
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.mjs +580 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-journal.test.mjs +593 -0
- package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +119 -190
- package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +115 -0
- package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +43 -8
- package/src/runtime/agent/orchestrator/session/provider-prefix-guard.mjs +129 -12
- package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +9 -2
- package/src/runtime/agent/orchestrator/session/store/listing.mjs +8 -1
- package/src/runtime/agent/orchestrator/session/store/save-worker.mjs +15 -1
- package/src/runtime/agent/orchestrator/session/store-live-publication.test.mjs +83 -0
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +36 -2
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +157 -30
- package/src/runtime/agent/orchestrator/session/store-summary-reader.test.mjs +64 -0
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.mjs +57 -0
- package/src/runtime/agent/orchestrator/session/store-summary-visibility.test.mjs +219 -0
- package/src/runtime/agent/orchestrator/session/store.mjs +26 -2
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +22 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool-cwd-env.test.mjs +14 -0
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +19 -10
- package/src/runtime/agent/orchestrator/tools/builtin/find-search-budget.test.mjs +30 -14
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-policy.mjs +15 -4
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +73 -15
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +76 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +24 -683
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +64 -72
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-runner.mjs +2 -5
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.mjs +75 -0
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-transport.test.mjs +24 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +6 -6
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +24 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.test.mjs +44 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +12 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +35 -21
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +28 -13
- package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.test.mjs +54 -0
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/progress-message.mjs +12 -0
- package/src/runtime/agent/orchestrator/tools/spawn-manifest.json +11 -11
- package/src/runtime/attachments/pdf-extract.mjs +3 -0
- package/src/runtime/attachments/pdfjs-runtime.mjs +13 -0
- package/src/runtime/bridge-clients.test.mjs +827 -0
- package/src/runtime/browser-bridge/action-schema.mjs +251 -0
- package/src/runtime/browser-bridge/client.mjs +152 -0
- package/src/runtime/browser-bridge/tool-defs.mjs +159 -0
- package/src/runtime/computer-bridge/action-schema.mjs +653 -0
- package/src/runtime/computer-bridge/client.mjs +231 -0
- package/src/runtime/computer-bridge/tool-defs.mjs +33 -0
- package/src/runtime/media/store.mjs +18 -8
- package/src/runtime/media/store.test.mjs +14 -0
- package/src/runtime/memory/index.mjs +1 -1
- package/src/runtime/memory/lib/transcript-ingest.mjs +8 -6
- package/src/runtime/memory/lib/transcript-ingest.test.mjs +52 -0
- package/src/runtime/office/assurance-benchmark.mjs +358 -0
- package/src/runtime/office/assurance.mjs +833 -0
- package/src/runtime/office/benchmark.mjs +496 -0
- package/src/runtime/office/capabilities.mjs +551 -0
- package/src/runtime/office/com-adapter.mjs +283 -0
- package/src/runtime/office/content-model.mjs +183 -0
- package/src/runtime/office/contract-benchmark.mjs +105 -0
- package/src/runtime/office/design-library.mjs +1562 -0
- package/src/runtime/office/design-system.mjs +1966 -0
- package/src/runtime/office/index.mjs +2413 -0
- package/src/runtime/office/journal.mjs +82 -0
- package/src/runtime/office/office-assurance.test.mjs +371 -0
- package/src/runtime/office/office-com-host.ps1 +3781 -0
- package/src/runtime/office/office-com-session-host.ps1 +855 -0
- package/src/runtime/office/office-live-runtime.test.mjs +819 -0
- package/src/runtime/office/office-runtime.test.mjs +2070 -0
- package/src/runtime/office/ooxml-validator.mjs +271 -0
- package/src/runtime/office/pagination.mjs +132 -0
- package/src/runtime/office/pdf-adapter.mjs +591 -0
- package/src/runtime/office/pdf-analysis.mjs +372 -0
- package/src/runtime/office/pdf-render.mjs +167 -0
- package/src/runtime/office/pdf-security.mjs +63 -0
- package/src/runtime/office/polish-benchmark.mjs +190 -0
- package/src/runtime/office/portable-ooxml.mjs +1867 -0
- package/src/runtime/office/quality-live-benchmark.mjs +486 -0
- package/src/runtime/office/quality-pipeline.mjs +136 -0
- package/src/runtime/office/tabular.mjs +302 -0
- package/src/runtime/office/templates/mixdog-executive.pptx +0 -0
- package/src/runtime/office/templates/mixdog-executive.pptx.mixdog.json +190 -0
- package/src/runtime/office/tool-defs.mjs +113 -0
- package/src/runtime/office/visual-diff.mjs +89 -0
- package/src/runtime/office/xlsx-assertions.mjs +172 -0
- package/src/runtime/office/xlsx-contract.mjs +147 -0
- package/src/runtime/shared/agent-route-config.mjs +9 -0
- package/src/runtime/shared/child-spawn-gate.mjs +17 -0
- package/src/runtime/shared/child-spawn-remote.mjs +69 -7
- package/src/runtime/shared/child-spawn-remote.test.mjs +124 -0
- package/src/runtime/shared/pristine-execution-contract.json +2 -1
- package/src/runtime/shared/provider-api-key.mjs +1 -0
- package/src/runtime/shared/session-runtime-health.mjs +91 -0
- package/src/runtime/shared/session-runtime-health.test.mjs +53 -0
- package/src/runtime/shared/skill-document.mjs +86 -0
- package/src/runtime/shared/skill-document.test.mjs +81 -0
- package/src/runtime/shared/tool-execution-contract.mjs +16 -0
- package/src/runtime/shared/tool-surface.mjs +27 -0
- package/src/runtime/shared/turn-snapshot-store.mjs +1 -0
- package/src/runtime/shared/turn-snapshot.mjs +32 -9
- package/src/runtime/shared/user-cwd.mjs +33 -14
- package/src/runtime/shared/user-cwd.test.mjs +43 -0
- package/src/session-runtime/agent-disable.test.mjs +20 -1
- package/src/session-runtime/context-status.mjs +9 -3
- package/src/session-runtime/cwd-plugins.mjs +26 -26
- package/src/session-runtime/cwd-plugins.test.mjs +75 -0
- package/src/session-runtime/cwd-tool-routing.test.mjs +5 -0
- package/src/session-runtime/deferred-tool-delta.mjs +104 -0
- package/src/session-runtime/deferred-tool-delta.test.mjs +40 -0
- package/src/session-runtime/global-extensions.mjs +26 -0
- package/src/session-runtime/global-extensions.test.mjs +28 -0
- package/src/session-runtime/global-settings-session-identity.test.mjs +76 -0
- package/src/session-runtime/goal-runtime.mjs +1141 -0
- package/src/session-runtime/goal-runtime.test.mjs +645 -0
- package/src/session-runtime/lifecycle-api.mjs +58 -11
- package/src/session-runtime/lifecycle-api.test.mjs +183 -0
- package/src/session-runtime/mcp-glue.mjs +49 -47
- package/src/session-runtime/plugin-mcp-standard.test.mjs +177 -0
- package/src/session-runtime/plugin-mcp.mjs +96 -22
- package/src/session-runtime/prewarm.mjs +1 -10
- package/src/session-runtime/provider-request-snapshot.mjs +33 -9
- package/src/session-runtime/provider-request-snapshot.test.mjs +27 -5
- package/src/session-runtime/resource-api-global.test.mjs +109 -0
- package/src/session-runtime/resource-api.mjs +168 -116
- package/src/session-runtime/runtime-core.mjs +208 -29
- package/src/session-runtime/runtime-tunables.mjs +6 -8
- package/src/session-runtime/session-lifecycle.mjs +34 -12
- package/src/session-runtime/session-title.mjs +2 -2
- package/src/session-runtime/session-turn-api.mjs +37 -25
- package/src/session-runtime/skills-api.mjs +79 -25
- package/src/session-runtime/skills-api.test.mjs +99 -0
- package/src/session-runtime/tool-catalog-data.mjs +1 -1
- package/src/session-runtime/tool-catalog-schema.mjs +0 -5
- package/src/session-runtime/tool-catalog.mjs +42 -112
- package/src/session-runtime/tool-defs.mjs +6 -1
- package/src/session-runtime/tool-policy-surface.test.mjs +93 -17
- package/src/session-runtime/tool-surface.mjs +30 -8
- package/src/session-runtime/workflow-agents-api.mjs +8 -25
- package/src/session-runtime/workflow.mjs +8 -6
- package/src/standalone/agent-dispatch-broker.mjs +5 -9
- package/src/standalone/agent-tool/helpers.mjs +2 -2
- package/src/standalone/agent-tool/job-views.mjs +3 -1
- package/src/standalone/agent-tool/notify.mjs +11 -9
- package/src/standalone/agent-tool/notify.test.mjs +37 -0
- package/src/standalone/agent-tool/render.mjs +3 -0
- package/src/standalone/agent-tool/spawn-flow.mjs +61 -16
- package/src/standalone/agent-tool/spawn-flow.test.mjs +41 -0
- package/src/standalone/agent-tool/tool-def.mjs +1 -1
- package/src/standalone/agent-tool/worker-index.mjs +123 -0
- package/src/standalone/agent-tool/worker-index.test.mjs +132 -0
- package/src/standalone/agent-tool/worker-rows.mjs +2 -1
- package/src/standalone/agent-tool.mjs +57 -10
- package/src/standalone/daemon.mjs +113 -15
- package/src/standalone/hook-bus/config.mjs +1 -0
- package/src/standalone/plugin-admin.mjs +17 -0
- package/src/standalone/plugin-admin.test.mjs +7 -0
- package/src/standalone/provider-admin.mjs +1 -0
- package/src/standalone/session-protocol.mjs +5 -0
- package/src/standalone/session-runtime-agent-control-client.mjs +133 -0
- package/src/standalone/session-runtime-agent-control-client.test.mjs +71 -0
- package/src/standalone/session-runtime-dispatch-cancel.test.mjs +139 -0
- package/src/standalone/session-runtime-host-factory.mjs +17 -0
- package/src/standalone/session-runtime-host-factory.test.mjs +31 -0
- package/src/standalone/session-runtime-host-health.test.mjs +5 -0
- package/src/standalone/session-runtime-host.mjs +709 -58
- package/src/standalone/session-runtime-inline-host.mjs +258 -0
- package/src/standalone/session-runtime-inline-host.test.mjs +385 -0
- package/src/standalone/session-runtime-provider-cooldown.mjs +78 -0
- package/src/standalone/session-runtime-provider-cooldown.test.mjs +80 -0
- package/src/standalone/session-runtime-shard-host.test.mjs +642 -0
- package/src/standalone/session-runtime-shard-router.mjs +149 -0
- package/src/standalone/session-runtime-shard-router.test.mjs +101 -0
- package/src/standalone/session-runtime-worker.mjs +302 -26
- package/src/standalone/session-service-agent-tree.test.mjs +984 -0
- package/src/standalone/session-service-goal-recovery.test.mjs +177 -0
- package/src/standalone/session-service.mjs +711 -11
- package/src/tui/app/app-view.jsx +1 -0
- package/src/tui/app/core-memory-picker.mjs +2 -0
- package/src/tui/app/extension-pickers.mjs +1 -1
- package/src/tui/app/maintenance-pickers.mjs +3 -3
- package/src/tui/app/model-options.mjs +2 -0
- package/src/tui/app/model-picker.mjs +14 -4
- package/src/tui/app/panel-handoff.test.mjs +311 -0
- package/src/tui/app/project-picker.mjs +1 -0
- package/src/tui/app/route-pickers.mjs +21 -6
- package/src/tui/app/settings-picker.mjs +26 -3
- package/src/tui/app/slash-commands.mjs +1 -0
- package/src/tui/app/slash-dispatch.mjs +73 -22
- package/src/tui/app/theme-effort-pickers.mjs +7 -2
- package/src/tui/components/Picker.jsx +10 -1
- package/src/tui/dist/index.mjs +187 -45
- package/src/tui/lib/voice-setup.mjs +4 -1
- package/src/tui/session/agent-message-source.test.mjs +37 -0
- package/src/tui/session/completion-card-restore.test.mjs +50 -0
- package/src/tui/session/context-state.mjs +3 -13
- package/src/tui/session/goal-continuation.mjs +141 -0
- package/src/tui/session/goal-continuation.test.mjs +203 -0
- package/src/tui/session/queue-helpers.mjs +5 -2
- package/src/tui/session/session-action-surface.test.mjs +190 -0
- package/src/tui/session/session-api-ext.mjs +48 -8
- package/src/tui/session/session-api.mjs +73 -1
- package/src/tui/session/session-flow.mjs +31 -1
- package/src/tui/session/turn.mjs +88 -15
- package/src/tui/session-local.mjs +39 -3
- package/src/vendor/statusline/src/gateway/route-meta.mjs +31 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +0 -301
|
@@ -0,0 +1,1867 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, extname, join, posix } from 'node:path';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
import JSZip from 'jszip';
|
|
7
|
+
|
|
8
|
+
const OOXML_REQUIRED = {
|
|
9
|
+
docx: ['[Content_Types].xml', 'word/document.xml'],
|
|
10
|
+
xlsx: ['[Content_Types].xml', 'xl/workbook.xml'],
|
|
11
|
+
pptx: ['[Content_Types].xml', 'ppt/presentation.xml'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function xmlDecode(value = '') {
|
|
15
|
+
return String(value)
|
|
16
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
17
|
+
.replace(/&#(\d+);/g, (_, decimal) => String.fromCodePoint(Number.parseInt(decimal, 10)))
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, "'")
|
|
22
|
+
.replace(/&/g, '&');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function xmlEncode(value = '') {
|
|
26
|
+
return String(value)
|
|
27
|
+
.replace(/&/g, '&')
|
|
28
|
+
.replace(/</g, '<')
|
|
29
|
+
.replace(/>/g, '>')
|
|
30
|
+
.replace(/"/g, '"')
|
|
31
|
+
.replace(/'/g, ''');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function tagPattern(tag) {
|
|
35
|
+
return tag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function textNodes(xml, tag) {
|
|
39
|
+
const regex = new RegExp(`<${tagPattern(tag)}(\\s[^>]*)?>([\\s\\S]*?)</${tagPattern(tag)}>`, 'g');
|
|
40
|
+
const nodes = [];
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = regex.exec(xml))) {
|
|
43
|
+
nodes.push({
|
|
44
|
+
start: match.index,
|
|
45
|
+
end: regex.lastIndex,
|
|
46
|
+
attrs: match[1] || '',
|
|
47
|
+
text: xmlDecode(match[2]),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
return nodes;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rebuildTextNodes(xml, tag, nodes) {
|
|
54
|
+
let cursor = 0;
|
|
55
|
+
const chunks = [];
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
chunks.push(xml.slice(cursor, node.start));
|
|
58
|
+
let attrs = node.attrs;
|
|
59
|
+
if (/^\s|\s$/.test(node.text) && !/\bxml:space=/.test(attrs)) attrs += ' xml:space="preserve"';
|
|
60
|
+
chunks.push(`<${tag}${attrs}>${xmlEncode(node.text)}</${tag}>`);
|
|
61
|
+
cursor = node.end;
|
|
62
|
+
}
|
|
63
|
+
chunks.push(xml.slice(cursor));
|
|
64
|
+
return chunks.join('');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function nodeAtOffset(nodes, offset, usePreviousAtBoundary = false) {
|
|
68
|
+
let current = 0;
|
|
69
|
+
for (let index = 0; index < nodes.length; index += 1) {
|
|
70
|
+
const next = current + nodes[index].text.length;
|
|
71
|
+
if (offset < next || (usePreviousAtBoundary && offset === next && next > current)) {
|
|
72
|
+
return { index, offset: Math.max(0, offset - current) };
|
|
73
|
+
}
|
|
74
|
+
current = next;
|
|
75
|
+
}
|
|
76
|
+
return nodes.length ? { index: nodes.length - 1, offset: nodes.at(-1).text.length } : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function replaceAcrossRuns(xml, tag, find, replacement) {
|
|
80
|
+
if (!find) throw new Error('replace_text requires non-empty find');
|
|
81
|
+
const nodes = textNodes(xml, tag);
|
|
82
|
+
if (!nodes.length) return { xml, count: 0 };
|
|
83
|
+
const joined = nodes.map((node) => node.text).join('');
|
|
84
|
+
const occurrences = [];
|
|
85
|
+
let cursor = 0;
|
|
86
|
+
while (cursor <= joined.length - find.length) {
|
|
87
|
+
const index = joined.indexOf(find, cursor);
|
|
88
|
+
if (index < 0) break;
|
|
89
|
+
occurrences.push(index);
|
|
90
|
+
cursor = index + Math.max(1, find.length);
|
|
91
|
+
}
|
|
92
|
+
for (let occurrence = occurrences.length - 1; occurrence >= 0; occurrence -= 1) {
|
|
93
|
+
const start = nodeAtOffset(nodes, occurrences[occurrence]);
|
|
94
|
+
const end = nodeAtOffset(nodes, occurrences[occurrence] + find.length, true);
|
|
95
|
+
if (!start || !end) continue;
|
|
96
|
+
if (start.index === end.index) {
|
|
97
|
+
const source = nodes[start.index].text;
|
|
98
|
+
nodes[start.index].text = `${source.slice(0, start.offset)}${replacement}${source.slice(end.offset)}`;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const first = nodes[start.index];
|
|
102
|
+
const last = nodes[end.index];
|
|
103
|
+
first.text = `${first.text.slice(0, start.offset)}${replacement}`;
|
|
104
|
+
for (let index = start.index + 1; index < end.index; index += 1) nodes[index].text = '';
|
|
105
|
+
last.text = last.text.slice(end.offset);
|
|
106
|
+
}
|
|
107
|
+
return { xml: rebuildTextNodes(xml, tag, nodes), count: occurrences.length };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function templateTokenMatches(text) {
|
|
111
|
+
return [...String(text || '').matchAll(/\{\{\s*([A-Za-z0-9_.-]+)\s*\}\}/g)]
|
|
112
|
+
.map((match) => ({ raw: match[0], key: match[1] }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function fillTemplateParts(zip, parts, tag, operation) {
|
|
116
|
+
const tokens = operation.tokens;
|
|
117
|
+
if (!tokens || typeof tokens !== 'object' || Array.isArray(tokens)) {
|
|
118
|
+
throw new Error('fill_template requires tokens as an object');
|
|
119
|
+
}
|
|
120
|
+
const filled = {};
|
|
121
|
+
for (const part of parts) {
|
|
122
|
+
let xml = await zipText(zip, part);
|
|
123
|
+
const variants = new Map(templateTokenMatches(paragraphTexts(xml, tag).join('')).map((match) => [match.raw, match.key]));
|
|
124
|
+
let changed = false;
|
|
125
|
+
for (const [raw, key] of variants) {
|
|
126
|
+
if (!Object.hasOwn(tokens, key)) continue;
|
|
127
|
+
const replaced = replaceAcrossRuns(xml, tag, raw, String(tokens[key] ?? ''));
|
|
128
|
+
if (!replaced.count) continue;
|
|
129
|
+
xml = replaced.xml;
|
|
130
|
+
changed = true;
|
|
131
|
+
filled[key] = (filled[key] || 0) + replaced.count;
|
|
132
|
+
}
|
|
133
|
+
if (changed) zip.file(part, xml);
|
|
134
|
+
}
|
|
135
|
+
const remaining = new Set();
|
|
136
|
+
for (const part of parts) {
|
|
137
|
+
const xml = await zipText(zip, part);
|
|
138
|
+
for (const match of templateTokenMatches(paragraphTexts(xml, tag).join(''))) remaining.add(match.key);
|
|
139
|
+
}
|
|
140
|
+
const unfilledTokens = [...remaining].sort();
|
|
141
|
+
if (operation.strict && unfilledTokens.length) {
|
|
142
|
+
throw new Error(`Unfilled template tokens: ${unfilledTokens.join(', ')}`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
op: 'fill_template',
|
|
146
|
+
changed: Object.keys(filled).length > 0,
|
|
147
|
+
filled,
|
|
148
|
+
unfilledTokens,
|
|
149
|
+
strict: operation.strict === true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function loadPackage(path) {
|
|
154
|
+
return await JSZip.loadAsync(await readFile(path), {
|
|
155
|
+
checkCRC32: true,
|
|
156
|
+
createFolders: false,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function zipText(zip, path) {
|
|
161
|
+
const file = zip.file(path);
|
|
162
|
+
return file ? await file.async('string') : '';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function savePackage(zip, path) {
|
|
166
|
+
const data = await zip.generateAsync({
|
|
167
|
+
type: 'nodebuffer',
|
|
168
|
+
compression: 'DEFLATE',
|
|
169
|
+
compressionOptions: { level: 6 },
|
|
170
|
+
platform: 'DOS',
|
|
171
|
+
});
|
|
172
|
+
await writeFile(path, data);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function paragraphTexts(xml, tag) {
|
|
176
|
+
return textNodes(xml, tag).map((node) => node.text).filter(Boolean);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function topLevelElements(fragment, acceptedTags) {
|
|
180
|
+
const accepted = new Set(acceptedTags);
|
|
181
|
+
const elements = [];
|
|
182
|
+
const stack = [];
|
|
183
|
+
let tracked = null;
|
|
184
|
+
const regex = /<([/]?)([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?([/]?)>/g;
|
|
185
|
+
let match;
|
|
186
|
+
while ((match = regex.exec(fragment))) {
|
|
187
|
+
const closing = match[1] === '/';
|
|
188
|
+
const name = match[2];
|
|
189
|
+
const selfClosing = match[3] === '/';
|
|
190
|
+
if (closing) {
|
|
191
|
+
const opened = stack.pop();
|
|
192
|
+
if (tracked && stack.length === 0 && opened === tracked.name) {
|
|
193
|
+
elements.push({
|
|
194
|
+
name: tracked.name,
|
|
195
|
+
start: tracked.start,
|
|
196
|
+
end: regex.lastIndex,
|
|
197
|
+
xml: fragment.slice(tracked.start, regex.lastIndex),
|
|
198
|
+
});
|
|
199
|
+
tracked = null;
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (stack.length === 0 && accepted.has(name)) {
|
|
204
|
+
if (selfClosing) {
|
|
205
|
+
elements.push({ name, start: match.index, end: regex.lastIndex, xml: match[0] });
|
|
206
|
+
} else {
|
|
207
|
+
tracked = { name, start: match.index };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!selfClosing) stack.push(name);
|
|
211
|
+
}
|
|
212
|
+
return elements;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function containerInner(xml, tag) {
|
|
216
|
+
const open = new RegExp(`<${tagPattern(tag)}(?:\\s[^>]*)?>`).exec(xml);
|
|
217
|
+
const close = new RegExp(`</${tagPattern(tag)}>`).exec(xml);
|
|
218
|
+
if (!open || !close || close.index < open.index) return null;
|
|
219
|
+
const start = open.index + open[0].length;
|
|
220
|
+
return { start, end: close.index, inner: xml.slice(start, close.index) };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function docxBodyModel(documentXml) {
|
|
224
|
+
const body = containerInner(documentXml, 'w:body');
|
|
225
|
+
if (!body) return { paragraphs: [], tables: [], blocks: [] };
|
|
226
|
+
const blocks = topLevelElements(body.inner, ['w:p', 'w:tbl']);
|
|
227
|
+
let paragraphIndex = 0;
|
|
228
|
+
let tableIndex = 0;
|
|
229
|
+
const paragraphs = [];
|
|
230
|
+
const tables = [];
|
|
231
|
+
for (const block of blocks) {
|
|
232
|
+
if (block.name === 'w:p') {
|
|
233
|
+
paragraphIndex += 1;
|
|
234
|
+
const runs = textNodes(block.xml, 'w:t').map((node, index) => ({
|
|
235
|
+
path: `/body/p[${paragraphIndex}]/run[${index + 1}]`,
|
|
236
|
+
index: index + 1,
|
|
237
|
+
text: node.text,
|
|
238
|
+
}));
|
|
239
|
+
paragraphs.push({
|
|
240
|
+
path: `/body/p[${paragraphIndex}]`,
|
|
241
|
+
index: paragraphIndex,
|
|
242
|
+
text: runs.map((run) => run.text).join(''),
|
|
243
|
+
style: xmlDecode(/<w:pStyle\b[^>]*\bw:val="([^"]+)"/.exec(block.xml)?.[1] || ''),
|
|
244
|
+
runs,
|
|
245
|
+
});
|
|
246
|
+
block.logicalIndex = paragraphIndex;
|
|
247
|
+
} else {
|
|
248
|
+
tableIndex += 1;
|
|
249
|
+
const tableInner = containerInner(block.xml, 'w:tbl')?.inner || '';
|
|
250
|
+
const rows = topLevelElements(tableInner, ['w:tr']).map((row, rowIndex) => ({
|
|
251
|
+
path: `/body/tbl[${tableIndex}]/row[${rowIndex + 1}]`,
|
|
252
|
+
index: rowIndex + 1,
|
|
253
|
+
cells: topLevelElements(containerInner(row.xml, 'w:tr')?.inner || '', ['w:tc']).map((cell, cellIndex) => ({
|
|
254
|
+
path: `/body/tbl[${tableIndex}]/row[${rowIndex + 1}]/cell[${cellIndex + 1}]`,
|
|
255
|
+
index: cellIndex + 1,
|
|
256
|
+
text: paragraphTexts(cell.xml, 'w:t').join(''),
|
|
257
|
+
})),
|
|
258
|
+
}));
|
|
259
|
+
tables.push({ path: `/body/tbl[${tableIndex}]`, index: tableIndex, rows });
|
|
260
|
+
block.logicalIndex = tableIndex;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { paragraphs, tables, blocks, body };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function snapshotDocx(zip, options = {}) {
|
|
267
|
+
const parts = Object.keys(zip.files)
|
|
268
|
+
.filter((name) => /^word\/(document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/i.test(name))
|
|
269
|
+
.sort();
|
|
270
|
+
const content = [];
|
|
271
|
+
for (const part of parts) {
|
|
272
|
+
const xml = await zipText(zip, part);
|
|
273
|
+
content.push({ part, text: paragraphTexts(xml, 'w:t').join('') });
|
|
274
|
+
}
|
|
275
|
+
const documentXml = await zipText(zip, 'word/document.xml');
|
|
276
|
+
const model = docxBodyModel(documentXml);
|
|
277
|
+
const paged = options.paged === true;
|
|
278
|
+
const offset = paged ? Math.max(0, Number(options.offset) || 0) : 0;
|
|
279
|
+
const limit = paged ? Math.max(1, Number(options.limit) || 200) : model.blocks.length;
|
|
280
|
+
let selectedBlocks = model.blocks;
|
|
281
|
+
if (paged && options.target) {
|
|
282
|
+
const paragraph = /^\/body\/p\[(\d+)]/.exec(String(options.target));
|
|
283
|
+
const table = /^\/body\/tbl\[(\d+)]/.exec(String(options.target));
|
|
284
|
+
selectedBlocks = paragraph
|
|
285
|
+
? model.blocks.filter((block) => block.name === 'w:p' && block.logicalIndex === Number(paragraph[1]))
|
|
286
|
+
: table
|
|
287
|
+
? model.blocks.filter((block) => block.name === 'w:tbl' && block.logicalIndex === Number(table[1]))
|
|
288
|
+
: model.blocks.slice(offset, offset + limit);
|
|
289
|
+
} else if (paged) {
|
|
290
|
+
selectedBlocks = model.blocks.slice(offset, offset + limit);
|
|
291
|
+
}
|
|
292
|
+
const paragraphIndexes = new Set(selectedBlocks.filter((block) => block.name === 'w:p').map((block) => block.logicalIndex));
|
|
293
|
+
const tableIndexes = new Set(selectedBlocks.filter((block) => block.name === 'w:tbl').map((block) => block.logicalIndex));
|
|
294
|
+
const storyParts = parts.filter((name) => !/\/comments\.xml$/i.test(name));
|
|
295
|
+
const comments = [];
|
|
296
|
+
const commentsXml = await zipText(zip, 'word/comments.xml');
|
|
297
|
+
for (const match of commentsXml.matchAll(/<w:comment\b([^>]*)>([\s\S]*?)<\/w:comment>/g)) {
|
|
298
|
+
const attributes = match[1];
|
|
299
|
+
const id = xmlDecode(/\bw:id="([^"]+)"/.exec(attributes)?.[1] || '');
|
|
300
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
301
|
+
let anchoredText = '';
|
|
302
|
+
let anchoredPart = '';
|
|
303
|
+
for (const part of storyParts) {
|
|
304
|
+
const xml = await zipText(zip, part);
|
|
305
|
+
const start = new RegExp(`<w:commentRangeStart\\b[^>]*\\bw:id="${escapedId}"[^>]*/?>`).exec(xml);
|
|
306
|
+
const end = new RegExp(`<w:commentRangeEnd\\b[^>]*\\bw:id="${escapedId}"[^>]*/?>`).exec(xml);
|
|
307
|
+
if (!start || !end || end.index < start.index) continue;
|
|
308
|
+
anchoredText = paragraphTexts(xml.slice(start.index + start[0].length, end.index), 'w:t').join('');
|
|
309
|
+
anchoredPart = part;
|
|
310
|
+
break;
|
|
311
|
+
}
|
|
312
|
+
comments.push({
|
|
313
|
+
path: `/body/comment[${comments.length + 1}]`,
|
|
314
|
+
index: comments.length + 1,
|
|
315
|
+
id,
|
|
316
|
+
author: xmlDecode(/\bw:author="([^"]*)"/.exec(attributes)?.[1] || ''),
|
|
317
|
+
initials: xmlDecode(/\bw:initials="([^"]*)"/.exec(attributes)?.[1] || ''),
|
|
318
|
+
date: xmlDecode(/\bw:date="([^"]*)"/.exec(attributes)?.[1] || ''),
|
|
319
|
+
text: paragraphTexts(match[2], 'w:t').join(''),
|
|
320
|
+
anchoredText,
|
|
321
|
+
part: anchoredPart,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
const revisions = [];
|
|
325
|
+
for (const part of storyParts) {
|
|
326
|
+
const xml = await zipText(zip, part);
|
|
327
|
+
for (const match of xml.matchAll(/<w:(ins|del)\b([^>]*)>([\s\S]*?)<\/w:\1>/g)) {
|
|
328
|
+
const kind = match[1];
|
|
329
|
+
const attributes = match[2];
|
|
330
|
+
revisions.push({
|
|
331
|
+
path: `/body/revision[${revisions.length + 1}]`,
|
|
332
|
+
index: revisions.length + 1,
|
|
333
|
+
id: xmlDecode(/\bw:id="([^"]+)"/.exec(attributes)?.[1] || ''),
|
|
334
|
+
author: xmlDecode(/\bw:author="([^"]*)"/.exec(attributes)?.[1] || ''),
|
|
335
|
+
date: xmlDecode(/\bw:date="([^"]*)"/.exec(attributes)?.[1] || ''),
|
|
336
|
+
type: kind === 'ins' ? 'insertion' : 'deletion',
|
|
337
|
+
text: paragraphTexts(match[3], kind === 'ins' ? 'w:t' : 'w:delText').join(''),
|
|
338
|
+
part,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const notes = [];
|
|
343
|
+
for (const [kind, part, tag] of [
|
|
344
|
+
['footnote', 'word/footnotes.xml', 'w:footnote'],
|
|
345
|
+
['endnote', 'word/endnotes.xml', 'w:endnote'],
|
|
346
|
+
]) {
|
|
347
|
+
const xml = await zipText(zip, part);
|
|
348
|
+
const pattern = new RegExp(`<${tag}\\b([^>]*)>([\\s\\S]*?)<\\/${tag}>`, 'g');
|
|
349
|
+
for (const match of xml.matchAll(pattern)) {
|
|
350
|
+
const id = xmlDecode(/\bw:id="([^"]+)"/.exec(match[1])?.[1] || '');
|
|
351
|
+
if (Number(id) < 0) continue;
|
|
352
|
+
notes.push({
|
|
353
|
+
path: `/body/${kind}[${notes.filter((entry) => entry.kind === kind).length + 1}]`,
|
|
354
|
+
kind,
|
|
355
|
+
id,
|
|
356
|
+
text: paragraphTexts(match[2], 'w:t').join(''),
|
|
357
|
+
part,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const contentControls = [];
|
|
362
|
+
for (const part of storyParts) {
|
|
363
|
+
const xml = await zipText(zip, part);
|
|
364
|
+
for (const match of xml.matchAll(/<w:sdt\b[^>]*>([\s\S]*?)<\/w:sdt>/g)) {
|
|
365
|
+
const properties = /<w:sdtPr\b[^>]*>([\s\S]*?)<\/w:sdtPr>/.exec(match[1])?.[1] || '';
|
|
366
|
+
contentControls.push({
|
|
367
|
+
path: `/body/content-control[${contentControls.length + 1}]`,
|
|
368
|
+
index: contentControls.length + 1,
|
|
369
|
+
tag: xmlDecode(/<w:tag\b[^>]*\bw:val="([^"]*)"/.exec(properties)?.[1] || ''),
|
|
370
|
+
title: xmlDecode(/<w:alias\b[^>]*\bw:val="([^"]*)"/.exec(properties)?.[1] || ''),
|
|
371
|
+
lock: xmlDecode(/<w:lock\b[^>]*\bw:val="([^"]*)"/.exec(properties)?.[1] || ''),
|
|
372
|
+
text: paragraphTexts(match[1], 'w:t').join(''),
|
|
373
|
+
part,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const commentThreads = [];
|
|
378
|
+
const commentsExtended = await zipText(zip, 'word/commentsExtended.xml');
|
|
379
|
+
for (const match of commentsExtended.matchAll(/<w15:commentEx\b([^>]*?)\/?>/g)) {
|
|
380
|
+
commentThreads.push({
|
|
381
|
+
path: `/body/comment-thread[${commentThreads.length + 1}]`,
|
|
382
|
+
index: commentThreads.length + 1,
|
|
383
|
+
paraId: xmlDecode(/\bw15:paraId="([^"]*)"/.exec(match[1])?.[1] || ''),
|
|
384
|
+
parentParaId: xmlDecode(/\bw15:paraIdParent="([^"]*)"/.exec(match[1])?.[1] || ''),
|
|
385
|
+
resolved: /^(?:1|true)$/i.test(/\bw15:done="([^"]*)"/.exec(match[1])?.[1] || ''),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
format: 'docx',
|
|
390
|
+
path: '/',
|
|
391
|
+
paragraphCount: model.paragraphs.length,
|
|
392
|
+
tableCount: model.tables.length,
|
|
393
|
+
paragraphs: paged ? model.paragraphs.filter((paragraph) => paragraphIndexes.has(paragraph.index)) : model.paragraphs,
|
|
394
|
+
tables: paged ? model.tables.filter((table) => tableIndexes.has(table.index)) : model.tables,
|
|
395
|
+
blockOrder: selectedBlocks.map((block) => ({
|
|
396
|
+
type: block.name === 'w:p' ? 'paragraph' : 'table',
|
|
397
|
+
index: block.logicalIndex,
|
|
398
|
+
path: block.name === 'w:p'
|
|
399
|
+
? `/body/p[${block.logicalIndex}]`
|
|
400
|
+
: `/body/tbl[${block.logicalIndex}]`,
|
|
401
|
+
start: block.start,
|
|
402
|
+
})),
|
|
403
|
+
parts: paged && model.blocks.length > limit
|
|
404
|
+
? content.map((part) => ({ part: part.part, chars: part.text.length }))
|
|
405
|
+
: content,
|
|
406
|
+
commentCount: comments.length,
|
|
407
|
+
revisionCount: revisions.length,
|
|
408
|
+
comments,
|
|
409
|
+
revisions,
|
|
410
|
+
footnoteCount: notes.filter((entry) => entry.kind === 'footnote').length,
|
|
411
|
+
endnoteCount: notes.filter((entry) => entry.kind === 'endnote').length,
|
|
412
|
+
footnotes: notes.filter((entry) => entry.kind === 'footnote'),
|
|
413
|
+
endnotes: notes.filter((entry) => entry.kind === 'endnote'),
|
|
414
|
+
contentControlCount: contentControls.length,
|
|
415
|
+
contentControls,
|
|
416
|
+
commentThreadCount: commentThreads.length,
|
|
417
|
+
commentThreads,
|
|
418
|
+
...(paged ? {
|
|
419
|
+
pagination: {
|
|
420
|
+
unit: 'body-block',
|
|
421
|
+
offset,
|
|
422
|
+
limit,
|
|
423
|
+
returned: selectedBlocks.length,
|
|
424
|
+
total: model.blocks.length,
|
|
425
|
+
nextOffset: offset + selectedBlocks.length < model.blocks.length
|
|
426
|
+
? offset + selectedBlocks.length
|
|
427
|
+
: null,
|
|
428
|
+
},
|
|
429
|
+
} : {}),
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function relationshipMap(xml) {
|
|
434
|
+
const map = new Map();
|
|
435
|
+
const regex = /<Relationship\b([^>]+?)\/?>/g;
|
|
436
|
+
let match;
|
|
437
|
+
while ((match = regex.exec(xml))) {
|
|
438
|
+
const attrs = match[1];
|
|
439
|
+
const id = /\bId="([^"]+)"/.exec(attrs)?.[1];
|
|
440
|
+
const target = /\bTarget="([^"]+)"/.exec(attrs)?.[1];
|
|
441
|
+
if (id && target) map.set(id, target);
|
|
442
|
+
}
|
|
443
|
+
return map;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function workbookSheets(zip) {
|
|
447
|
+
const workbook = await zipText(zip, 'xl/workbook.xml');
|
|
448
|
+
const rels = relationshipMap(await zipText(zip, 'xl/_rels/workbook.xml.rels'));
|
|
449
|
+
const sheets = [];
|
|
450
|
+
const regex = /<sheet\b([^>]+?)\/?>/g;
|
|
451
|
+
let match;
|
|
452
|
+
while ((match = regex.exec(workbook))) {
|
|
453
|
+
const attrs = match[1];
|
|
454
|
+
const name = xmlDecode(/\bname="([^"]+)"/.exec(attrs)?.[1] || '');
|
|
455
|
+
const rid = /\br:id="([^"]+)"/.exec(attrs)?.[1];
|
|
456
|
+
const target = rid ? rels.get(rid) : '';
|
|
457
|
+
if (!name || !target) continue;
|
|
458
|
+
const normalized = target.startsWith('/') ? target.slice(1) : posix.normalize(posix.join('xl', target));
|
|
459
|
+
sheets.push({ name, path: normalized, rid });
|
|
460
|
+
}
|
|
461
|
+
return sheets;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async function sharedStrings(zip) {
|
|
465
|
+
const xml = await zipText(zip, 'xl/sharedStrings.xml');
|
|
466
|
+
if (!xml) return [];
|
|
467
|
+
const strings = [];
|
|
468
|
+
const regex = /<si(?:\s[^>]*)?>([\s\S]*?)<\/si>/g;
|
|
469
|
+
let match;
|
|
470
|
+
while ((match = regex.exec(xml))) strings.push(paragraphTexts(match[1], 't').join(''));
|
|
471
|
+
return strings;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function snapshotRangeBounds(reference) {
|
|
475
|
+
const match = /^([^:]+):([^:]+)$/.exec(String(reference || '').trim());
|
|
476
|
+
if (!match) return null;
|
|
477
|
+
const start = parseCellRef(match[1]);
|
|
478
|
+
const end = parseCellRef(match[2]);
|
|
479
|
+
return {
|
|
480
|
+
startRow: start.row,
|
|
481
|
+
endRow: end.row,
|
|
482
|
+
startCol: columnNumber(start.col),
|
|
483
|
+
endCol: columnNumber(end.col),
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function cellRecords(xml, strings, options = null) {
|
|
488
|
+
const records = [];
|
|
489
|
+
const paged = options?.paged === true;
|
|
490
|
+
const offset = paged ? Math.max(0, Number(options.offset) || 0) : 0;
|
|
491
|
+
const limit = paged ? Math.max(1, Number(options.limit) || 2_000) : Number.POSITIVE_INFINITY;
|
|
492
|
+
const bounds = paged && options.range ? snapshotRangeBounds(options.range) : null;
|
|
493
|
+
let total = 0;
|
|
494
|
+
let formulaCount = 0;
|
|
495
|
+
let formulaCacheMissing = 0;
|
|
496
|
+
const regex = /<c\b([^>]*\br="([A-Z]+\d+)"[^>]*)>([\s\S]*?)<\/c>|<c\b([^>]*\br="([A-Z]+\d+)"[^>]*)\/>/g;
|
|
497
|
+
let match;
|
|
498
|
+
while ((match = regex.exec(xml))) {
|
|
499
|
+
const attrs = match[1] || match[4] || '';
|
|
500
|
+
const ref = match[2] || match[5];
|
|
501
|
+
if (bounds) {
|
|
502
|
+
const parsed = parseCellRef(ref);
|
|
503
|
+
const column = columnNumber(parsed.col);
|
|
504
|
+
if (parsed.row < bounds.startRow || parsed.row > bounds.endRow || column < bounds.startCol || column > bounds.endCol) continue;
|
|
505
|
+
}
|
|
506
|
+
const body = match[3] || '';
|
|
507
|
+
const type = /\bt="([^"]+)"/.exec(attrs)?.[1] || '';
|
|
508
|
+
const formula = xmlDecode(/<f(?:\s[^>]*)?>([\s\S]*?)<\/f>/.exec(body)?.[1] || '');
|
|
509
|
+
let raw = '';
|
|
510
|
+
let value;
|
|
511
|
+
if (type === 'inlineStr') value = paragraphTexts(body, 't').join('');
|
|
512
|
+
else {
|
|
513
|
+
raw = xmlDecode(/<v(?:\s[^>]*)?>([\s\S]*?)<\/v>/.exec(body)?.[1] || '');
|
|
514
|
+
value = type === 's' ? strings[Number(raw)] ?? raw : raw;
|
|
515
|
+
}
|
|
516
|
+
const record = {
|
|
517
|
+
ref,
|
|
518
|
+
value,
|
|
519
|
+
...(formula ? {
|
|
520
|
+
formula,
|
|
521
|
+
cachedValue: raw === '' ? null : value,
|
|
522
|
+
cacheState: raw === '' ? 'missing' : 'present',
|
|
523
|
+
} : {}),
|
|
524
|
+
};
|
|
525
|
+
if (formula) {
|
|
526
|
+
formulaCount += 1;
|
|
527
|
+
if (record.cacheState === 'missing') formulaCacheMissing += 1;
|
|
528
|
+
}
|
|
529
|
+
if (!paged || (total >= offset && records.length < limit)) records.push(record);
|
|
530
|
+
total += 1;
|
|
531
|
+
}
|
|
532
|
+
return paged ? { records, total, formulaCount, formulaCacheMissing } : records;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function booleanXmlAttribute(attributes, name) {
|
|
536
|
+
const value = new RegExp(`\\b${name}="([^"]+)"`, 'i').exec(attributes)?.[1] || '';
|
|
537
|
+
return /^(?:1|true|on)$/i.test(value);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function workbookCalculation(xml) {
|
|
541
|
+
const attributes = /<calcPr\b([^>]*)\/?>/i.exec(xml)?.[1] || '';
|
|
542
|
+
return {
|
|
543
|
+
mode: /\bcalcMode="([^"]+)"/i.exec(attributes)?.[1] || '',
|
|
544
|
+
fullCalcOnLoad: booleanXmlAttribute(attributes, 'fullCalcOnLoad'),
|
|
545
|
+
forceFullCalc: booleanXmlAttribute(attributes, 'forceFullCalc'),
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function formulaReferences(formula, currentSheet) {
|
|
550
|
+
const references = [];
|
|
551
|
+
const seen = new Set();
|
|
552
|
+
const pattern = /(?:(?:'([^']+)'|([A-Za-z0-9_ .-]+))!)?\$?([A-Z]{1,3})\$?([1-9]\d*)/g;
|
|
553
|
+
for (const match of String(formula || '').matchAll(pattern)) {
|
|
554
|
+
const sheet = String(match[1] || match[2] || currentSheet);
|
|
555
|
+
const ref = `${match[3]}${match[4]}`;
|
|
556
|
+
const key = `${sheet}!${ref}`.toLowerCase();
|
|
557
|
+
if (seen.has(key)) continue;
|
|
558
|
+
seen.add(key);
|
|
559
|
+
references.push({ sheet, ref, path: `/sheet[${sheet}]/cell[${ref}]` });
|
|
560
|
+
}
|
|
561
|
+
return references;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function snapshotXlsx(zip, options = {}) {
|
|
565
|
+
const sheets = await workbookSheets(zip);
|
|
566
|
+
const strings = await sharedStrings(zip);
|
|
567
|
+
const workbookXml = await zipText(zip, 'xl/workbook.xml');
|
|
568
|
+
const calculation = workbookCalculation(workbookXml);
|
|
569
|
+
const definedNames = [];
|
|
570
|
+
for (const match of workbookXml.matchAll(/<definedName\b([^>]*)>([\s\S]*?)<\/definedName>/g)) {
|
|
571
|
+
const attributes = match[1];
|
|
572
|
+
definedNames.push({
|
|
573
|
+
path: `/defined-name[${definedNames.length + 1}]`,
|
|
574
|
+
index: definedNames.length + 1,
|
|
575
|
+
name: xmlDecode(/\bname="([^"]+)"/.exec(attributes)?.[1] || ''),
|
|
576
|
+
localSheetId: Number(/\blocalSheetId="(\d+)"/.exec(attributes)?.[1] ?? -1),
|
|
577
|
+
hidden: booleanXmlAttribute(attributes, 'hidden'),
|
|
578
|
+
refersTo: xmlDecode(match[2]),
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
const output = [];
|
|
582
|
+
let formulaCount = 0;
|
|
583
|
+
let formulaCacheMissing = 0;
|
|
584
|
+
const paged = options.paged === true;
|
|
585
|
+
const selectedSheets = paged
|
|
586
|
+
? [options.sheet
|
|
587
|
+
? sheets.find((sheet) => sheet.name.toLowerCase() === String(options.sheet).toLowerCase())
|
|
588
|
+
: sheets[0]].filter(Boolean)
|
|
589
|
+
: sheets;
|
|
590
|
+
if (paged && options.sheet && !selectedSheets.length) throw new Error(`XLSX sheet not found: ${options.sheet}`);
|
|
591
|
+
let page = null;
|
|
592
|
+
for (const sheet of selectedSheets) {
|
|
593
|
+
const xml = await zipText(zip, sheet.path);
|
|
594
|
+
const cellResult = cellRecords(xml, strings, paged ? options : null);
|
|
595
|
+
const cells = paged ? cellResult.records : cellResult;
|
|
596
|
+
const validations = [];
|
|
597
|
+
for (const match of xml.matchAll(/<dataValidation\b([^>]*?)(?:\/>|>([\s\S]*?)<\/dataValidation>)/g)) {
|
|
598
|
+
const attributes = match[1];
|
|
599
|
+
const body = match[2] || '';
|
|
600
|
+
validations.push({
|
|
601
|
+
path: `/sheet[${sheet.name}]/validation[${validations.length + 1}]`,
|
|
602
|
+
index: validations.length + 1,
|
|
603
|
+
ranges: xmlDecode(/\bsqref="([^"]+)"/.exec(attributes)?.[1] || '').split(/\s+/).filter(Boolean),
|
|
604
|
+
type: /\btype="([^"]+)"/.exec(attributes)?.[1] || '',
|
|
605
|
+
operator: /\boperator="([^"]+)"/.exec(attributes)?.[1] || '',
|
|
606
|
+
allowBlank: booleanXmlAttribute(attributes, 'allowBlank'),
|
|
607
|
+
showInputMessage: booleanXmlAttribute(attributes, 'showInputMessage'),
|
|
608
|
+
showErrorMessage: booleanXmlAttribute(attributes, 'showErrorMessage'),
|
|
609
|
+
formula1: xmlDecode(/<formula1(?:\s[^>]*)?>([\s\S]*?)<\/formula1>/.exec(body)?.[1] || ''),
|
|
610
|
+
formula2: xmlDecode(/<formula2(?:\s[^>]*)?>([\s\S]*?)<\/formula2>/.exec(body)?.[1] || ''),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
const conditionalFormats = [];
|
|
614
|
+
for (const match of xml.matchAll(/<conditionalFormatting\b([^>]*)>([\s\S]*?)<\/conditionalFormatting>/g)) {
|
|
615
|
+
const ranges = xmlDecode(/\bsqref="([^"]+)"/.exec(match[1])?.[1] || '').split(/\s+/).filter(Boolean);
|
|
616
|
+
for (const rule of match[2].matchAll(/<cfRule\b([^>]*?)(?:\/>|>([\s\S]*?)<\/cfRule>)/g)) {
|
|
617
|
+
const attributes = rule[1];
|
|
618
|
+
const body = rule[2] || '';
|
|
619
|
+
conditionalFormats.push({
|
|
620
|
+
path: `/sheet[${sheet.name}]/conditional-format[${conditionalFormats.length + 1}]`,
|
|
621
|
+
index: conditionalFormats.length + 1,
|
|
622
|
+
ranges,
|
|
623
|
+
type: xmlDecode(/\btype="([^"]+)"/.exec(attributes)?.[1] || ''),
|
|
624
|
+
operator: xmlDecode(/\boperator="([^"]+)"/.exec(attributes)?.[1] || ''),
|
|
625
|
+
priority: Number(/\bpriority="(\d+)"/.exec(attributes)?.[1] || 0),
|
|
626
|
+
formulas: [...body.matchAll(/<formula(?:\s[^>]*)?>([\s\S]*?)<\/formula>/g)].map((entry) => xmlDecode(entry[1])),
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
const lineage = cells.filter((cell) => cell.formula).map((cell) => ({
|
|
631
|
+
path: `/sheet[${sheet.name}]/cell[${cell.ref}]/lineage`,
|
|
632
|
+
from: `/sheet[${sheet.name}]/cell[${cell.ref}]`,
|
|
633
|
+
formula: cell.formula,
|
|
634
|
+
precedents: formulaReferences(cell.formula, sheet.name),
|
|
635
|
+
}));
|
|
636
|
+
formulaCount += paged ? cellResult.formulaCount : cells.filter((cell) => cell.formula).length;
|
|
637
|
+
formulaCacheMissing += paged ? cellResult.formulaCacheMissing : cells.filter((cell) => cell.formula && cell.cacheState === 'missing').length;
|
|
638
|
+
if (paged) page = cellResult;
|
|
639
|
+
output.push({
|
|
640
|
+
path: `/sheet[${sheet.name}]`,
|
|
641
|
+
name: sheet.name,
|
|
642
|
+
cellCount: paged ? cellResult.total : cells.length,
|
|
643
|
+
cells: (paged ? cells : cells.slice(0, 2000)).map((cell) => ({
|
|
644
|
+
path: `/sheet[${sheet.name}]/cell[${cell.ref}]`,
|
|
645
|
+
...cell,
|
|
646
|
+
})),
|
|
647
|
+
truncated: paged ? cellResult.total > cells.length : cells.length > 2000,
|
|
648
|
+
validationCount: validations.length,
|
|
649
|
+
validations,
|
|
650
|
+
conditionalFormatCount: conditionalFormats.length,
|
|
651
|
+
conditionalFormats,
|
|
652
|
+
lineageCount: lineage.length,
|
|
653
|
+
formulaLineage: lineage,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
return {
|
|
657
|
+
format: 'xlsx',
|
|
658
|
+
sheetCount: sheets.length,
|
|
659
|
+
sheets: output,
|
|
660
|
+
formulaCount,
|
|
661
|
+
formulaCacheMissing,
|
|
662
|
+
needsRecalculation: formulaCacheMissing > 0,
|
|
663
|
+
calculation,
|
|
664
|
+
definedNameCount: definedNames.length,
|
|
665
|
+
definedNames,
|
|
666
|
+
...(paged ? {
|
|
667
|
+
pagination: {
|
|
668
|
+
unit: 'populated-cell',
|
|
669
|
+
scope: `${selectedSheets[0]?.name || ''}${options.range ? `!${options.range}` : ''}`,
|
|
670
|
+
offset: Math.max(0, Number(options.offset) || 0),
|
|
671
|
+
limit: Math.max(1, Number(options.limit) || 2_000),
|
|
672
|
+
returned: page?.records.length || 0,
|
|
673
|
+
total: page?.total || 0,
|
|
674
|
+
nextOffset: page && (Math.max(0, Number(options.offset) || 0) + page.records.length < page.total)
|
|
675
|
+
? Math.max(0, Number(options.offset) || 0) + page.records.length
|
|
676
|
+
: null,
|
|
677
|
+
},
|
|
678
|
+
} : {}),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function snapshotPptx(zip, options = {}) {
|
|
683
|
+
const slidePaths = Object.keys(zip.files)
|
|
684
|
+
.filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name))
|
|
685
|
+
.sort((a, b) => Number(/\d+/.exec(basename(a))?.[0]) - Number(/\d+/.exec(basename(b))?.[0]));
|
|
686
|
+
slidePaths.reverse();
|
|
687
|
+
const paged = options.paged === true;
|
|
688
|
+
const offset = paged ? Math.max(0, Number(options.offset) || 0) : 0;
|
|
689
|
+
const limit = paged ? Math.max(1, Number(options.limit) || 20) : slidePaths.length;
|
|
690
|
+
const requested = paged && Array.isArray(options.pages) && options.pages.length
|
|
691
|
+
? options.pages.map((page) => slidePaths.find((path) => Number(/slide(\d+)\.xml$/.exec(path)?.[1]) === Number(page))).filter(Boolean)
|
|
692
|
+
: paged
|
|
693
|
+
? slidePaths.slice(offset, offset + limit)
|
|
694
|
+
: slidePaths;
|
|
695
|
+
const slides = [];
|
|
696
|
+
for (const path of requested) {
|
|
697
|
+
const xml = await zipText(zip, path);
|
|
698
|
+
const index = Number(/slide(\d+)\.xml$/.exec(path)?.[1]);
|
|
699
|
+
const tree = containerInner(xml, 'p:spTree');
|
|
700
|
+
const shapeBlocks = tree ? topLevelElements(tree.inner, ['p:sp', 'p:pic', 'p:graphicFrame', 'p:grpSp']) : [];
|
|
701
|
+
slides.push({
|
|
702
|
+
path: `/slide[${index}]`,
|
|
703
|
+
index,
|
|
704
|
+
text: paragraphTexts(xml, 'a:t'),
|
|
705
|
+
shapes: shapeBlocks.map((shape, shapeIndex) => {
|
|
706
|
+
const offset = /<a:off\b[^>]*\bx="(-?\d+)"[^>]*\by="(-?\d+)"/i.exec(shape.xml);
|
|
707
|
+
const extent = /<a:ext\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/i.exec(shape.xml);
|
|
708
|
+
return {
|
|
709
|
+
path: `/slide[${index}]/shape[${shapeIndex + 1}]`,
|
|
710
|
+
index: shapeIndex + 1,
|
|
711
|
+
type: shape.name,
|
|
712
|
+
text: paragraphTexts(shape.xml, 'a:t').join(''),
|
|
713
|
+
...(offset && extent ? {
|
|
714
|
+
left: Number(offset[1]) / 12_700,
|
|
715
|
+
top: Number(offset[2]) / 12_700,
|
|
716
|
+
width: Number(extent[1]) / 12_700,
|
|
717
|
+
height: Number(extent[2]) / 12_700,
|
|
718
|
+
} : {}),
|
|
719
|
+
};
|
|
720
|
+
}),
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
const layoutPaths = Object.keys(zip.files)
|
|
724
|
+
.filter((name) => /^ppt\/slideLayouts\/slideLayout\d+\.xml$/.test(name))
|
|
725
|
+
.sort((a, b) => Number(/\d+/.exec(basename(a))?.[0]) - Number(/\d+/.exec(basename(b))?.[0]));
|
|
726
|
+
const layouts = [];
|
|
727
|
+
for (const path of layoutPaths) {
|
|
728
|
+
const xml = await zipText(zip, path);
|
|
729
|
+
layouts.push({
|
|
730
|
+
path: `/layout[${layouts.length + 1}]`,
|
|
731
|
+
index: layouts.length + 1,
|
|
732
|
+
name: xmlDecode(/<p:cSld\b[^>]*\bname="([^"]*)"/.exec(xml)?.[1] || ''),
|
|
733
|
+
packagePart: path,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
const presentationXml = await zipText(zip, 'ppt/presentation.xml');
|
|
737
|
+
const slideSize = /<p:sldSz\b[^>]*\bcx="(\d+)"[^>]*\bcy="(\d+)"/i.exec(presentationXml);
|
|
738
|
+
return {
|
|
739
|
+
format: 'pptx',
|
|
740
|
+
slideCount: slidePaths.length,
|
|
741
|
+
slideWidth: slideSize ? Number(slideSize[1]) / 12_700 : 0,
|
|
742
|
+
slideHeight: slideSize ? Number(slideSize[2]) / 12_700 : 0,
|
|
743
|
+
slides,
|
|
744
|
+
layoutCount: layouts.length,
|
|
745
|
+
layouts,
|
|
746
|
+
...(paged ? {
|
|
747
|
+
pagination: {
|
|
748
|
+
unit: 'slide',
|
|
749
|
+
offset,
|
|
750
|
+
limit,
|
|
751
|
+
returned: slides.length,
|
|
752
|
+
total: Array.isArray(options.pages) && options.pages.length ? requested.length : slidePaths.length,
|
|
753
|
+
nextOffset: !options.pages?.length && offset + slides.length < slidePaths.length
|
|
754
|
+
? offset + slides.length
|
|
755
|
+
: null,
|
|
756
|
+
},
|
|
757
|
+
} : {}),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export async function snapshotPortableOoxml(path, format, options = {}) {
|
|
762
|
+
const zip = await loadPackage(path);
|
|
763
|
+
if (format === 'docx') return await snapshotDocx(zip, options);
|
|
764
|
+
if (format === 'xlsx') return await snapshotXlsx(zip, options);
|
|
765
|
+
if (format === 'pptx') return await snapshotPptx(zip, options);
|
|
766
|
+
throw new Error(`Unsupported OOXML format: ${format}`);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function parseCellRef(ref) {
|
|
770
|
+
const match = /^([A-Z]+)([1-9]\d*)$/i.exec(String(ref || '').trim());
|
|
771
|
+
if (!match) throw new Error(`Invalid cell reference: ${ref}`);
|
|
772
|
+
return { col: match[1].toUpperCase(), row: Number(match[2]), ref: `${match[1].toUpperCase()}${match[2]}` };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function cellXml(ref, value, formula = '') {
|
|
776
|
+
if (formula) {
|
|
777
|
+
const normalized = String(formula).replace(/^=/, '');
|
|
778
|
+
return `<c r="${ref}"><f>${xmlEncode(normalized)}</f><v></v></c>`;
|
|
779
|
+
}
|
|
780
|
+
if (typeof value === 'number' && Number.isFinite(value)) return `<c r="${ref}"><v>${value}</v></c>`;
|
|
781
|
+
if (typeof value === 'boolean') return `<c r="${ref}" t="b"><v>${value ? 1 : 0}</v></c>`;
|
|
782
|
+
return `<c r="${ref}" t="inlineStr"><is><t${/^\s|\s$/.test(String(value ?? '')) ? ' xml:space="preserve"' : ''}>${xmlEncode(value ?? '')}</t></is></c>`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function setXmlAttribute(attributes, name, value) {
|
|
786
|
+
const pattern = new RegExp(`\\b${name}="[^"]*"`, 'i');
|
|
787
|
+
return pattern.test(attributes)
|
|
788
|
+
? attributes.replace(pattern, `${name}="${value}"`)
|
|
789
|
+
: `${attributes} ${name}="${value}"`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function forceWorkbookRecalculation(xml) {
|
|
793
|
+
if (/<calcPr\b/i.test(xml)) {
|
|
794
|
+
return xml.replace(/<calcPr\b([^>]*)\/?>/i, (_match, sourceAttributes) => {
|
|
795
|
+
let attributes = String(sourceAttributes || '').replace(/\/\s*$/, '');
|
|
796
|
+
attributes = setXmlAttribute(attributes, 'calcMode', 'auto');
|
|
797
|
+
attributes = setXmlAttribute(attributes, 'fullCalcOnLoad', '1');
|
|
798
|
+
attributes = setXmlAttribute(attributes, 'forceFullCalc', '1');
|
|
799
|
+
return `<calcPr${attributes}/>`;
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
return xml.replace(/<\/workbook>/i, '<calcPr calcMode="auto" fullCalcOnLoad="1" forceFullCalc="1"/></workbook>');
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function setCellInSheet(xml, ref, value, formula = '') {
|
|
806
|
+
const parsed = parseCellRef(ref);
|
|
807
|
+
const rowRegex = new RegExp(`<row\\b([^>]*\\br="${parsed.row}"[^>]*)>([\\s\\S]*?)</row>`);
|
|
808
|
+
const rowMatch = rowRegex.exec(xml);
|
|
809
|
+
const nextCell = cellXml(parsed.ref, value, formula);
|
|
810
|
+
if (rowMatch) {
|
|
811
|
+
const rowBody = rowMatch[2];
|
|
812
|
+
const cellRegex = new RegExp(`<c\\b[^>]*\\br="${parsed.ref}"[^>]*(?:>[\\s\\S]*?</c>|/>)`, 'i');
|
|
813
|
+
const nextBody = cellRegex.test(rowBody)
|
|
814
|
+
? rowBody.replace(cellRegex, nextCell)
|
|
815
|
+
: `${rowBody}${nextCell}`;
|
|
816
|
+
return xml.replace(rowRegex, `<row${rowMatch[1]}>${nextBody}</row>`);
|
|
817
|
+
}
|
|
818
|
+
const sheetDataRegex = /<sheetData(?:\s[^>]*)?>([\s\S]*?)<\/sheetData>/;
|
|
819
|
+
const dataMatch = sheetDataRegex.exec(xml);
|
|
820
|
+
if (!dataMatch) throw new Error('Worksheet is missing sheetData');
|
|
821
|
+
const row = `<row r="${parsed.row}">${nextCell}</row>`;
|
|
822
|
+
return xml.replace(sheetDataRegex, `<sheetData>${dataMatch[1]}${row}</sheetData>`);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function columnNumber(label) {
|
|
826
|
+
return [...label.toUpperCase()].reduce((value, char) => value * 26 + char.charCodeAt(0) - 64, 0);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function columnLabel(number) {
|
|
830
|
+
let value = number;
|
|
831
|
+
let output = '';
|
|
832
|
+
while (value > 0) {
|
|
833
|
+
value -= 1;
|
|
834
|
+
output = String.fromCharCode(65 + (value % 26)) + output;
|
|
835
|
+
value = Math.floor(value / 26);
|
|
836
|
+
}
|
|
837
|
+
return output;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
function expandRange(range) {
|
|
841
|
+
const match = /^([A-Z]+)(\d+):([A-Z]+)(\d+)$/i.exec(String(range || '').trim());
|
|
842
|
+
if (!match) throw new Error(`Invalid range: ${range}`);
|
|
843
|
+
return {
|
|
844
|
+
startCol: columnNumber(match[1]),
|
|
845
|
+
startRow: Number(match[2]),
|
|
846
|
+
endCol: columnNumber(match[3]),
|
|
847
|
+
endRow: Number(match[4]),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function wordTableProperties(properties = {}) {
|
|
852
|
+
const borders = properties.borders || {};
|
|
853
|
+
const borderXml = Object.keys(borders).length
|
|
854
|
+
? `<w:tblBorders>${['top', 'left', 'bottom', 'right', 'insideH', 'insideV'].map((side) => {
|
|
855
|
+
const value = typeof borders[side] === 'object' ? borders[side] : borders;
|
|
856
|
+
if (!value || value.enabled === false) return '';
|
|
857
|
+
return `<w:${side} w:val="${xmlEncode(value.style || 'single')}" w:sz="${Math.max(1, Number(value.size) || 4)}" w:space="${Math.max(0, Number(value.space) || 0)}" w:color="${xmlEncode(String(value.color || 'auto').replace(/^#/, ''))}"/>`;
|
|
858
|
+
}).join('')}</w:tblBorders>`
|
|
859
|
+
: '';
|
|
860
|
+
return [
|
|
861
|
+
properties.style ? `<w:tblStyle w:val="${xmlEncode(properties.style)}"/>` : '',
|
|
862
|
+
`<w:tblW w:w="0" w:type="auto"/>`,
|
|
863
|
+
properties.alignment ? `<w:jc w:val="${xmlEncode(properties.alignment)}"/>` : '',
|
|
864
|
+
properties.shading ? `<w:shd w:val="clear" w:color="auto" w:fill="${xmlEncode(String(properties.shading).replace(/^#/, ''))}"/>` : '',
|
|
865
|
+
borderXml,
|
|
866
|
+
].join('');
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function wordCellProperties(properties = {}) {
|
|
870
|
+
return [
|
|
871
|
+
properties.width ? `<w:tcW w:w="${Math.max(1, Math.round(Number(properties.width)))}" w:type="dxa"/>` : '',
|
|
872
|
+
properties.fillColor ? `<w:shd w:val="clear" w:color="auto" w:fill="${xmlEncode(String(properties.fillColor).replace(/^#/, ''))}"/>` : '',
|
|
873
|
+
properties.verticalAlignment ? `<w:vAlign w:val="${xmlEncode(properties.verticalAlignment)}"/>` : '',
|
|
874
|
+
].join('');
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
function wordTableXml(operation) {
|
|
878
|
+
const values = Array.isArray(operation.values) ? operation.values : [];
|
|
879
|
+
const rows = Math.max(1, Number(operation.rows) || values.length || 1);
|
|
880
|
+
const columns = Math.max(1, Number(operation.columns) || Math.max(0, ...values.map((row) => row.length)) || 1);
|
|
881
|
+
const widths = operation.properties?.columnWidths || [];
|
|
882
|
+
const grid = Array.from({ length: columns }, (_, column) => `<w:gridCol${widths[column] ? ` w:w="${Math.max(1, Math.round(Number(widths[column])))}"` : ''}/>`).join('');
|
|
883
|
+
const body = Array.from({ length: rows }, (_, row) => `<w:tr>${Array.from({ length: columns }, (_, column) => {
|
|
884
|
+
const text = String(values[row]?.[column] ?? '');
|
|
885
|
+
const width = widths[column] ? `<w:tcW w:w="${Math.max(1, Math.round(Number(widths[column])))}" w:type="dxa"/>` : '';
|
|
886
|
+
return `<w:tc>${width ? `<w:tcPr>${width}</w:tcPr>` : ''}<w:p><w:r><w:t${/^\s|\s$/.test(text) ? ' xml:space="preserve"' : ''}>${xmlEncode(text)}</w:t></w:r></w:p></w:tc>`;
|
|
887
|
+
}).join('')}</w:tr>`).join('');
|
|
888
|
+
return `<w:tbl><w:tblPr>${wordTableProperties(operation.properties)}</w:tblPr><w:tblGrid>${grid}</w:tblGrid>${body}</w:tbl>`;
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function docxTable(current, number) {
|
|
892
|
+
const match = [...current.matchAll(/<w:tbl(?:\s[^>]*)?>[\s\S]*?<\/w:tbl>/g)][Number(number) - 1];
|
|
893
|
+
if (!match) throw new Error(`DOCX table ${number} not found`);
|
|
894
|
+
return match;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function replaceDocxTable(current, table, nextTable) {
|
|
898
|
+
return `${current.slice(0, table.index)}${nextTable}${current.slice(table.index + table[0].length)}`;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function replaceWordProperties(xml, owner, propertyTag, value) {
|
|
902
|
+
const pattern = new RegExp(`<w:${propertyTag}(?:\\s[^>]*)?>[\\s\\S]*?<\\/w:${propertyTag}>`);
|
|
903
|
+
if (pattern.test(xml)) return xml.replace(pattern, `<w:${propertyTag}>${value}</w:${propertyTag}>`);
|
|
904
|
+
return xml.replace(new RegExp(`<w:${owner}(?:\\s[^>]*)?>`), (open) => `${open}<w:${propertyTag}>${value}</w:${propertyTag}>`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function paragraphFormatXml(properties = {}) {
|
|
908
|
+
const border = properties.border || null;
|
|
909
|
+
const tabs = Array.isArray(properties.tabStops) ? properties.tabStops : [];
|
|
910
|
+
return [
|
|
911
|
+
properties.alignment ? `<w:jc w:val="${xmlEncode(properties.alignment)}"/>` : '',
|
|
912
|
+
(properties.spacingBefore !== undefined || properties.spacingAfter !== undefined || properties.lineSpacing !== undefined)
|
|
913
|
+
? `<w:spacing${properties.spacingBefore !== undefined ? ` w:before="${Math.round(Number(properties.spacingBefore))}"` : ''}${properties.spacingAfter !== undefined ? ` w:after="${Math.round(Number(properties.spacingAfter))}"` : ''}${properties.lineSpacing !== undefined ? ` w:line="${Math.round(Number(properties.lineSpacing))}" w:lineRule="auto"` : ''}/>`
|
|
914
|
+
: '',
|
|
915
|
+
properties.keepWithNext === true ? '<w:keepNext/>' : '',
|
|
916
|
+
properties.pageBreakBefore === true ? '<w:pageBreakBefore/>' : '',
|
|
917
|
+
border ? `<w:pBdr><w:${xmlEncode(border.side || 'bottom')} w:val="${xmlEncode(border.style || 'single')}" w:sz="${Math.max(1, Number(border.size) || 4)}" w:space="${Math.max(0, Number(border.space) || 1)}" w:color="${xmlEncode(String(border.color || 'auto').replace(/^#/, ''))}"/></w:pBdr>` : '',
|
|
918
|
+
tabs.length ? `<w:tabs>${tabs.map((tab) => `<w:tab w:val="${xmlEncode(tab.alignment || 'left')}" w:pos="${Math.round(Number(tab.position) || 0)}"${tab.leader ? ` w:leader="${xmlEncode(tab.leader)}"` : ''}/>`).join('')}</w:tabs>` : '',
|
|
919
|
+
].join('');
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
async function applyDocx(zip, operations) {
|
|
923
|
+
const parts = Object.keys(zip.files).filter((name) => /^word\/(document|header\d+|footer\d+|footnotes|endnotes|comments)\.xml$/i.test(name));
|
|
924
|
+
const results = [];
|
|
925
|
+
for (const op of operations) {
|
|
926
|
+
if (op.op === 'fill_template') {
|
|
927
|
+
results.push(await fillTemplateParts(zip, parts, 'w:t', op));
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (op.op === 'replace_text') {
|
|
931
|
+
let count = 0;
|
|
932
|
+
for (const part of parts) {
|
|
933
|
+
const current = await zipText(zip, part);
|
|
934
|
+
const replaced = replaceAcrossRuns(current, 'w:t', String(op.find || ''), String(op.replace ?? ''));
|
|
935
|
+
if (replaced.count) zip.file(part, replaced.xml);
|
|
936
|
+
count += replaced.count;
|
|
937
|
+
}
|
|
938
|
+
results.push({ op: op.op, changed: count > 0, count });
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
if (op.op === 'append_text') {
|
|
942
|
+
const current = await zipText(zip, 'word/document.xml');
|
|
943
|
+
const style = op.style || op.properties?.style;
|
|
944
|
+
const paragraphProperties = style ? `<w:pPr><w:pStyle w:val="${xmlEncode(style)}"/></w:pPr>` : '';
|
|
945
|
+
const font = op.properties || {};
|
|
946
|
+
const runProperties = [
|
|
947
|
+
font.name ? `<w:rFonts w:ascii="${xmlEncode(font.name)}" w:hAnsi="${xmlEncode(font.name)}" w:eastAsia="${xmlEncode(font.name)}"/>` : '',
|
|
948
|
+
font.size ? `<w:sz w:val="${Math.max(2, Math.round(Number(font.size) * 2))}"/>` : '',
|
|
949
|
+
font.bold === true ? '<w:b/>' : '',
|
|
950
|
+
font.italic === true ? '<w:i/>' : '',
|
|
951
|
+
font.color ? `<w:color w:val="${xmlEncode(String(font.color).replace(/^#/, ''))}"/>` : '',
|
|
952
|
+
].join('');
|
|
953
|
+
const block = `<w:p>${paragraphProperties}<w:r>${runProperties ? `<w:rPr>${runProperties}</w:rPr>` : ''}<w:t${/^\s|\s$/.test(String(op.text || '')) ? ' xml:space="preserve"' : ''}>${xmlEncode(op.text || '')}</w:t></w:r></w:p>`;
|
|
954
|
+
if (!/<\/w:body>/.test(current)) throw new Error('DOCX document body is missing');
|
|
955
|
+
zip.file('word/document.xml', current.replace('</w:body>', `${block}</w:body>`));
|
|
956
|
+
results.push({ op: op.op, changed: true, style: style || '' });
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
959
|
+
if (op.op === 'add_table') {
|
|
960
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
961
|
+
const table = wordTableXml(op);
|
|
962
|
+
if (op.paragraph) {
|
|
963
|
+
const model = docxBodyModel(current);
|
|
964
|
+
const paragraph = model.blocks.filter((block) => block.name === 'w:p')[Number(op.paragraph) - 1];
|
|
965
|
+
if (!paragraph) throw new Error(`DOCX paragraph ${op.paragraph} not found`);
|
|
966
|
+
const position = paragraph.end;
|
|
967
|
+
const nextInner = `${model.body.inner.slice(0, position)}${table}${model.body.inner.slice(position)}`;
|
|
968
|
+
current = `${current.slice(0, model.body.start)}${nextInner}${current.slice(model.body.end)}`;
|
|
969
|
+
} else if (/<w:sectPr(?:\s[^>]*)?>/.test(current)) {
|
|
970
|
+
current = current.replace(/<w:sectPr(?:\s[^>]*)?>/, `${table}$&`);
|
|
971
|
+
} else {
|
|
972
|
+
current = current.replace('</w:body>', `${table}</w:body>`);
|
|
973
|
+
}
|
|
974
|
+
zip.file('word/document.xml', current);
|
|
975
|
+
results.push({ op: op.op, changed: true, table: docxBodyModel(current).blocks.filter((block) => block.name === 'w:tbl').length });
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
if (op.op === 'set_paragraph_text' || op.op === 'set_run_text' || op.op === 'remove_paragraph' || op.op === 'move_paragraph') {
|
|
979
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
980
|
+
const model = docxBodyModel(current);
|
|
981
|
+
const paragraph = model.blocks.filter((block) => block.name === 'w:p')[Number(op.paragraph) - 1];
|
|
982
|
+
if (!paragraph) throw new Error(`DOCX paragraph ${op.paragraph} not found`);
|
|
983
|
+
let nextInner = model.body.inner;
|
|
984
|
+
if (op.op === 'remove_paragraph') {
|
|
985
|
+
nextInner = `${nextInner.slice(0, paragraph.start)}${nextInner.slice(paragraph.end)}`;
|
|
986
|
+
} else if (op.op === 'move_paragraph') {
|
|
987
|
+
const destination = Math.max(1, Number(op.index));
|
|
988
|
+
const remaining = model.blocks.filter((block) => block !== paragraph);
|
|
989
|
+
const paragraphBlocks = remaining.filter((block) => block.name === 'w:p');
|
|
990
|
+
const anchor = paragraphBlocks[destination - 1];
|
|
991
|
+
const without = `${nextInner.slice(0, paragraph.start)}${nextInner.slice(paragraph.end)}`;
|
|
992
|
+
if (!anchor) {
|
|
993
|
+
nextInner = `${without}${paragraph.xml}`;
|
|
994
|
+
} else {
|
|
995
|
+
const adjustedStart = anchor.start > paragraph.start ? anchor.start - paragraph.xml.length : anchor.start;
|
|
996
|
+
nextInner = `${without.slice(0, adjustedStart)}${paragraph.xml}${without.slice(adjustedStart)}`;
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
const nodes = textNodes(paragraph.xml, 'w:t');
|
|
1000
|
+
let nextParagraph;
|
|
1001
|
+
if (!nodes.length) {
|
|
1002
|
+
if (op.op === 'set_run_text') throw new Error(`DOCX paragraph ${op.paragraph} has no editable text`);
|
|
1003
|
+
const run = `<w:r><w:t xml:space="preserve">${xmlEncode(String(op.text ?? ''))}</w:t></w:r>`;
|
|
1004
|
+
if (/<\/w:p>\s*$/.test(paragraph.xml)) {
|
|
1005
|
+
nextParagraph = paragraph.xml.replace(/<\/w:p>\s*$/, `${run}</w:p>`);
|
|
1006
|
+
} else if (/\/>\s*$/.test(paragraph.xml)) {
|
|
1007
|
+
nextParagraph = paragraph.xml.replace(/\/>\s*$/, `>${run}</w:p>`);
|
|
1008
|
+
} else {
|
|
1009
|
+
throw new Error(`DOCX paragraph ${op.paragraph} is malformed`);
|
|
1010
|
+
}
|
|
1011
|
+
} else if (op.op === 'set_run_text') {
|
|
1012
|
+
const run = nodes[Number(op.run) - 1];
|
|
1013
|
+
if (!run) throw new Error(`DOCX run ${op.run} not found in paragraph ${op.paragraph}`);
|
|
1014
|
+
run.text = String(op.text ?? '');
|
|
1015
|
+
nextParagraph = rebuildTextNodes(paragraph.xml, 'w:t', nodes);
|
|
1016
|
+
} else {
|
|
1017
|
+
nodes[0].text = String(op.text ?? '');
|
|
1018
|
+
for (let index = 1; index < nodes.length; index += 1) nodes[index].text = '';
|
|
1019
|
+
nextParagraph = rebuildTextNodes(paragraph.xml, 'w:t', nodes);
|
|
1020
|
+
}
|
|
1021
|
+
nextInner = `${nextInner.slice(0, paragraph.start)}${nextParagraph}${nextInner.slice(paragraph.end)}`;
|
|
1022
|
+
}
|
|
1023
|
+
current = `${current.slice(0, model.body.start)}${nextInner}${current.slice(model.body.end)}`;
|
|
1024
|
+
zip.file('word/document.xml', current);
|
|
1025
|
+
results.push({ op: op.op, changed: true });
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
if (op.op === 'set_paragraph_style') {
|
|
1029
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
1030
|
+
const model = docxBodyModel(current);
|
|
1031
|
+
const paragraph = model.blocks.filter((block) => block.name === 'w:p')[Number(op.paragraph) - 1];
|
|
1032
|
+
if (!paragraph) throw new Error(`DOCX paragraph ${op.paragraph} not found`);
|
|
1033
|
+
const style = xmlEncode(op.style || 'Normal');
|
|
1034
|
+
let nextParagraph = paragraph.xml;
|
|
1035
|
+
if (/<w:pPr(?:\s[^>]*)?>/.test(nextParagraph)) {
|
|
1036
|
+
if (/<w:pStyle\b[^>]*\/>/.test(nextParagraph)) {
|
|
1037
|
+
nextParagraph = nextParagraph.replace(/<w:pStyle\b[^>]*\/>/, `<w:pStyle w:val="${style}"/>`);
|
|
1038
|
+
} else {
|
|
1039
|
+
nextParagraph = nextParagraph.replace(/<w:pPr(?:\s[^>]*)?>/, (open) => `${open}<w:pStyle w:val="${style}"/>`);
|
|
1040
|
+
}
|
|
1041
|
+
} else {
|
|
1042
|
+
nextParagraph = nextParagraph.replace(/<w:p(?:\s[^>]*)?>/, (open) => `${open}<w:pPr><w:pStyle w:val="${style}"/></w:pPr>`);
|
|
1043
|
+
}
|
|
1044
|
+
const nextInner = `${model.body.inner.slice(0, paragraph.start)}${nextParagraph}${model.body.inner.slice(paragraph.end)}`;
|
|
1045
|
+
current = `${current.slice(0, model.body.start)}${nextInner}${current.slice(model.body.end)}`;
|
|
1046
|
+
zip.file('word/document.xml', current);
|
|
1047
|
+
results.push({ op: op.op, changed: true, style: op.style || 'Normal' });
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (op.op === 'set_table_cell') {
|
|
1051
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
1052
|
+
const tables = [...current.matchAll(/<w:tbl(?:\s[^>]*)?>[\s\S]*?<\/w:tbl>/g)];
|
|
1053
|
+
const table = tables[Number(op.table) - 1];
|
|
1054
|
+
if (!table) throw new Error(`DOCX table ${op.table} not found`);
|
|
1055
|
+
const rows = [...table[0].matchAll(/<w:tr(?:\s[^>]*)?>[\s\S]*?<\/w:tr>/g)];
|
|
1056
|
+
const row = rows[Number(op.row) - 1];
|
|
1057
|
+
if (!row) throw new Error(`DOCX table row ${op.row} not found`);
|
|
1058
|
+
const cells = [...row[0].matchAll(/<w:tc(?:\s[^>]*)?>[\s\S]*?<\/w:tc>/g)];
|
|
1059
|
+
const cell = cells[Number(op.col) - 1];
|
|
1060
|
+
if (!cell) throw new Error(`DOCX table cell ${op.col} not found`);
|
|
1061
|
+
const nodes = textNodes(cell[0], 'w:t');
|
|
1062
|
+
let nextCell;
|
|
1063
|
+
if (nodes.length) {
|
|
1064
|
+
nodes[0].text = String(op.text ?? '');
|
|
1065
|
+
for (let index = 1; index < nodes.length; index += 1) nodes[index].text = '';
|
|
1066
|
+
nextCell = rebuildTextNodes(cell[0], 'w:t', nodes);
|
|
1067
|
+
} else {
|
|
1068
|
+
nextCell = cell[0].replace('</w:tc>', `<w:p><w:r><w:t>${xmlEncode(op.text ?? '')}</w:t></w:r></w:p></w:tc>`);
|
|
1069
|
+
}
|
|
1070
|
+
const nextRow = row[0].replace(cell[0], nextCell);
|
|
1071
|
+
const nextTable = table[0].replace(row[0], nextRow);
|
|
1072
|
+
current = `${current.slice(0, table.index)}${nextTable}${current.slice(table.index + table[0].length)}`;
|
|
1073
|
+
zip.file('word/document.xml', current);
|
|
1074
|
+
results.push({ op: op.op, changed: true });
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (op.op === 'set_table_style') {
|
|
1078
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
1079
|
+
const table = docxTable(current, op.table);
|
|
1080
|
+
const nextTable = replaceWordProperties(table[0], 'tbl', 'tblPr', wordTableProperties(op.properties));
|
|
1081
|
+
current = replaceDocxTable(current, table, nextTable);
|
|
1082
|
+
zip.file('word/document.xml', current);
|
|
1083
|
+
results.push({ op: op.op, changed: nextTable !== table[0], table: Number(op.table) });
|
|
1084
|
+
continue;
|
|
1085
|
+
}
|
|
1086
|
+
if (op.op === 'set_table_cell_style' || op.op === 'merge_table_cells') {
|
|
1087
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
1088
|
+
const table = docxTable(current, op.table);
|
|
1089
|
+
const rows = [...table[0].matchAll(/<w:tr(?:\s[^>]*)?>[\s\S]*?<\/w:tr>/g)];
|
|
1090
|
+
const row = rows[Number(op.row) - 1];
|
|
1091
|
+
if (!row) throw new Error(`DOCX table row ${op.row} not found`);
|
|
1092
|
+
const cells = [...row[0].matchAll(/<w:tc(?:\s[^>]*)?>[\s\S]*?<\/w:tc>/g)];
|
|
1093
|
+
const cell = cells[Number(op.col) - 1];
|
|
1094
|
+
if (!cell) throw new Error(`DOCX table cell ${op.col} not found`);
|
|
1095
|
+
let nextTable = table[0];
|
|
1096
|
+
if (op.op === 'set_table_cell_style') {
|
|
1097
|
+
let nextCell = replaceWordProperties(cell[0], 'tc', 'tcPr', wordCellProperties(op.properties));
|
|
1098
|
+
const runFormat = [
|
|
1099
|
+
op.properties?.bold ? '<w:b/>' : '',
|
|
1100
|
+
op.properties?.italic ? '<w:i/>' : '',
|
|
1101
|
+
op.properties?.color ? `<w:color w:val="${xmlEncode(String(op.properties.color).replace(/^#/, ''))}"/>` : '',
|
|
1102
|
+
].join('');
|
|
1103
|
+
if (runFormat) {
|
|
1104
|
+
nextCell = /<w:rPr(?:\s[^>]*)?>/.test(nextCell)
|
|
1105
|
+
? nextCell.replace(/<w:rPr(?:\s[^>]*)?>/, (open) => `${open}${runFormat}`)
|
|
1106
|
+
: nextCell.replace(/<w:r(?:\s[^>]*)?>/, (open) => `${open}<w:rPr>${runFormat}</w:rPr>`);
|
|
1107
|
+
}
|
|
1108
|
+
nextTable = table[0].replace(cell[0], nextCell);
|
|
1109
|
+
} else {
|
|
1110
|
+
const colSpan = Math.max(1, Number(op.colSpan) || 1);
|
|
1111
|
+
const rowSpan = Math.max(1, Number(op.rowSpan) || 1);
|
|
1112
|
+
let merged = replaceWordProperties(cell[0], 'tc', 'tcPr', `${colSpan > 1 ? `<w:gridSpan w:val="${colSpan}"/>` : ''}${rowSpan > 1 ? '<w:vMerge w:val="restart"/>' : ''}`);
|
|
1113
|
+
let nextRow = row[0].replace(cell[0], merged);
|
|
1114
|
+
for (let index = Number(op.col); index < Number(op.col) + colSpan - 1; index += 1) {
|
|
1115
|
+
const remove = cells[index];
|
|
1116
|
+
if (remove) nextRow = nextRow.replace(remove[0], '');
|
|
1117
|
+
}
|
|
1118
|
+
nextTable = table[0].replace(row[0], nextRow);
|
|
1119
|
+
if (rowSpan > 1) {
|
|
1120
|
+
for (let rowIndex = Number(op.row); rowIndex < Number(op.row) + rowSpan - 1; rowIndex += 1) {
|
|
1121
|
+
const continuationRow = rows[rowIndex];
|
|
1122
|
+
if (!continuationRow) break;
|
|
1123
|
+
const continuationCells = [...continuationRow[0].matchAll(/<w:tc(?:\s[^>]*)?>[\s\S]*?<\/w:tc>/g)];
|
|
1124
|
+
const continuation = continuationCells[Number(op.col) - 1];
|
|
1125
|
+
if (!continuation) continue;
|
|
1126
|
+
const nextCell = replaceWordProperties(continuation[0], 'tc', 'tcPr', `${colSpan > 1 ? `<w:gridSpan w:val="${colSpan}"/>` : ''}<w:vMerge/>`);
|
|
1127
|
+
nextTable = nextTable.replace(continuation[0], nextCell);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
current = replaceDocxTable(current, table, nextTable);
|
|
1132
|
+
zip.file('word/document.xml', current);
|
|
1133
|
+
results.push({ op: op.op, changed: nextTable !== table[0], table: Number(op.table), row: Number(op.row), col: Number(op.col) });
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
if (op.op === 'set_paragraph_format') {
|
|
1137
|
+
let current = await zipText(zip, 'word/document.xml');
|
|
1138
|
+
const model = docxBodyModel(current);
|
|
1139
|
+
const paragraph = model.blocks.filter((block) => block.name === 'w:p')[Number(op.paragraph) - 1];
|
|
1140
|
+
if (!paragraph) throw new Error(`DOCX paragraph ${op.paragraph} not found`);
|
|
1141
|
+
const nextParagraph = replaceWordProperties(paragraph.xml, 'p', 'pPr', paragraphFormatXml(op.properties));
|
|
1142
|
+
const nextInner = `${model.body.inner.slice(0, paragraph.start)}${nextParagraph}${model.body.inner.slice(paragraph.end)}`;
|
|
1143
|
+
current = `${current.slice(0, model.body.start)}${nextInner}${current.slice(model.body.end)}`;
|
|
1144
|
+
zip.file('word/document.xml', current);
|
|
1145
|
+
results.push({ op: op.op, changed: nextParagraph !== paragraph.xml, paragraph: Number(op.paragraph) });
|
|
1146
|
+
continue;
|
|
1147
|
+
}
|
|
1148
|
+
throw new Error(`Portable DOCX backend does not support operation: ${op.op}`);
|
|
1149
|
+
}
|
|
1150
|
+
return results;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function applyXlsx(zip, operations) {
|
|
1154
|
+
const sheets = await workbookSheets(zip);
|
|
1155
|
+
const byName = new Map(sheets.map((sheet) => [sheet.name.toLowerCase(), sheet]));
|
|
1156
|
+
const results = [];
|
|
1157
|
+
let recalculationRequired = false;
|
|
1158
|
+
for (const op of operations) {
|
|
1159
|
+
const sheet = op.sheet ? byName.get(String(op.sheet).toLowerCase()) : sheets[0];
|
|
1160
|
+
if (!sheet) throw new Error(`Worksheet not found: ${op.sheet || '(first sheet)'}`);
|
|
1161
|
+
let xml = await zipText(zip, sheet.path);
|
|
1162
|
+
if (op.op === 'set_cell' || op.op === 'set_formula') {
|
|
1163
|
+
xml = setCellInSheet(xml, op.cell, op.value, op.op === 'set_formula' ? op.formula : '');
|
|
1164
|
+
zip.file(sheet.path, xml);
|
|
1165
|
+
if (op.op === 'set_formula') recalculationRequired = true;
|
|
1166
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name, cell: parseCellRef(op.cell).ref });
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (op.op === 'set_range') {
|
|
1170
|
+
const area = expandRange(op.range);
|
|
1171
|
+
const values = Array.isArray(op.values) ? op.values : [];
|
|
1172
|
+
for (let row = area.startRow; row <= area.endRow; row += 1) {
|
|
1173
|
+
for (let col = area.startCol; col <= area.endCol; col += 1) {
|
|
1174
|
+
const value = values[row - area.startRow]?.[col - area.startCol] ?? null;
|
|
1175
|
+
xml = setCellInSheet(xml, `${columnLabel(col)}${row}`, value);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
zip.file(sheet.path, xml);
|
|
1179
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name, range: op.range });
|
|
1180
|
+
continue;
|
|
1181
|
+
}
|
|
1182
|
+
if (op.op === 'append_row') {
|
|
1183
|
+
const cells = cellRecords(xml, await sharedStrings(zip));
|
|
1184
|
+
const maxRow = cells.reduce((max, cell) => Math.max(max, parseCellRef(cell.ref).row), 0);
|
|
1185
|
+
const row = maxRow + 1;
|
|
1186
|
+
for (let index = 0; index < (op.values || []).length; index += 1) {
|
|
1187
|
+
xml = setCellInSheet(xml, `${columnLabel(index + 1)}${row}`, op.values[index]);
|
|
1188
|
+
}
|
|
1189
|
+
zip.file(sheet.path, xml);
|
|
1190
|
+
results.push({ op: op.op, changed: true, sheet: sheet.name, row });
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
if (op.op === 'clear_cell') {
|
|
1194
|
+
const parsed = parseCellRef(op.cell);
|
|
1195
|
+
const cellRegex = new RegExp(`<c\\b[^>]*\\br="${parsed.ref}"[^>]*(?:>[\\s\\S]*?</c>|/>)`, 'i');
|
|
1196
|
+
const changed = cellRegex.test(xml);
|
|
1197
|
+
if (changed) {
|
|
1198
|
+
xml = xml.replace(cellRegex, '');
|
|
1199
|
+
zip.file(sheet.path, xml);
|
|
1200
|
+
}
|
|
1201
|
+
results.push({ op: op.op, changed, sheet: sheet.name, cell: parsed.ref });
|
|
1202
|
+
continue;
|
|
1203
|
+
}
|
|
1204
|
+
if (op.op === 'replace_text') {
|
|
1205
|
+
let count = 0;
|
|
1206
|
+
for (const candidate of sheets) {
|
|
1207
|
+
const current = await zipText(zip, candidate.path);
|
|
1208
|
+
const replaced = replaceAcrossRuns(current, 't', String(op.find || ''), String(op.replace ?? ''));
|
|
1209
|
+
if (replaced.count) zip.file(candidate.path, replaced.xml);
|
|
1210
|
+
count += replaced.count;
|
|
1211
|
+
}
|
|
1212
|
+
const shared = await zipText(zip, 'xl/sharedStrings.xml');
|
|
1213
|
+
if (shared) {
|
|
1214
|
+
const replaced = replaceAcrossRuns(shared, 't', String(op.find || ''), String(op.replace ?? ''));
|
|
1215
|
+
if (replaced.count) zip.file('xl/sharedStrings.xml', replaced.xml);
|
|
1216
|
+
count += replaced.count;
|
|
1217
|
+
}
|
|
1218
|
+
results.push({ op: op.op, changed: count > 0, count });
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1221
|
+
throw new Error(`Portable XLSX backend does not support operation: ${op.op}`);
|
|
1222
|
+
}
|
|
1223
|
+
if (recalculationRequired) {
|
|
1224
|
+
const workbookPath = 'xl/workbook.xml';
|
|
1225
|
+
zip.file(workbookPath, forceWorkbookRecalculation(await zipText(zip, workbookPath)));
|
|
1226
|
+
}
|
|
1227
|
+
return results;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function nextShapeId(xml) {
|
|
1231
|
+
const ids = [...xml.matchAll(/\bcNvPr\s+id="(\d+)"/g)].map((match) => Number(match[1]));
|
|
1232
|
+
return Math.max(1, ...ids) + 1;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
async function applyPptx(zip, operations) {
|
|
1236
|
+
const results = [];
|
|
1237
|
+
for (const op of operations) {
|
|
1238
|
+
if (op.op === 'fill_template') {
|
|
1239
|
+
const paths = Object.keys(zip.files).filter((name) => /^ppt\/(slides\/slide\d+|notesSlides\/notesSlide\d+)\.xml$/.test(name));
|
|
1240
|
+
results.push(await fillTemplateParts(zip, paths, 'a:t', op));
|
|
1241
|
+
continue;
|
|
1242
|
+
}
|
|
1243
|
+
if (op.op === 'replace_text') {
|
|
1244
|
+
let count = 0;
|
|
1245
|
+
const paths = Object.keys(zip.files).filter((name) => /^ppt\/(slides\/slide\d+|notesSlides\/notesSlide\d+)\.xml$/.test(name));
|
|
1246
|
+
for (const path of paths) {
|
|
1247
|
+
const current = await zipText(zip, path);
|
|
1248
|
+
const replaced = replaceAcrossRuns(current, 'a:t', String(op.find || ''), String(op.replace ?? ''));
|
|
1249
|
+
if (replaced.count) zip.file(path, replaced.xml);
|
|
1250
|
+
count += replaced.count;
|
|
1251
|
+
}
|
|
1252
|
+
results.push({ op: op.op, changed: count > 0, count });
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (op.op === 'set_text') {
|
|
1256
|
+
const path = `ppt/slides/slide${Number(op.slide)}.xml`;
|
|
1257
|
+
const current = await zipText(zip, path);
|
|
1258
|
+
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1259
|
+
const shapes = [...current.matchAll(/<p:sp(?:\s[^>]*)?>[\s\S]*?<\/p:sp>/g)];
|
|
1260
|
+
const shape = shapes[Number(op.shape) - 1];
|
|
1261
|
+
if (!shape) throw new Error(`PPTX shape ${op.shape} not found on slide ${op.slide}`);
|
|
1262
|
+
const nodes = textNodes(shape[0], 'a:t');
|
|
1263
|
+
if (!nodes.length) throw new Error(`PPTX shape ${op.shape} has no editable text`);
|
|
1264
|
+
nodes[0].text = String(op.text ?? '');
|
|
1265
|
+
for (let index = 1; index < nodes.length; index += 1) nodes[index].text = '';
|
|
1266
|
+
const nextShape = rebuildTextNodes(shape[0], 'a:t', nodes);
|
|
1267
|
+
zip.file(path, `${current.slice(0, shape.index)}${nextShape}${current.slice(shape.index + shape[0].length)}`);
|
|
1268
|
+
results.push({ op: op.op, changed: true });
|
|
1269
|
+
continue;
|
|
1270
|
+
}
|
|
1271
|
+
if (op.op === 'add_textbox') {
|
|
1272
|
+
const path = `ppt/slides/slide${Number(op.slide)}.xml`;
|
|
1273
|
+
const current = await zipText(zip, path);
|
|
1274
|
+
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1275
|
+
const id = nextShapeId(current);
|
|
1276
|
+
const left = Math.round(Number(op.left ?? 72) * 12700);
|
|
1277
|
+
const top = Math.round(Number(op.top ?? 72) * 12700);
|
|
1278
|
+
const width = Math.round(Number(op.width ?? 360) * 12700);
|
|
1279
|
+
const height = Math.round(Number(op.height ?? 72) * 12700);
|
|
1280
|
+
const size = Math.round(Number(op.properties?.fontSize ?? 18) * 100);
|
|
1281
|
+
const shape = `<p:sp><p:nvSpPr><p:cNvPr id="${id}" name="Mixdog TextBox ${id}"/><p:cNvSpPr txBox="1"/><p:nvPr/></p:nvSpPr><p:spPr><a:xfrm><a:off x="${left}" y="${top}"/><a:ext cx="${width}" cy="${height}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln></p:spPr><p:txBody><a:bodyPr wrap="square"/><a:lstStyle/><a:p><a:r><a:rPr lang="en-US" sz="${size}"/><a:t>${xmlEncode(op.text ?? '')}</a:t></a:r><a:endParaRPr lang="en-US"/></a:p></p:txBody></p:sp>`;
|
|
1282
|
+
if (!/<\/p:spTree>/.test(current)) throw new Error('PPTX slide shape tree is missing');
|
|
1283
|
+
zip.file(path, current.replace('</p:spTree>', `${shape}</p:spTree>`));
|
|
1284
|
+
results.push({ op: op.op, changed: true, shapeId: id });
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
if (op.op === 'delete_shape') {
|
|
1288
|
+
const path = `ppt/slides/slide${Number(op.slide)}.xml`;
|
|
1289
|
+
const current = await zipText(zip, path);
|
|
1290
|
+
if (!current) throw new Error(`PPTX slide ${op.slide} not found`);
|
|
1291
|
+
const tree = containerInner(current, 'p:spTree');
|
|
1292
|
+
if (!tree) throw new Error('PPTX slide shape tree is missing');
|
|
1293
|
+
const shapes = topLevelElements(tree.inner, ['p:sp', 'p:pic', 'p:graphicFrame', 'p:grpSp']);
|
|
1294
|
+
const shape = shapes[Number(op.shape) - 1];
|
|
1295
|
+
if (!shape) throw new Error(`PPTX shape ${op.shape} not found on slide ${op.slide}`);
|
|
1296
|
+
const nextInner = `${tree.inner.slice(0, shape.start)}${tree.inner.slice(shape.end)}`;
|
|
1297
|
+
zip.file(path, `${current.slice(0, tree.start)}${nextInner}${current.slice(tree.end)}`);
|
|
1298
|
+
results.push({ op: op.op, changed: true });
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
throw new Error(`Portable PPTX backend does not support operation: ${op.op}`);
|
|
1302
|
+
}
|
|
1303
|
+
return results;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
export async function applyPortableOoxmlBatch(path, format, operations) {
|
|
1307
|
+
const zip = await loadPackage(path);
|
|
1308
|
+
const results = format === 'docx'
|
|
1309
|
+
? await applyDocx(zip, operations)
|
|
1310
|
+
: format === 'xlsx'
|
|
1311
|
+
? await applyXlsx(zip, operations)
|
|
1312
|
+
: await applyPptx(zip, operations);
|
|
1313
|
+
await savePackage(zip, path);
|
|
1314
|
+
return results;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
function xmlAttribute(attributes, name) {
|
|
1318
|
+
return new RegExp(`\\b${name}="([^"]*)"`, 'i').exec(attributes)?.[1] || '';
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
function relationshipOwner(relPath) {
|
|
1322
|
+
if (relPath === '_rels/.rels') return '';
|
|
1323
|
+
const match = /^(.*)\/_rels\/([^/]+)\.rels$/i.exec(relPath);
|
|
1324
|
+
return match ? `${match[1]}/${match[2]}` : '';
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
function relationshipTarget(relPath, target) {
|
|
1328
|
+
let decoded = xmlDecode(target).split('#')[0].split('?')[0];
|
|
1329
|
+
try {
|
|
1330
|
+
decoded = decodeURIComponent(decoded);
|
|
1331
|
+
} catch {}
|
|
1332
|
+
const owner = relationshipOwner(relPath);
|
|
1333
|
+
const base = owner ? posix.dirname(owner) : '';
|
|
1334
|
+
return posix.normalize(decoded.startsWith('/') ? decoded.slice(1) : posix.join(base, decoded));
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
async function inspectXmlParts(zip, entries) {
|
|
1338
|
+
const malformedXml = [];
|
|
1339
|
+
const xmlEntries = entries.filter((name) => name === '[Content_Types].xml' || /\.(?:xml|rels)$/i.test(name));
|
|
1340
|
+
const { JSDOM } = await import('jsdom');
|
|
1341
|
+
for (const name of xmlEntries) {
|
|
1342
|
+
try {
|
|
1343
|
+
const xml = await zipText(zip, name);
|
|
1344
|
+
const dom = new JSDOM(xml, { contentType: 'text/xml' });
|
|
1345
|
+
dom.window.close();
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
malformedXml.push({ part: name, error: error?.message || String(error) });
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
return malformedXml;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
function contentTypeCoverage(entries, xml, format) {
|
|
1354
|
+
const defaults = new Map();
|
|
1355
|
+
const overrides = new Map();
|
|
1356
|
+
for (const match of xml.matchAll(/<Default\b([^>]*?)\/?>/gi)) {
|
|
1357
|
+
defaults.set(xmlAttribute(match[1], 'Extension').toLowerCase(), xmlAttribute(match[1], 'ContentType'));
|
|
1358
|
+
}
|
|
1359
|
+
for (const match of xml.matchAll(/<Override\b([^>]*?)\/?>/gi)) {
|
|
1360
|
+
overrides.set(xmlAttribute(match[1], 'PartName').replace(/^\/+/, ''), xmlAttribute(match[1], 'ContentType'));
|
|
1361
|
+
}
|
|
1362
|
+
const missingContentTypes = entries.filter((name) => {
|
|
1363
|
+
if (name === '[Content_Types].xml') return false;
|
|
1364
|
+
if (overrides.has(name)) return false;
|
|
1365
|
+
const extension = name.toLowerCase().endsWith('.rels')
|
|
1366
|
+
? 'rels'
|
|
1367
|
+
: posix.extname(name).slice(1).toLowerCase();
|
|
1368
|
+
return !extension || !defaults.has(extension);
|
|
1369
|
+
});
|
|
1370
|
+
const mainPart = OOXML_REQUIRED[format]?.[1] || '';
|
|
1371
|
+
const mainContentType = overrides.get(mainPart) || '';
|
|
1372
|
+
return {
|
|
1373
|
+
missingContentTypes,
|
|
1374
|
+
mainPart,
|
|
1375
|
+
mainContentType,
|
|
1376
|
+
mainContentTypeMissing: Boolean(mainPart && !mainContentType),
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
async function baselinePackage(zip, original) {
|
|
1381
|
+
if (!original) return { compared: false };
|
|
1382
|
+
const originalZip = await loadPackage(original);
|
|
1383
|
+
const currentEntries = new Set(Object.entries(zip.files).filter(([, entry]) => !entry.dir).map(([name]) => name));
|
|
1384
|
+
const originalEntries = Object.entries(originalZip.files).filter(([, entry]) => !entry.dir).map(([name]) => name);
|
|
1385
|
+
const protectedPattern = /(?:^|\/)(?:vbaProject\.bin|vbaData\.xml|_xmlsignatures\/|origin\.sigs$|signatures?\.xml$|customUI\/|embeddings\/|externalLinks\/|connections\.xml$|slideMasters\/|slideLayouts\/|theme\/)/i;
|
|
1386
|
+
const protectedParts = originalEntries.filter((name) => protectedPattern.test(name));
|
|
1387
|
+
const lostProtectedParts = protectedParts.filter((name) => !currentEntries.has(name));
|
|
1388
|
+
const hash = async (entry) => createHash('sha256').update(await entry.async('nodebuffer')).digest('hex');
|
|
1389
|
+
const changedProtectedParts = [];
|
|
1390
|
+
const changedParts = [];
|
|
1391
|
+
for (const name of originalEntries) {
|
|
1392
|
+
const current = zip.file(name);
|
|
1393
|
+
if (!current) continue;
|
|
1394
|
+
const [before, after] = await Promise.all([hash(originalZip.file(name)), hash(current)]);
|
|
1395
|
+
if (before !== after) {
|
|
1396
|
+
changedParts.push(name);
|
|
1397
|
+
if (protectedPattern.test(name)) changedProtectedParts.push({ part: name, before, after });
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
const signatureParts = originalEntries.filter((name) => /(?:^|\/)(?:_xmlsignatures\/|origin\.sigs$|signatures?\.xml$)/i.test(name));
|
|
1401
|
+
return {
|
|
1402
|
+
compared: true,
|
|
1403
|
+
original,
|
|
1404
|
+
originalEntries: originalEntries.length,
|
|
1405
|
+
addedParts: [...currentEntries].filter((name) => !originalZip.file(name)),
|
|
1406
|
+
lostProtectedParts,
|
|
1407
|
+
changedProtectedParts,
|
|
1408
|
+
signatureParts,
|
|
1409
|
+
digitalSignatureInvalidated: signatureParts.length > 0 && changedParts.length > 0,
|
|
1410
|
+
};
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
function rejectedDocxText(xml) {
|
|
1414
|
+
let value = String(xml || '');
|
|
1415
|
+
value = value.replace(/<w:ins(?:\s[^>]*)?>[\s\S]*?<\/w:ins>/g, '');
|
|
1416
|
+
value = value.replace(/<w:del(?:\s[^>]*)?>([\s\S]*?)<\/w:del>/g, (_, body) => (
|
|
1417
|
+
body.replaceAll('<w:delText', '<w:t').replaceAll('</w:delText>', '</w:t>')
|
|
1418
|
+
));
|
|
1419
|
+
return [...value.matchAll(/<w:p(?:\s[^>]*)?>([\s\S]*?)<\/w:p>/g)]
|
|
1420
|
+
.map((paragraph) => paragraphTexts(paragraph[1], 'w:t').join(''))
|
|
1421
|
+
.filter((text) => text.length > 0)
|
|
1422
|
+
.join('\n');
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async function validateDocxRedlining(zip, originalPath) {
|
|
1426
|
+
if (!originalPath) {
|
|
1427
|
+
return {
|
|
1428
|
+
requested: true,
|
|
1429
|
+
ok: false,
|
|
1430
|
+
reason: 'Redlining audit requires an opened source document.',
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
try {
|
|
1434
|
+
const original = await loadPackage(originalPath);
|
|
1435
|
+
const before = rejectedDocxText(await zipText(original, 'word/document.xml'));
|
|
1436
|
+
const after = rejectedDocxText(await zipText(zip, 'word/document.xml'));
|
|
1437
|
+
return {
|
|
1438
|
+
requested: true,
|
|
1439
|
+
ok: before === after,
|
|
1440
|
+
originalCharacters: before.length,
|
|
1441
|
+
rejectedCharacters: after.length,
|
|
1442
|
+
reason: before === after ? '' : 'Document text differs from the source after rejecting tracked changes; at least one edit is untracked.',
|
|
1443
|
+
};
|
|
1444
|
+
} catch (error) {
|
|
1445
|
+
return {
|
|
1446
|
+
requested: true,
|
|
1447
|
+
ok: false,
|
|
1448
|
+
reason: error?.message || String(error),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
export async function validatePortableOoxml(path, format, options = {}) {
|
|
1454
|
+
const zip = await loadPackage(path);
|
|
1455
|
+
const entries = Object.entries(zip.files).filter(([, entry]) => !entry.dir).map(([name]) => name);
|
|
1456
|
+
const missing = (OOXML_REQUIRED[format] || []).filter((name) => !zip.file(name));
|
|
1457
|
+
const unsafeEntries = entries.filter((name) => name.includes('..') || name.startsWith('/') || /^[A-Za-z]:/.test(name));
|
|
1458
|
+
const macros = entries.filter((name) => /vbaProject\.bin$/i.test(name));
|
|
1459
|
+
const signatures = entries.filter((name) => /(?:^|\/)(?:_xmlsignatures\/|origin\.sigs$|signatures?\.xml$)/i.test(name));
|
|
1460
|
+
const externalLinks = entries.filter((name) => /(?:^|\/)externalLinks\//i.test(name));
|
|
1461
|
+
const dataConnections = entries.filter((name) => /(?:^|\/)connections\.xml$/i.test(name));
|
|
1462
|
+
const embeddedObjects = entries.filter((name) => /(?:^|\/)embeddings\//i.test(name));
|
|
1463
|
+
const malformedXml = await inspectXmlParts(zip, entries);
|
|
1464
|
+
const contentTypes = contentTypeCoverage(entries, await zipText(zip, '[Content_Types].xml'), format);
|
|
1465
|
+
const missingRelationships = [];
|
|
1466
|
+
const duplicateRelationshipIds = [];
|
|
1467
|
+
const externalRelationships = [];
|
|
1468
|
+
for (const relPath of entries.filter((name) => name.endsWith('.rels'))) {
|
|
1469
|
+
const xml = await zipText(zip, relPath);
|
|
1470
|
+
const ids = new Set();
|
|
1471
|
+
for (const match of xml.matchAll(/<Relationship\b([^>]+?)\/?>/gi)) {
|
|
1472
|
+
const id = xmlAttribute(match[1], 'Id');
|
|
1473
|
+
const target = xmlAttribute(match[1], 'Target');
|
|
1474
|
+
const mode = xmlAttribute(match[1], 'TargetMode');
|
|
1475
|
+
if (id && ids.has(id)) duplicateRelationshipIds.push({ relationship: relPath, id });
|
|
1476
|
+
if (id) ids.add(id);
|
|
1477
|
+
if (mode.toLowerCase() === 'external') {
|
|
1478
|
+
externalRelationships.push({ relationship: relPath, id, target: xmlDecode(target) });
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
const resolved = relationshipTarget(relPath, target);
|
|
1482
|
+
if (!resolved || resolved.startsWith('../') || !zip.file(resolved)) {
|
|
1483
|
+
missingRelationships.push({ relationship: relPath, id, target: xmlDecode(target), resolved });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
const baseline = await baselinePackage(zip, options.original);
|
|
1488
|
+
const redlining = format === 'docx' && options.auditProfile === 'redlining'
|
|
1489
|
+
? await validateDocxRedlining(zip, options.original)
|
|
1490
|
+
: null;
|
|
1491
|
+
const ok = missing.length === 0
|
|
1492
|
+
&& unsafeEntries.length === 0
|
|
1493
|
+
&& malformedXml.length === 0
|
|
1494
|
+
&& contentTypes.missingContentTypes.length === 0
|
|
1495
|
+
&& !contentTypes.mainContentTypeMissing
|
|
1496
|
+
&& missingRelationships.length === 0
|
|
1497
|
+
&& duplicateRelationshipIds.length === 0
|
|
1498
|
+
&& !(baseline.lostProtectedParts?.length)
|
|
1499
|
+
&& !(baseline.changedProtectedParts?.length)
|
|
1500
|
+
&& baseline.digitalSignatureInvalidated !== true
|
|
1501
|
+
&& (!redlining || redlining.ok);
|
|
1502
|
+
return {
|
|
1503
|
+
ok,
|
|
1504
|
+
format,
|
|
1505
|
+
entries: entries.length,
|
|
1506
|
+
missing,
|
|
1507
|
+
unsafeEntries,
|
|
1508
|
+
macros,
|
|
1509
|
+
security: {
|
|
1510
|
+
macros,
|
|
1511
|
+
signatures,
|
|
1512
|
+
externalLinks,
|
|
1513
|
+
dataConnections,
|
|
1514
|
+
embeddedObjects,
|
|
1515
|
+
macroExecution: 'disabled',
|
|
1516
|
+
digitalSignatureInvalidated: baseline.digitalSignatureInvalidated === true,
|
|
1517
|
+
},
|
|
1518
|
+
malformedXml,
|
|
1519
|
+
missingRelationships,
|
|
1520
|
+
duplicateRelationshipIds,
|
|
1521
|
+
externalRelationships,
|
|
1522
|
+
...contentTypes,
|
|
1523
|
+
baseline,
|
|
1524
|
+
redlining,
|
|
1525
|
+
validation: 'opc-relationships-content-types-xml',
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
export async function issuesPortableOoxml(path, format, options = {}) {
|
|
1530
|
+
const zip = await loadPackage(path);
|
|
1531
|
+
const issues = [];
|
|
1532
|
+
const validation = await validatePortableOoxml(path, format);
|
|
1533
|
+
for (const missing of validation.missing) {
|
|
1534
|
+
issues.push({ severity: 'error', code: 'missing_part', path: `/${missing}`, message: `Required package part is missing: ${missing}` });
|
|
1535
|
+
}
|
|
1536
|
+
for (const unsafe of validation.unsafeEntries) {
|
|
1537
|
+
issues.push({ severity: 'error', code: 'unsafe_zip_entry', path: `/${unsafe}`, message: `Unsafe ZIP entry path: ${unsafe}` });
|
|
1538
|
+
}
|
|
1539
|
+
for (const malformed of validation.malformedXml) {
|
|
1540
|
+
issues.push({ severity: 'error', code: 'malformed_xml', path: `/${malformed.part}`, message: malformed.error });
|
|
1541
|
+
}
|
|
1542
|
+
for (const relationship of validation.missingRelationships) {
|
|
1543
|
+
issues.push({ severity: 'error', code: 'missing_relationship_target', path: `/${relationship.relationship}`, message: `Relationship ${relationship.id || '(unnamed)'} targets missing part ${relationship.resolved || relationship.target}` });
|
|
1544
|
+
}
|
|
1545
|
+
for (const relationship of validation.duplicateRelationshipIds) {
|
|
1546
|
+
issues.push({ severity: 'error', code: 'duplicate_relationship_id', path: `/${relationship.relationship}`, message: `Relationship id is duplicated: ${relationship.id}` });
|
|
1547
|
+
}
|
|
1548
|
+
for (const part of validation.missingContentTypes) {
|
|
1549
|
+
issues.push({ severity: 'error', code: 'missing_content_type', path: `/${part}`, message: 'Package part has no matching content type declaration.' });
|
|
1550
|
+
}
|
|
1551
|
+
if (validation.mainContentTypeMissing) {
|
|
1552
|
+
issues.push({ severity: 'error', code: 'missing_main_content_type', path: `/${validation.mainPart}`, message: 'The main Office document part needs an explicit content type override.' });
|
|
1553
|
+
}
|
|
1554
|
+
for (const part of validation.baseline.lostProtectedParts || []) {
|
|
1555
|
+
issues.push({ severity: 'error', code: 'lost_protected_part', path: `/${part}`, message: 'A macro, master, layout, or theme part from the source package was removed.' });
|
|
1556
|
+
}
|
|
1557
|
+
for (const part of validation.baseline.changedProtectedParts || []) {
|
|
1558
|
+
issues.push({ severity: 'error', code: 'changed_protected_part', path: `/${part.part}`, message: 'A macro, signature, embedded object, external link, connection, master, layout, or theme part changed unexpectedly.' });
|
|
1559
|
+
}
|
|
1560
|
+
if (validation.security?.digitalSignatureInvalidated) {
|
|
1561
|
+
issues.push({ severity: 'error', code: 'digital_signature_invalidated', path: '/', message: 'The source package was digitally signed and document changes invalidate that signature.' });
|
|
1562
|
+
}
|
|
1563
|
+
for (const macro of validation.macros) {
|
|
1564
|
+
issues.push({ severity: 'warning', code: 'macro_present', path: `/${macro}`, message: 'VBA macro payload is present and is never executed by the portable backend.' });
|
|
1565
|
+
}
|
|
1566
|
+
for (const connection of validation.security?.dataConnections || []) {
|
|
1567
|
+
issues.push({ severity: 'warning', code: 'data_connection_present', path: `/${connection}`, message: 'Workbook data connection is preserved but never refreshed automatically.' });
|
|
1568
|
+
}
|
|
1569
|
+
for (const embedded of validation.security?.embeddedObjects || []) {
|
|
1570
|
+
issues.push({ severity: 'warning', code: 'embedded_object_present', path: `/${embedded}`, message: 'Embedded object is preserved but never activated by Mixdog.' });
|
|
1571
|
+
}
|
|
1572
|
+
for (const relationship of validation.externalRelationships) {
|
|
1573
|
+
issues.push({ severity: 'warning', code: 'external_relationship', path: `/${relationship.relationship}`, message: `External relationship: ${relationship.target}` });
|
|
1574
|
+
}
|
|
1575
|
+
if (format === 'docx') {
|
|
1576
|
+
const snapshot = await snapshotDocx(zip);
|
|
1577
|
+
if (snapshot.revisionCount) issues.push({ severity: 'info', code: 'unresolved_revisions', path: '/body', message: `${snapshot.revisionCount} tracked revision element(s) remain unresolved.` });
|
|
1578
|
+
if (snapshot.commentCount) issues.push({ severity: 'info', code: 'unresolved_comments', path: '/body', message: `${snapshot.commentCount} comment(s) remain in the document.` });
|
|
1579
|
+
} else if (format === 'xlsx') {
|
|
1580
|
+
const selective = Boolean(options.sheet || options.range);
|
|
1581
|
+
const snapshot = await snapshotXlsx(zip, selective ? {
|
|
1582
|
+
paged: true,
|
|
1583
|
+
sheet: options.sheet,
|
|
1584
|
+
range: options.range,
|
|
1585
|
+
offset: 0,
|
|
1586
|
+
limit: Number.MAX_SAFE_INTEGER,
|
|
1587
|
+
} : {});
|
|
1588
|
+
const errorPattern = /^(?:#REF!|#DIV\/0!|#VALUE!|#NAME\?|#N\/A|#NUM!|#NULL!)$/i;
|
|
1589
|
+
for (const sheet of snapshot.sheets) {
|
|
1590
|
+
for (const cell of sheet.cells) {
|
|
1591
|
+
if (cell.formula && cell.cacheState === 'missing') {
|
|
1592
|
+
issues.push({ severity: 'warning', code: 'formula_cache_missing', path: cell.path, message: 'Formula has no cached value; the workbook is marked for full recalculation on open.' });
|
|
1593
|
+
}
|
|
1594
|
+
if (errorPattern.test(String(cell.value || ''))) {
|
|
1595
|
+
issues.push({ severity: 'error', code: 'formula_error', path: cell.path, message: `Cell contains formula error ${cell.value}` });
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
if (options.auditProfile === 'financial-model') {
|
|
1600
|
+
if (!snapshot.sheets.some((sheet) => sheet.name.toLowerCase() === 'checks')) {
|
|
1601
|
+
issues.push({ severity: 'warning', code: 'missing_checks_sheet', path: '/', message: 'Financial-model audit expects a Checks sheet with explicit tie-out formulas.' });
|
|
1602
|
+
}
|
|
1603
|
+
for (const sheet of snapshot.sheets) {
|
|
1604
|
+
const formulaRows = new Set(sheet.cells.filter((cell) => cell.formula).map((cell) => parseCellRef(cell.ref).row));
|
|
1605
|
+
for (const cell of sheet.cells) {
|
|
1606
|
+
if (!cell.formula && formulaRows.has(parseCellRef(cell.ref).row) && /^-?\d+(?:\.\d+)?$/.test(String(cell.value || ''))) {
|
|
1607
|
+
issues.push({ severity: 'warning', code: 'rogue_hardcode', path: cell.path, message: 'Numeric hardcode appears inside a row that otherwise contains formulas.' });
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
} else if (format === 'pptx') {
|
|
1613
|
+
const requestedPages = new Set((options.pages || []).map(Number));
|
|
1614
|
+
const slidePaths = Object.keys(zip.files).filter((name) => {
|
|
1615
|
+
if (!/^ppt\/slides\/slide\d+\.xml$/.test(name)) return false;
|
|
1616
|
+
if (!requestedPages.size) return true;
|
|
1617
|
+
return requestedPages.has(Number(/slide(\d+)\.xml$/.exec(name)?.[1]));
|
|
1618
|
+
});
|
|
1619
|
+
for (const slidePath of slidePaths) {
|
|
1620
|
+
const xml = await zipText(zip, slidePath);
|
|
1621
|
+
const slide = Number(/slide(\d+)\.xml$/.exec(slidePath)?.[1]);
|
|
1622
|
+
let picture = 0;
|
|
1623
|
+
for (const match of xml.matchAll(/<p:pic(?:\s[^>]*)?>[\s\S]*?<\/p:pic>/g)) {
|
|
1624
|
+
picture += 1;
|
|
1625
|
+
const descr = /\bdescr="([^"]*)"/.exec(match[0])?.[1] || '';
|
|
1626
|
+
if (!descr) issues.push({ severity: 'warning', code: 'missing_alt_text', path: `/slide[${slide}]/picture[${picture}]`, message: 'Picture has no alternative text.' });
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return { ok: !issues.some((issue) => issue.severity === 'error'), format, issueCount: issues.length, issues };
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function commandExists(command) {
|
|
1634
|
+
return new Promise((resolve) => {
|
|
1635
|
+
const child = spawn(command, ['--version'], { windowsHide: true, stdio: 'ignore' });
|
|
1636
|
+
child.once('error', () => resolve(false));
|
|
1637
|
+
child.once('close', (code) => resolve(code === 0));
|
|
1638
|
+
});
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
async function libreOfficeProgram() {
|
|
1642
|
+
const candidates = process.platform === 'win32'
|
|
1643
|
+
? [
|
|
1644
|
+
'soffice.exe',
|
|
1645
|
+
'C:\\Program Files\\LibreOffice\\program\\soffice.exe',
|
|
1646
|
+
'C:\\Program Files (x86)\\LibreOffice\\program\\soffice.exe',
|
|
1647
|
+
]
|
|
1648
|
+
: ['soffice', 'libreoffice'];
|
|
1649
|
+
for (const candidate of candidates) {
|
|
1650
|
+
if (await commandExists(candidate)) return candidate;
|
|
1651
|
+
}
|
|
1652
|
+
return '';
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
export async function recalculateLibreOfficeWorkbook(path, {
|
|
1656
|
+
force = false,
|
|
1657
|
+
signal = null,
|
|
1658
|
+
} = {}) {
|
|
1659
|
+
const source = await readFile(path);
|
|
1660
|
+
const zip = await JSZip.loadAsync(source);
|
|
1661
|
+
let formulaCount = 0;
|
|
1662
|
+
let missingCachedValues = 0;
|
|
1663
|
+
for (const name of Object.keys(zip.files).filter((entry) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(entry))) {
|
|
1664
|
+
const xml = await zipText(zip, name);
|
|
1665
|
+
for (const match of xml.matchAll(/<c\b[^>]*>([\s\S]*?)<\/c>/gi)) {
|
|
1666
|
+
if (!/<f(?:\s[^>]*)?>/i.test(match[1])) continue;
|
|
1667
|
+
formulaCount += 1;
|
|
1668
|
+
if (!/<v(?:\s[^>]*)?>[\s\S]*?<\/v>/i.test(match[1])) missingCachedValues += 1;
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
const needed = formulaCount > 0 && (force || missingCachedValues > 0);
|
|
1672
|
+
if (!needed) {
|
|
1673
|
+
return {
|
|
1674
|
+
needed: false,
|
|
1675
|
+
recalculated: false,
|
|
1676
|
+
formulaCount,
|
|
1677
|
+
missingCachedValues,
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
if (extname(path).toLowerCase() !== '.xlsx') {
|
|
1681
|
+
return {
|
|
1682
|
+
needed: true,
|
|
1683
|
+
available: false,
|
|
1684
|
+
recalculated: false,
|
|
1685
|
+
formulaCount,
|
|
1686
|
+
missingCachedValues,
|
|
1687
|
+
reason: 'Portable formula recalculation currently supports .xlsx only; use Microsoft Office background mode for macro-enabled or template workbooks.',
|
|
1688
|
+
};
|
|
1689
|
+
}
|
|
1690
|
+
if (Object.keys(zip.files).some((entry) => /^xl\/externalLinks\//i.test(entry))) {
|
|
1691
|
+
return {
|
|
1692
|
+
needed: true,
|
|
1693
|
+
available: false,
|
|
1694
|
+
recalculated: false,
|
|
1695
|
+
formulaCount,
|
|
1696
|
+
missingCachedValues,
|
|
1697
|
+
reason: 'Portable formula recalculation is blocked because LibreOffice may invalidate external workbook links.',
|
|
1698
|
+
};
|
|
1699
|
+
}
|
|
1700
|
+
const program = await libreOfficeProgram();
|
|
1701
|
+
if (!program) {
|
|
1702
|
+
return {
|
|
1703
|
+
needed: true,
|
|
1704
|
+
available: false,
|
|
1705
|
+
recalculated: false,
|
|
1706
|
+
formulaCount,
|
|
1707
|
+
missingCachedValues,
|
|
1708
|
+
reason: 'LibreOffice is unavailable for portable XLSX recalculation.',
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-office-recalculate-'));
|
|
1712
|
+
const inputDir = join(root, 'input');
|
|
1713
|
+
const outputDir = join(root, 'output');
|
|
1714
|
+
await mkdir(inputDir, { recursive: true });
|
|
1715
|
+
await mkdir(outputDir, { recursive: true });
|
|
1716
|
+
const input = join(inputDir, basename(path));
|
|
1717
|
+
await writeFile(input, source);
|
|
1718
|
+
let timeout;
|
|
1719
|
+
try {
|
|
1720
|
+
const result = await new Promise((resolve) => {
|
|
1721
|
+
let settled = false;
|
|
1722
|
+
const child = spawn(program, ['--headless', '--convert-to', 'xlsx', '--outdir', outputDir, input], {
|
|
1723
|
+
windowsHide: true,
|
|
1724
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1725
|
+
});
|
|
1726
|
+
let stderr = '';
|
|
1727
|
+
const finish = (value) => {
|
|
1728
|
+
if (settled) return;
|
|
1729
|
+
settled = true;
|
|
1730
|
+
clearTimeout(timeout);
|
|
1731
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
1732
|
+
resolve(value);
|
|
1733
|
+
};
|
|
1734
|
+
const onAbort = () => {
|
|
1735
|
+
try { child.kill(); } catch {}
|
|
1736
|
+
finish({ recalculated: false, error: 'Portable XLSX recalculation was cancelled' });
|
|
1737
|
+
};
|
|
1738
|
+
child.stderr.setEncoding('utf8');
|
|
1739
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1740
|
+
child.once('error', (error) => finish({ recalculated: false, error: error?.message || String(error) }));
|
|
1741
|
+
child.once('close', (code) => finish(code === 0
|
|
1742
|
+
? { recalculated: true }
|
|
1743
|
+
: { recalculated: false, error: stderr.trim() || `LibreOffice exited with code ${code}` }));
|
|
1744
|
+
timeout = setTimeout(() => {
|
|
1745
|
+
try { child.kill(); } catch {}
|
|
1746
|
+
finish({ recalculated: false, error: 'Portable XLSX recalculation timed out after 60 seconds' });
|
|
1747
|
+
}, 60_000);
|
|
1748
|
+
if (signal?.aborted) onAbort();
|
|
1749
|
+
else signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1750
|
+
});
|
|
1751
|
+
if (!result.recalculated) {
|
|
1752
|
+
return {
|
|
1753
|
+
needed: true,
|
|
1754
|
+
available: true,
|
|
1755
|
+
recalculated: false,
|
|
1756
|
+
formulaCount,
|
|
1757
|
+
missingCachedValues,
|
|
1758
|
+
reason: result.error,
|
|
1759
|
+
};
|
|
1760
|
+
}
|
|
1761
|
+
const generated = join(outputDir, `${basename(path, extname(path))}.xlsx`);
|
|
1762
|
+
const details = await stat(generated).catch(() => null);
|
|
1763
|
+
if (!details?.isFile() || details.size <= 0) {
|
|
1764
|
+
return {
|
|
1765
|
+
needed: true,
|
|
1766
|
+
available: true,
|
|
1767
|
+
recalculated: false,
|
|
1768
|
+
formulaCount,
|
|
1769
|
+
missingCachedValues,
|
|
1770
|
+
reason: 'LibreOffice produced no recalculated workbook.',
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
await writeFile(path, await readFile(generated));
|
|
1774
|
+
return {
|
|
1775
|
+
needed: true,
|
|
1776
|
+
available: true,
|
|
1777
|
+
recalculated: true,
|
|
1778
|
+
backend: 'libreoffice',
|
|
1779
|
+
formulaCount,
|
|
1780
|
+
missingCachedValues,
|
|
1781
|
+
outputBytes: details.size,
|
|
1782
|
+
};
|
|
1783
|
+
} finally {
|
|
1784
|
+
clearTimeout(timeout);
|
|
1785
|
+
await rm(root, { recursive: true, force: true }).catch(() => {});
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
export async function validateLibreOfficeReopen(path) {
|
|
1790
|
+
const program = await libreOfficeProgram();
|
|
1791
|
+
if (!program) return { available: false, opened: false, backend: 'libreoffice' };
|
|
1792
|
+
const outputDir = await mkdtemp(join(tmpdir(), 'mixdog-office-libreoffice-'));
|
|
1793
|
+
let timeout;
|
|
1794
|
+
try {
|
|
1795
|
+
const result = await new Promise((resolve) => {
|
|
1796
|
+
const child = spawn(program, ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, path], {
|
|
1797
|
+
windowsHide: true,
|
|
1798
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1799
|
+
});
|
|
1800
|
+
let stderr = '';
|
|
1801
|
+
child.stderr.setEncoding('utf8');
|
|
1802
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1803
|
+
const finish = (value) => {
|
|
1804
|
+
clearTimeout(timeout);
|
|
1805
|
+
resolve(value);
|
|
1806
|
+
};
|
|
1807
|
+
timeout = setTimeout(() => {
|
|
1808
|
+
try { child.kill(); } catch {}
|
|
1809
|
+
finish({ opened: false, error: 'LibreOffice reopen timed out after 60 seconds' });
|
|
1810
|
+
}, 60_000);
|
|
1811
|
+
child.once('error', (error) => finish({ opened: false, error: error?.message || String(error) }));
|
|
1812
|
+
child.once('close', (code) => finish(code === 0
|
|
1813
|
+
? { opened: true }
|
|
1814
|
+
: { opened: false, error: stderr.trim() || `LibreOffice exited with code ${code}` }));
|
|
1815
|
+
});
|
|
1816
|
+
const output = join(outputDir, `${basename(path, extname(path))}.pdf`);
|
|
1817
|
+
if (result.opened) {
|
|
1818
|
+
const details = await stat(output).catch(() => null);
|
|
1819
|
+
if (!details?.isFile() || details.size <= 0) return { available: true, opened: false, backend: 'libreoffice', error: 'LibreOffice produced no review PDF' };
|
|
1820
|
+
return { available: true, opened: true, backend: 'libreoffice', outputBytes: details.size };
|
|
1821
|
+
}
|
|
1822
|
+
return { available: true, backend: 'libreoffice', ...result };
|
|
1823
|
+
} finally {
|
|
1824
|
+
clearTimeout(timeout);
|
|
1825
|
+
await rm(outputDir, { recursive: true, force: true }).catch(() => {});
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
export async function renderPortableOoxml(path, output, { signal = null } = {}) {
|
|
1830
|
+
const program = await libreOfficeProgram();
|
|
1831
|
+
if (!program) {
|
|
1832
|
+
throw new Error('Portable Office rendering requires LibreOffice; install LibreOffice or open the document in background mode to render through Microsoft Office');
|
|
1833
|
+
}
|
|
1834
|
+
const outputDir = dirname(output);
|
|
1835
|
+
await new Promise((resolve, reject) => {
|
|
1836
|
+
const child = spawn(program, ['--headless', '--convert-to', 'pdf', '--outdir', outputDir, path], {
|
|
1837
|
+
windowsHide: true,
|
|
1838
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1839
|
+
});
|
|
1840
|
+
let stderr = '';
|
|
1841
|
+
let settled = false;
|
|
1842
|
+
const finish = (operation) => {
|
|
1843
|
+
if (settled) return;
|
|
1844
|
+
settled = true;
|
|
1845
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
1846
|
+
operation();
|
|
1847
|
+
};
|
|
1848
|
+
const onAbort = () => {
|
|
1849
|
+
try { child.kill(); } catch {}
|
|
1850
|
+
finish(() => reject(new Error('Office rendering was cancelled')));
|
|
1851
|
+
};
|
|
1852
|
+
child.stderr.setEncoding('utf8');
|
|
1853
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1854
|
+
child.on('error', (error) => finish(() => reject(error)));
|
|
1855
|
+
child.on('close', (code) => finish(() => (
|
|
1856
|
+
code === 0 ? resolve() : reject(new Error(stderr.trim() || `LibreOffice exited with code ${code}`))
|
|
1857
|
+
)));
|
|
1858
|
+
if (signal?.aborted) return onAbort();
|
|
1859
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
1860
|
+
});
|
|
1861
|
+
const generated = join(outputDir, `${basename(path, extname(path))}.pdf`);
|
|
1862
|
+
if (generated !== output) {
|
|
1863
|
+
const { rename } = await import('node:fs/promises');
|
|
1864
|
+
await rename(generated, output);
|
|
1865
|
+
}
|
|
1866
|
+
return output;
|
|
1867
|
+
}
|