Skip to content

Commit a2d9d93

Browse files
committed
refactor: use Exchange Node for Office conversion
1 parent 87439a9 commit a2d9d93

12 files changed

Lines changed: 286 additions & 140 deletions

AGENTS.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,11 +36,11 @@ scripts 仓库级 SDK 版本与 CLI 本地开发脚本
3636

3737
本仓库是 product/application composition root,不重新拥有上游 SDK 的合同:
3838

39-
- Univer / Univer Pro SDK 拥有 Unit 数据模型、Facade API、mutation、render 和内容能力。
39+
- Univer / Univer Pro SDK 拥有 Unit 数据模型、Facade API、mutation、render、Office exchange 和内容能力。
4040
- Univer Collaboration SDK 拥有 snapshot、changeset、revision、OT、协同 Service、Worktree、
4141
Database Adapter、Endpoint 和 Transport 合同。
42-
- Univer CLI SDK 拥有 target-neutral 的 headless runtime、execution、inspection、render、exchange、
43-
daemon 和可选 Commander preset。
42+
- Univer CLI SDK 拥有 target-neutral 的 headless runtime、execution、inspection、render、daemon 和可选
43+
Commander preset。
4444
- Workspace 产品模型、认证、资源目录、远程 workflow 和 deployment 留在本仓库。
4545

4646
只通过已发布 package 的公开 exports 使用其他 SDK。代码、构建、测试和生成流程不得依赖相邻仓库

DREAMNUM.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,13 @@
3030

3131
- [`dream-num/univer`](https://github.com/dream-num/univer) — owns the core Unit model, Facade APIs, mutations,
3232
rendering, and `@univerjs/*` packages composed by both applications. Contract: [repository boundary](AGENTS.md).
33-
- [`dream-num/univer-pro`](https://github.com/dream-num/univer-pro) — owns the Univer Pro content, collaboration
34-
client, and UI packages consumed by the Browser and CLI. Contract: [repository boundary](AGENTS.md).
33+
- [`dream-num/univer-pro`](https://github.com/dream-num/univer-pro) — owns the Univer Pro content, Office exchange,
34+
collaboration client, and UI packages consumed by the Browser and CLI. Contract: [repository boundary](AGENTS.md).
3535
- [`dream-num/univer-collaboration-sdk`](https://github.com/dream-num/univer-collaboration-sdk) — owns collaboration
3636
Service, revision, changeset, Worktree, persistence, Endpoint, and Transport contracts used by Workspace.
3737
Contract: [repository boundary](AGENTS.md).
3838
- [`dream-num/univer-cli-sdk`](https://github.com/dream-num/univer-cli-sdk) — owns the target-neutral headless,
39-
execution, inspection, rendering, exchange, daemon, and Commander capabilities composed by Workspace CLI.
39+
execution, inspection, rendering, daemon, and Commander capabilities composed by Workspace CLI.
4040
Contract: [repository boundary](AGENTS.md).
4141
- [`dream-num/univer-protocol`](https://github.com/dream-num/univer-protocol) — owns the protocol types consumed by
4242
collaboration and content workflows. Contract: [repository boundary](AGENTS.md).

apps/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"@univer-cli/resource-library": "1.0.0-insiders.20260819-8595af2",
4949
"@univer-cli/resource-library-command": "1.0.0-insiders.20260819-8595af2",
5050
"@univer-cli/svg-facade": "1.0.0-insiders.20260819-8595af2",
51-
"@univer-cli/unit-exchange": "1.0.0-insiders.20260819-8595af2",
5251
"@univer-cli/unit-layout-lint": "1.0.0-insiders.20260819-8595af2",
5352
"@univer-cli/unit-layout-lint-command": "1.0.0-insiders.20260819-8595af2",
5453
"@univer-cli/unit-screenshot": "1.0.0-insiders.20260819-8595af2",
@@ -95,6 +94,8 @@
9594
"@univerjs-pro/engine-formula": "1.0.0-insiders.20260819-8595af2",
9695
"@univerjs-pro/engine-shape": "1.0.0-insiders.20260819-8595af2",
9796
"@univerjs-pro/exchange-client": "1.0.0-insiders.20260819-8595af2",
97+
"@univerjs-pro/exchange-node": "1.0.0-insiders.20260819-8595af2",
98+
"@univerjs-pro/exchange-node-binding": "0.1.0",
9899
"@univerjs-pro/ink": "1.0.0-insiders.20260819-8595af2",
99100
"@univerjs-pro/ink-ui": "1.0.0-insiders.20260819-8595af2",
100101
"@univerjs-pro/license": "1.0.0-insiders.20260819-8595af2",

apps/cli/scripts/package-artifact.mjs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export const EXTERNAL_RUNTIME_DEPENDENCIES = [
88
"@univerjs-pro/cli-assets",
99
"@univerjs-pro/doc-typst-native-binding",
1010
"@univerjs-pro/engine-formula-rust-binding",
11-
"@univerjs-pro/uexcli",
11+
"@univerjs-pro/exchange-node-binding",
1212
"puppeteer-core",
1313
];
1414

@@ -103,7 +103,6 @@ export function resolveExternalRuntimeDependencies(appRoot, source) {
103103
const appRequire = createRequire(resolve(appRoot, "package.json"));
104104
const renderRuntime = readPackageManifest(appRequire, "@univer-cli/univer-render-runtime");
105105
const typst = readPackageManifest(appRequire, "@univer-cli/doc-typst-facade");
106-
const exchange = readPackageManifest(appRequire, "@univer-cli/unit-exchange");
107106
const headless = readPackageManifest(appRequire, "@univer-cli/headless-univer");
108107
const headlessRequire = createRequire(headless.path);
109108
const formula = readPackageManifest(headlessRequire, "@univerjs-pro/engine-formula-rust");
@@ -122,7 +121,10 @@ export function resolveExternalRuntimeDependencies(appRoot, source) {
122121
formula.manifest,
123122
"@univerjs-pro/engine-formula-rust-binding",
124123
),
125-
"@univerjs-pro/uexcli": readOwnedDependency(exchange.manifest, "@univerjs-pro/uexcli"),
124+
"@univerjs-pro/exchange-node-binding": readOwnedDependency(
125+
source,
126+
"@univerjs-pro/exchange-node-binding",
127+
),
126128
"puppeteer-core": readOwnedDependency(renderRuntime.manifest, "puppeteer-core"),
127129
};
128130
}

apps/cli/scripts/smoke-package.mjs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { spawnSync } from "node:child_process";
22
import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
3+
import { createRequire } from "node:module";
34
import { tmpdir } from "node:os";
45
import { basename, join, resolve } from "node:path";
56

67
const packageRoot = resolve(process.argv[2] ?? "package-dist");
78
const packageManifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8"));
9+
const registry = packageManifest.publishConfig?.registry;
10+
if (typeof registry !== "string" || registry === "") {
11+
throw new Error("Package manifest must declare publishConfig.registry");
12+
}
813
const temporaryRoot = await mkdtemp(join(tmpdir(), "univer-workspace-cli-package-"));
914
let executable;
1015
let smokeEnv;
@@ -27,7 +32,14 @@ try {
2732
const tarball = join(tarballRoot, basename(artifacts[0].filename));
2833
run(
2934
"npm",
30-
["install", "--no-audit", "--no-fund", "--package-lock=false", tarball],
35+
[
36+
"install",
37+
"--no-audit",
38+
"--no-fund",
39+
"--package-lock=false",
40+
`--registry=${registry}`,
41+
tarball,
42+
],
3143
installRoot,
3244
);
3345

@@ -50,6 +62,15 @@ try {
5062
run(executable, ["api", "--help"], installRoot, smokeEnv);
5163
run(executable, ["daemon", "start", "--json"], installRoot, smokeEnv);
5264
run(executable, ["daemon", "status", "--json"], installRoot, smokeEnv);
65+
const binding = createRequire(join(installRoot, "package.json"))(
66+
"@univerjs-pro/exchange-node-binding",
67+
);
68+
if (
69+
typeof binding.exchangeImportToSnapshot !== "function" ||
70+
typeof binding.exchangeExportSnapshot !== "function"
71+
) {
72+
throw new Error("Installed package did not load the Exchange Node native binding");
73+
}
5374
run(executable, ["daemon", "stop", "--json"], installRoot, smokeEnv);
5475
console.log("[package-smoke] installed tarball commands passed");
5576
} finally {

apps/cli/src/features/exchange/exchange.ts

Lines changed: 74 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,41 @@
11
import { extname } from "node:path";
22
import type { DaemonClient, JsonValue } from "@univer-cli/daemon";
33
import {
4-
UnitExchangeFormat,
5-
type ImportableUnitType,
6-
type UnitExchange,
7-
type UnitExchangeData,
8-
} from "@univer-cli/unit-exchange";
4+
ExchangeFormat,
5+
FormulaCalculationMode,
6+
exportToFile,
7+
importFile,
8+
type ExportOptions,
9+
type ImportOptions,
10+
} from "@univerjs-pro/exchange-node";
911
import { UniverInstanceType } from "@univerjs/core";
1012
import { workspaceError } from "../../errors.js";
1113
import type { WorkspaceRuntimeTarget } from "../../runtime/target.js";
1214
import type { WorkspaceUnitType } from "../space/model.js";
1315
import type { WorkspaceUnit } from "../worktree/model.js";
1416

17+
type WorkspaceExchangeUnitType =
18+
| UniverInstanceType.UNIVER_SHEET
19+
| UniverInstanceType.UNIVER_BASE
20+
| UniverInstanceType.UNIVER_DOC
21+
| UniverInstanceType.UNIVER_SLIDE;
22+
type ImportOfficeFile = (
23+
path: string,
24+
options: ImportOptions,
25+
) => Promise<Readonly<Record<string, unknown>>>;
26+
type ExportOfficeFile = (
27+
data: Readonly<Record<string, JsonValue>>,
28+
path: string,
29+
options: ExportOptions,
30+
) => Promise<void>;
31+
32+
const importOfficeFile = importFile as unknown as ImportOfficeFile;
33+
const exportOfficeFile = exportToFile as unknown as ExportOfficeFile;
34+
1535
export interface WorkspaceUnitExchangeDependencies {
1636
readonly daemon: Pick<DaemonClient, "request">;
17-
readonly exchange: UnitExchange;
37+
readonly exportToFile?: ExportOfficeFile;
38+
readonly importFile?: ImportOfficeFile;
1839
readonly createUnit: (input: {
1940
readonly idempotencyKey?: string;
2041
readonly initialData: Readonly<Record<string, unknown>>;
@@ -70,18 +91,18 @@ export class WorkspaceUnitExchangeFeature {
7091
public async importFile(input: WorkspaceImportFileInput): Promise<WorkspaceImportFileResult> {
7192
const type = inferImportType(input.sourcePath, input.type);
7293
const unitType = toInstanceType(type);
73-
const imported = await this.dependencies.exchange.importFile({
74-
sourcePath: input.sourcePath,
75-
unitType,
76-
});
94+
const imported = await (this.dependencies.importFile ?? importOfficeFile)(
95+
input.sourcePath,
96+
importOptions(input.sourcePath, unitType),
97+
);
7798
const explicitName = nonEmpty(input.name);
7899
const name =
79100
explicitName ??
80-
nonEmpty("name" in imported.data ? imported.data.name : undefined) ??
81-
nonEmpty("title" in imported.data ? imported.data.title : undefined) ??
101+
nonEmpty(imported["name"]) ??
102+
nonEmpty(imported["title"]) ??
82103
`Imported ${type}`;
83104
const initialData = {
84-
...imported.data,
105+
...imported,
85106
...(explicitName === undefined ? {} : { name: explicitName }),
86107
} as Readonly<Record<string, unknown>>;
87108
const created = await this.dependencies.createUnit({
@@ -129,7 +150,13 @@ export class WorkspaceUnitExchangeFeature {
129150
`Workspace runtime exported invalid UnitData for ${target.unitId}.`,
130151
);
131152
}
132-
await exportUnit(this.dependencies.exchange, target.unitType, result, format, input.outputPath);
153+
await exportUnit(
154+
this.dependencies.exportToFile ?? exportOfficeFile,
155+
target.unitType,
156+
result,
157+
format,
158+
input.outputPath,
159+
);
133160
return {
134161
outputPath: input.outputPath,
135162
type: target.unitType,
@@ -159,14 +186,14 @@ function inferImportType(
159186
);
160187
}
161188

162-
function inferExportFormat(outputPath: string): UnitExchangeFormat {
189+
function inferExportFormat(outputPath: string): ExchangeFormat {
163190
switch (extname(outputPath).toLowerCase()) {
164191
case ".xlsx":
165-
return UnitExchangeFormat.XLSX;
192+
return ExchangeFormat.XLSX;
166193
case ".docx":
167-
return UnitExchangeFormat.DOCX;
194+
return ExchangeFormat.DOCX;
168195
case ".pptx":
169-
return UnitExchangeFormat.PPTX;
196+
return ExchangeFormat.PPTX;
170197
default:
171198
throw workspaceError(
172199
"workspace-exchange-export-format-unsupported",
@@ -177,12 +204,12 @@ function inferExportFormat(outputPath: string): UnitExchangeFormat {
177204

178205
function requireCompatibleExport(
179206
type: Exclude<WorkspaceUnitType, "board">,
180-
format: UnitExchangeFormat,
207+
format: ExchangeFormat,
181208
): void {
182209
const compatible =
183-
((type === "sheet" || type === "base") && format === UnitExchangeFormat.XLSX) ||
184-
(type === "doc" && format === UnitExchangeFormat.DOCX) ||
185-
(type === "slide" && format === UnitExchangeFormat.PPTX);
210+
((type === "sheet" || type === "base") && format === ExchangeFormat.XLSX) ||
211+
(type === "doc" && format === ExchangeFormat.DOCX) ||
212+
(type === "slide" && format === ExchangeFormat.PPTX);
186213
if (!compatible) {
187214
throw workspaceError(
188215
"workspace-exchange-export-format-mismatch",
@@ -191,7 +218,7 @@ function requireCompatibleExport(
191218
}
192219
}
193220

194-
function toInstanceType(type: Exclude<WorkspaceUnitType, "board">): ImportableUnitType {
221+
function toInstanceType(type: Exclude<WorkspaceUnitType, "board">): WorkspaceExchangeUnitType {
195222
switch (type) {
196223
case "sheet":
197224
return UniverInstanceType.UNIVER_SHEET;
@@ -205,14 +232,34 @@ function toInstanceType(type: Exclude<WorkspaceUnitType, "board">): ImportableUn
205232
}
206233

207234
async function exportUnit(
208-
exchange: UnitExchange,
235+
exchange: ExportOfficeFile,
209236
type: Exclude<WorkspaceUnitType, "board">,
210237
data: Record<string, JsonValue>,
211-
format: UnitExchangeFormat,
238+
format: ExchangeFormat,
212239
outputPath: string,
213240
): Promise<void> {
214-
const unit = { data, type: toInstanceType(type) } as unknown as UnitExchangeData;
215-
await exchange.exportFile({ unit, format, outputPath });
241+
const unitType = toInstanceType(type);
242+
await exchange(data, outputPath, {
243+
format,
244+
type: unitType,
245+
...(unitType === UniverInstanceType.UNIVER_SHEET
246+
? { formulaCalculation: FormulaCalculationMode.FORCED }
247+
: {}),
248+
} as ExportOptions);
249+
}
250+
251+
function importOptions(sourcePath: string, type: WorkspaceExchangeUnitType): ImportOptions {
252+
const extension = extname(sourcePath).toLowerCase();
253+
const format = [".pptm", ".ppsx", ".ppsm", ".potx"].includes(extension)
254+
? ExchangeFormat.PPTX
255+
: undefined;
256+
return {
257+
type,
258+
...(format === undefined ? {} : { format }),
259+
...(type === UniverInstanceType.UNIVER_SHEET && extension === ".xlsx"
260+
? { formulaCalculation: FormulaCalculationMode.FORCED }
261+
: {}),
262+
} as ImportOptions;
216263
}
217264

218265
function requireCreatedUnit(

apps/cli/src/program.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { createDaemonClient, createDaemonControl, type JsonValue } from "@univer
88
import { createDaemonCommand } from "@univer-cli/daemon-command";
99
import { createNodeResourceLibraryFactory } from "@univer-cli/resource-library";
1010
import { createResourcesCommand } from "@univer-cli/resource-library-command";
11-
import { createUnitExchange, type UnitExchange } from "@univer-cli/unit-exchange";
1211
import type {
1312
UniverRenderRuntime,
1413
UniverRenderRuntimeOptions,
@@ -69,7 +68,6 @@ export interface WorkspaceCliProgramOptions {
6968
readonly resourceManifestPath: string;
7069
readonly socketPath: string;
7170
readonly skillDataRoot: string;
72-
readonly unitExchange?: UnitExchange;
7371
readonly write: (text: string) => void;
7472
readonly writeError?: (text: string) => void;
7573
}
@@ -123,7 +121,6 @@ export function createProgram(options: WorkspaceCliProgramOptions): Command {
123121
});
124122
const exchange = new WorkspaceUnitExchangeFeature({
125123
daemon,
126-
exchange: options.unitExchange ?? createUnitExchange(),
127124
createUnit: async (input) => await units.create(input),
128125
resolveRuntimeTarget: async (input) =>
129126
await new WorkspaceContentSource(await auth.authenticatedHttp("client")).resolveRuntimeTarget(
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { mkdtemp, rm, stat } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import {
5+
ExchangeFormat,
6+
FormulaCalculationMode,
7+
exportToFile,
8+
importFile,
9+
} from "@univerjs-pro/exchange-node";
10+
import {
11+
LocaleType,
12+
UniverInstanceType,
13+
type IWorkbookData,
14+
} from "@univerjs/core";
15+
import { describe, expect, it } from "vitest";
16+
17+
describe("Exchange Node runtime", () => {
18+
it("round-trips a real XLSX through the native binding", async () => {
19+
const directory = await mkdtemp(join(tmpdir(), "workspace-exchange-node-"));
20+
const outputPath = join(directory, "roundtrip.xlsx");
21+
try {
22+
await exportToFile(workbookData(), outputPath, {
23+
format: ExchangeFormat.XLSX,
24+
formulaCalculation: FormulaCalculationMode.FORCED,
25+
type: UniverInstanceType.UNIVER_SHEET,
26+
});
27+
expect((await stat(outputPath)).size).toBeGreaterThan(0);
28+
const imported = await importFile(outputPath, {
29+
formulaCalculation: FormulaCalculationMode.FORCED,
30+
type: UniverInstanceType.UNIVER_SHEET,
31+
});
32+
expect(imported.sheets[imported.sheetOrder[0]!]!.cellData?.[0]?.[0]?.v).toBe("A1");
33+
} finally {
34+
await rm(directory, { force: true, recursive: true });
35+
}
36+
});
37+
});
38+
39+
function workbookData(): IWorkbookData {
40+
return {
41+
appVersion: "",
42+
id: "sheet-unit",
43+
locale: LocaleType.EN_US,
44+
name: "Workbook",
45+
resources: [],
46+
rev: 1,
47+
sheetOrder: ["sheet-1"],
48+
sheets: {
49+
"sheet-1": {
50+
cellData: { 0: { 0: { v: "A1" } } },
51+
columnCount: 10,
52+
id: "sheet-1",
53+
name: "Sheet 1",
54+
rowCount: 20,
55+
},
56+
},
57+
styles: {},
58+
};
59+
}

0 commit comments

Comments
 (0)