import fs from "node:fs/promises"; import path from "node:path"; import type { DocxCommitRequest, DocxDiffRequest, DocxEditRequest, DocxInspectRequest, DocxReadRequest, DocxRenderRequest, DocxValidateRequest } from "../contracts.ts"; import { requireMutableDocx, requireOfficeFile } from "../core/paths.ts"; import { sha256Bytes, sha256File } from "../core/hash.ts"; import { OoxmlPackage } from "../ooxml/package.ts"; import { readSemantic, semanticSnapshot } from "../ooxml/semantic.ts"; import { TYPESCRIPT_READER_CAPABILITIES } from "../capabilities.ts"; import { OpenXmlSidecar } from "./openxml-sidecar.ts"; import { probeRenderer, renderDocx } from "./document-renderer.ts"; import { stageEdit, commitRevision, type MutationQueue } from "../core/transaction.ts"; import { diffDocuments } from "../core/document-diff.ts"; import { validateDocument } from "../core/validation.ts"; import { createDocumentArtifact } from "../core/artifact-manifest.ts"; import { assertNotAborted, fail } from "../errors.ts"; import { WORKSPACE_ROOT } from "../core/workspace.ts"; function assertSafeToRender(pkg: OoxmlPackage): void { const manifest = pkg.manifest(); if (manifest.activeContentParts.length) fail("ACTIVE_CONTENT_BLOCKED", "Rendering active-content or macro-enabled Office packages is blocked; inspect/read remain available.", { activeContentParts: manifest.activeContentParts }); const fetchable = manifest.externalRelationships.filter((relationship) => !relationship.type.toLowerCase().endsWith("/hyperlink")); if (fetchable.length) fail("ACTIVE_CONTENT_BLOCKED", "Rendering packages with non-hyperlink external relationships is blocked to prevent renderer-side dereferencing.", { relationships: fetchable }); } export class DocumentService { constructor(readonly cwd: string, readonly queue: MutationQueue = async (_key, work) => work(), readonly sidecar = new OpenXmlSidecar()) {} private async load(request: DocxInspectRequest, signal?: AbortSignal) { assertNotAborted(signal); const sourcePath = await requireOfficeFile(request.path, this.cwd), [bytes, sourceSha256] = await Promise.all([fs.readFile(sourcePath), sha256File(sourcePath)]); assertNotAborted(signal); const pkg = OoxmlPackage.fromBytes(bytes, request.limits), snapshot = semanticSnapshot(pkg, request.includeHiddenData); return { sourcePath, sourceSha256, pkg, snapshot }; } async inspect(request: DocxInspectRequest, signal?: AbortSignal): Promise> { const loaded = await this.load(request, signal), manifest = loaded.pkg.manifest(loaded.sourceSha256), [sidecar, renderer] = await Promise.all([this.sidecar.probe(signal), probeRenderer()]); const warnings = [...loaded.snapshot.warnings]; if (manifest.externalRelationships.length) warnings.push({ code: "EXTERNAL_RELATIONSHIPS", message: `${manifest.externalRelationships.length} external relationship(s) inventoried and never dereferenced.`, severity: "warning" }); if (manifest.signedParts.length) warnings.push({ code: "SIGNED_DOCUMENT", message: "Digital signatures detected; P1 mutation is blocked.", severity: "error" }); if (manifest.activeContentParts.length) warnings.push({ code: "ACTIVE_CONTENT_BLOCKED", message: "Active content detected; P1 mutation is blocked and never executed.", severity: "error" }); return { sourcePath: loaded.sourcePath, sourceSha256: loaded.sourceSha256, format: path.extname(loaded.sourcePath).slice(1).toLowerCase(), properties: loaded.snapshot.properties, stories: loaded.snapshot.stories.map((s) => ({ kind: s.kind, part: s.part, paragraphCount: s.paragraphs.length, tableCount: s.tables.length })), outline: loaded.snapshot.outline, features: loaded.snapshot.inventory, security: { signed: manifest.signedParts.length > 0, activeContent: manifest.activeContentParts.length > 0, externalRelationships: manifest.externalRelationships, protectedParts: manifest.protectedParts }, package: { mainDocumentPart: manifest.mainDocumentPart, partCount: manifest.parts.length, manifest }, engines: { reader: TYPESCRIPT_READER_CAPABILITIES, mutation: sidecar, renderer }, capabilities: [...TYPESCRIPT_READER_CAPABILITIES.operations, ...sidecar.operations], warnings }; } async read(request: DocxReadRequest, signal?: AbortSignal): Promise> { const loaded = await this.load(request, signal); return { sourcePath: loaded.sourcePath, sourceSha256: loaded.sourceSha256, ...readSemantic(loaded.snapshot, request) }; } async render(request: DocxRenderRequest, signal?: AbortSignal): Promise & { pages: Array<{ pageNum: number; width: number; height: number; outputPath: string; bytes: number; png: Buffer }> }> { assertNotAborted(signal); const sourcePath = await requireOfficeFile(request.path, this.cwd), sourceSha256 = await sha256File(sourcePath), pkg = OoxmlPackage.fromBytes(await fs.readFile(sourcePath), request.limits), snapshot = semanticSnapshot(pkg); assertSafeToRender(pkg); const result = await renderDocx(sourcePath, request, signal), postRenderSha256 = await sha256File(sourcePath); if (postRenderSha256 !== sourceSha256) fail("SOURCE_CHANGED", "Source changed during rendering.", { expectedSourceSha256: sourceSha256, actualSourceSha256: postRenderSha256 }); const paragraphs = snapshot.stories.flatMap((story) => story.paragraphs), artifact = await createDocumentArtifact({ title: path.basename(sourcePath), mimeType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", pageCount: result.pageCount, downloadPath: sourcePath, root: path.join(WORKSPACE_ROOT, "artifacts"), manifest: { sourcePath, sourceSha256, renderer: result.renderer, pages: result.pages.map(({ png: _png, ...page }) => page), outline: snapshot.outline, comments: snapshot.stories.filter((story) => story.kind === "comment").flatMap((story) => story.paragraphs.map((paragraph) => ({ text: paragraph.text, selector: paragraph.selector }))), revisions: paragraphs.filter((paragraph) => paragraph.revisionIds.length).map((paragraph) => ({ selector: paragraph.selector, revisionIds: paragraph.revisionIds, text: paragraph.text })), contentControls: snapshot.contentControls, warnings: result.warnings } }); return { sourcePath, sourceSha256, ...result, artifact }; } async edit(request: DocxEditRequest, signal?: AbortSignal): Promise> { const sourcePath = await requireOfficeFile(request.path, this.cwd); requireMutableDocx(sourcePath); return stageEdit(sourcePath, request, this.sidecar, signal); } async diff(request: DocxDiffRequest, signal?: AbortSignal): Promise> { assertNotAborted(signal); const beforePath = await requireOfficeFile(request.beforePath, this.cwd), afterPath = await requireOfficeFile(request.afterPath, this.cwd), [beforeBytes, afterBytes, beforeSha256, afterSha256] = await Promise.all([fs.readFile(beforePath), fs.readFile(afterPath), sha256File(beforePath), sha256File(afterPath)]); assertNotAborted(signal); const beforePackage = OoxmlPackage.fromBytes(beforeBytes, request.limits), afterPackage = OoxmlPackage.fromBytes(afterBytes, request.limits), result: Record = { beforePath, afterPath, beforeSha256, afterSha256, ...diffDocuments(beforePackage, afterPackage, semanticSnapshot(beforePackage), semanticSnapshot(afterPackage), { maxChanges: request.maxChanges, includeFormatting: request.includeFormatting, includePackageParts: request.includePackageParts }) }; if (request.renderPages) { assertSafeToRender(beforePackage); assertSafeToRender(afterPackage); const [beforeRender, afterRender] = await Promise.all([renderDocx(beforePath, { path: beforePath, pages: request.renderPages, timeoutMs: request.timeoutMs }, signal), renderDocx(afterPath, { path: afterPath, pages: request.renderPages, timeoutMs: request.timeoutMs }, signal)]); const beforePages = new Map(beforeRender.pages.map((page) => [page.pageNum, page])), afterPages = new Map(afterRender.pages.map((page) => [page.pageNum, page])); result.visualDiff = { renderer: { before: beforeRender.renderer, after: afterRender.renderer }, beforePageCount: beforeRender.pageCount, afterPageCount: afterRender.pageCount, pages: [...new Set([...beforePages.keys(), ...afterPages.keys()])].sort((a, b) => a - b).map((pageNum) => { const before = beforePages.get(pageNum), after = afterPages.get(pageNum), beforePngSha256 = before ? sha256Bytes(before.png) : undefined, afterPngSha256 = after ? sha256Bytes(after.png) : undefined; return { pageNum, equal: beforePngSha256 === afterPngSha256, before: before ? { width: before.width, height: before.height, bytes: before.bytes, pngSha256: beforePngSha256 } : undefined, after: after ? { width: after.width, height: after.height, bytes: after.bytes, pngSha256: afterPngSha256 } : undefined }; }) }; } return result; } async validate(request: DocxValidateRequest, signal?: AbortSignal): Promise> { const sourcePath = await requireOfficeFile(request.path, this.cwd), baselinePath = request.baselinePath ? await requireOfficeFile(request.baselinePath, this.cwd) : undefined; const result = await validateDocument({ path: sourcePath, baselinePath, expectedChangedParts: request.expectedChangedParts, limits: request.limits, sidecar: this.sidecar, signal, timeoutMs: request.timeoutMs }); if (request.renderPages) { const render = await this.render({ path: sourcePath, pages: request.renderPages, timeoutMs: request.timeoutMs }, signal); result.renderCheck = { ok: true, renderer: render.renderer, pageCount: render.pageCount, pages: render.pages.map(({ png: _png, outputPath: _outputPath, ...page }) => page), artifact: render.artifact }; } return result; } async commit(request: DocxCommitRequest, signal?: AbortSignal): Promise> { return commitRevision(request, this.cwd, this.queue, this.sidecar, signal); } }