From c9732310f066e3e9c559c689669c4208fea0ed31 Mon Sep 17 00:00:00 2001 From: Garfield Date: Mon, 19 Oct 2026 15:30:31 +0800 Subject: [PATCH 1/9] refactor(client): add isomorphic SDK facade and make the browser entry a thin wrapper Generalize the browser client into src/client/ (createClientWith(options, runtime, context)): input normalization, large-file pre-upload, task waiting, resume() and error translation now live once. The browser entry only binds the browser runtime and rejects apiKey. DeckProbe preflight, inspection and the probe Worker leave the browser entry; progress events carry the task's spaceId; the fake cloud serves its fixture image with a real md5. Co-Authored-By: Claude Fable 5.1 --- package.json | 2 +- scripts/check-browser.mjs | 8 +- skills/deckops/SKILL.md | 8 +- skills/deckops/references/artifact.md | 4 +- skills/deckops/references/formats.md | 8 +- skills/deckops/references/limits.md | 4 +- skills/deckops/references/recipes.md | 6 +- src/browser/index.ts | 359 ++------------------------ src/browser/inspector.ts | 103 -------- src/browser/probe-worker.ts | 37 --- src/browser/types.ts | 93 ------- src/client/index.ts | 319 +++++++++++++++++++++++ src/{browser => client}/input.ts | 36 ++- src/client/types.ts | 90 +++++++ src/cloud/browser.ts | 5 +- tests/browser/client.test.ts | 105 ++------ tests/browser/fake-cloud.d.mts | 1 + tests/browser/fake-cloud.mjs | 16 +- tsup.browser.config.ts | 4 +- type-tests/browser/consumer.ts | 17 +- 20 files changed, 522 insertions(+), 703 deletions(-) delete mode 100644 src/browser/inspector.ts delete mode 100644 src/browser/probe-worker.ts delete mode 100644 src/browser/types.ts create mode 100644 src/client/index.ts rename src/{browser => client}/input.ts (62%) create mode 100644 src/client/types.ts diff --git a/package.json b/package.json index 35ff2d8..be1c0f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deckflow/deckops", - "version": "2.6.1", + "version": "3.0.0-next.0", "description": "Parse any document into an agent-operable representation. Parse once, convert repeatedly: PDF, PPTX, DOCX, Keynote and URLs into durable IR artifacts and Markdown.", "type": "module", "main": "./dist/index.js", diff --git a/scripts/check-browser.mjs b/scripts/check-browser.mjs index a9dc5b0..2d7b2c9 100644 --- a/scripts/check-browser.mjs +++ b/scripts/check-browser.mjs @@ -13,8 +13,7 @@ const result = await build({ }, bundle: true, platform: 'browser', - // The published browser contract is native ESM. Keeping this as ESM also - // verifies that import.meta.url remains usable for the packaged Worker. + // The published browser contract is native ESM. format: 'esm', write: false, metafile: true, @@ -34,9 +33,4 @@ assert.equal('openArtifact' in bundled, false); const declaration = await fs.readFile(new URL('../dist/browser/index.d.ts', import.meta.url), 'utf8'); assert.doesNotMatch(declaration, /(?:from\s*|import\s*\()['"](?:node:|@deckops\/sdk)/); assert.doesNotMatch(declaration, /\b(?:Buffer|NodeJS)\b/); -const worker = await fs.readFile(new URL('../dist/browser/probe-worker.js', import.meta.url), 'utf8'); -const wasm = await fs.stat(new URL('../dist/browser/deckprobe_wasm_bg.wasm', import.meta.url)); -assert.match(code, /probe-worker\.js/, 'Browser entry must retain the local DeckProbe Worker URL.'); -assert.match(worker, /deckprobe_wasm_bg\.wasm/, 'DeckProbe Worker must resolve its packaged WASM asset.'); -assert.ok(wasm.size > 1_000_000, 'Packaged DeckProbe WASM asset is missing or truncated.'); console.log(`Browser export: ESM bundle/import OK, standalone types OK (${gzipSync(code).byteLength} bytes gzip).`); diff --git a/skills/deckops/SKILL.md b/skills/deckops/SKILL.md index 8d7206b..3a5c85c 100644 --- a/skills/deckops/SKILL.md +++ b/skills/deckops/SKILL.md @@ -15,7 +15,7 @@ metadata: tool: deckops homepage: https://github.com/deckflow/deckops deckops-skill-format: "1" - tested-cli-version: "2.6.1" + tested-cli-version: "3.0.0-next.0" --- # DeckOps @@ -31,11 +31,11 @@ document. deckops --version ``` -The instructions are tested with DeckOps 2.6.1. If `deckops` is missing and Node.js 22.18+ +The instructions are tested with DeckOps 3.0.0-next.0. If `deckops` is missing and Node.js 22.18+ and network access are available, use this command prefix in the examples below: ```bash -npx -y @deckflow/deckops@2.6.1 +npx -y @deckflow/deckops@3.0.0-next.0 ``` If the installed version differs, inspect `deckops formats --json` and the relevant @@ -158,4 +158,4 @@ Read references only when needed: downstream knowledge preparation. - [output.md](references/output.md): JSON envelopes, exit codes, and output caveats. - [limits.md](references/limits.md): preflight, quality controls, local budgets, cloud recovery, - and known 2.6.1 behavior. + and known 3.0.0-next.0 behavior. diff --git a/skills/deckops/references/artifact.md b/skills/deckops/references/artifact.md index 765dc73..74be4ef 100644 --- a/skills/deckops/references/artifact.md +++ b/skills/deckops/references/artifact.md @@ -36,7 +36,7 @@ view configuration. Regenerating another configuration replaces `views/markdown/ finished delivery elsewhere when several variants must be retained. On a cache hit, `outputs[].bytes` can be zero even when the existing file is non-empty. Check -the file itself. In 2.6.1, a cached artifact view can also bypass materialization of a new +the file itself. In 3.0.0-next.0, a cached artifact view can also bypass materialization of a new portable `-o` destination; use artifact conversion with `--force` and verify the target. The public v2 `deckir.v1` can be rendered locally after the remote reference expires. A cloud @@ -62,7 +62,7 @@ the source changes or parser behavior changes. ## Preparing traceable material for retrieval DeckOps does not create embeddings, a vector index, a retrieval service, or `chunks.jsonl` in -2.6.1. If the task needs downstream chunks, derive them from the artifact and retain this +3.0.0-next.0. If the task needs downstream chunks, derive them from the artifact and retain this minimum evidence: | Evidence | Source | diff --git a/skills/deckops/references/formats.md b/skills/deckops/references/formats.md index 1e8358b..eeab241 100644 --- a/skills/deckops/references/formats.md +++ b/skills/deckops/references/formats.md @@ -1,7 +1,7 @@ # Formats and operations Use `deckops formats --json` for the installed CLI's high-level matrix and the relevant -command's `--help` for live flags. The table below records the DeckOps 2.6.1 behavior that +command's `--help` for live flags. The table below records the DeckOps 3.0.0-next.0 behavior that matters when choosing a workflow. | Input | Parse | Convert | Important limits | @@ -19,7 +19,7 @@ matters when choosing a workflow. | Option | Format | Values and notes | | --- | --- | --- | -| `--profile` | PDF | `balanced` works locally. `fast` and `quality` require cloud parsing in 2.6.1. | +| `--profile` | PDF | `balanced` works locally. `fast` and `quality` require cloud parsing in 3.0.0-next.0. | | `--password` | PDF | Supplies the PDF password. Never repeat or expose it in summaries or logs. | | `--no-images` | PDF | Skips image extraction; use only when images cannot affect the task. | | `--page-furniture` | PDF | `off`, `drop`, or `extract`. | @@ -36,9 +36,9 @@ available. | Option | Applies to | Notes | | --- | --- | --- | -| `--to markdown` | Every convertible artifact/source | Markdown is the only target in 2.6.1. | +| `--to markdown` | Every convertible artifact/source | Markdown is the only target in 3.0.0-next.0. | | `--anchors` | PDF | Emits provenance comments carrying DeckIR node IDs. | -| `--split-pages` | PPTX and Keynote | Use artifact conversion for reliable per-page file materialization in 2.6.1. | +| `--split-pages` | PPTX and Keynote | Use artifact conversion for reliable per-page file materialization in 3.0.0-next.0. | | `--strict` | Cloud Markdown renderer | This is not the local parse-quality or preflight switch. | | `--keep-remote-images` | Cloud view | Accepts expiring remote image links; a result that uses them is not durable offline. | diff --git a/skills/deckops/references/limits.md b/skills/deckops/references/limits.md index 64793fa..cb5b693 100644 --- a/skills/deckops/references/limits.md +++ b/skills/deckops/references/limits.md @@ -42,7 +42,7 @@ if the limitation is reported. `--preflight strict`, `--fail-on-degraded`, and `convert --strict` cover different layers: pre-parse container inspection, parse/IR quality, and cloud Markdown rendering respectively. -Local Markdown conversion rejects `--strict` in 2.6.1. +Local Markdown conversion rejects `--strict` in 3.0.0-next.0. ## Local resource limits @@ -65,7 +65,7 @@ CLI overrides are: Raise only the limit identified by the failure and only enough for a known input. Overrides participate in artifact cache identity. A larger limit does not improve semantic fidelity. -## Known 2.6.1 behavior +## Known 3.0.0-next.0 behavior - Content and envelopes have one stdout owner, including `convert -o - --json`. - An explicitly requested portable output is materialized even if an artifact view exists. diff --git a/skills/deckops/references/recipes.md b/skills/deckops/references/recipes.md index 322f4f6..b2c0e98 100644 --- a/skills/deckops/references/recipes.md +++ b/skills/deckops/references/recipes.md @@ -1,7 +1,7 @@ # Recipes Commands below assume `deckops` is installed. When using the npx fallback, replace the first -word with `npx -y @deckflow/deckops@2.6.1`. +word with `npx -y @deckflow/deckops@3.0.0-next.0`. Choose output paths inside a task-specific work directory. File names are examples. @@ -46,7 +46,7 @@ deckops convert "work/slides-pptx" --engine local --split-pages --json ``` The artifact view contains `index.md`, `001.md`, and subsequent page files. Do not rely on -one-shot portable conversion to create those separate files in 2.6.1. +one-shot portable conversion to create those separate files in 3.0.0-next.0. ## Keynote with existing upload authorization @@ -99,7 +99,7 @@ deckops parse - --from pdf --engine local --preflight validate \ ## Batch processing -DeckOps 2.6.1 has no JSONL batch mode. Iterate an explicit file list in the host environment, +DeckOps 3.0.0-next.0 has no JSONL batch mode. Iterate an explicit file list in the host environment, give every source a unique output path, and collect each process's exit code, stdout, and stderr separately. Avoid concurrent writes to the same artifact or view directory. diff --git a/src/browser/index.ts b/src/browser/index.ts index 2480cb6..5c00c19 100644 --- a/src/browser/index.ts +++ b/src/browser/index.ts @@ -1,346 +1,29 @@ -import { createBrowserTransport } from '../cloud/browser.js'; -import type { ParseSource } from '../cloud/parse-facade.js'; -import type { DeckTask, WaitForTaskOptions } from '../cloud/contracts.js'; +import { browserRuntime } from '../cloud/browser.js'; import { DeckOpsError } from '../errors/index.js'; -import { PRE_UPLOAD_THRESHOLD } from '../shared/constants.js'; -import { translateError } from '../shared/errors.js'; -import { parseParams, convertParams } from '../shared/params.js'; -import { formatForTaskType, validateParseFlagsForFormat, validateConvertFlagsForFormat } from '../shared/validation.js'; -import { resolveBrowserInput } from './input.js'; -import { createBrowserDocumentInspector } from './inspector.js'; -import { - assessPreflight, - DEFAULT_PREFLIGHT_MODE, - preflightProbeOptions, - type DeckProbeReport, - type PreflightOutcome, - type PreflightSummary, -} from '../shared/preflight.js'; -import type { - BrowserClientOptions, - BrowserConvertOptions, - BrowserConvertRef, - BrowserConvertResult, - BrowserInput, - BrowserOperationOptions, - BrowserParseOptions, - BrowserParseResult, - BrowserTask, -} from './types.js'; +import { createClientWith, CloudParsedDocument, type DeckClient } from '../client/index.js'; +import type { ClientOptions } from '../client/types.js'; export { DeckOpsError, ERROR_CODES, type ErrorCode } from '../errors/index.js'; -export type * from './types.js'; - -/** An in-memory result: no directories, implicit disk writes, or automatic asset downloads. */ -export class BrowserParsedDocument { - readonly taskId: string; - readonly type: BrowserParseResult['type']; - readonly irKey: string; - readonly irSchemaVersion: string; - readonly inspection: PreflightSummary | undefined; - readonly warnings: readonly string[]; - private readonly snapshot: R; - private readonly localReport: DeckProbeReport | undefined; - - constructor( - parsed: BrowserParseResult, - private readonly convertView: (options?: BrowserConvertOptions) => Promise, - inspected: PreflightOutcome = { warnings: [] } - ) { - this.taskId = parsed.taskId; - this.type = parsed.type; - this.irKey = parsed.irKey; - this.irSchemaVersion = parsed.irSchemaVersion; - this.snapshot = parsed.ir; - this.inspection = inspected.summary; - this.localReport = inspected.report; - this.warnings = inspected.warnings; - } - - /** Returns the in-memory server response. Does not refetch it or extend cloud retention. */ - async ir(): Promise { - return this.snapshot; - } - - async inspectionReport(): Promise { - return this.localReport; - } - - async convert(options: BrowserConvertOptions = {}): Promise { - const format = formatForTaskType(this.type); - if (format) validateConvertFlagsForFormat(format, options); - return this.convertView(options); - } -} - -export interface BrowserClient { - parse(input: BrowserInput, options?: BrowserParseOptions): Promise>; - /** Accepts only an IR reference or parse task id, never a source file. */ - convert(ref: BrowserConvertRef, options?: BrowserConvertOptions): Promise; - /** Inspect a submitted task after a local timeout/disconnect without submitting it again. */ - getTask(taskId: string, options?: { signal?: AbortSignal; spaceId?: string }): Promise; -} +export { CloudParsedDocument as BrowserParsedDocument }; +export type { + ConvertImage as BrowserImage, ConvertOptions as BrowserConvertOptions, + ConvertRef as BrowserConvertRef, ConvertResult as BrowserConvertResult, OperationOptions as BrowserOperationOptions, + ParseOptions as BrowserParseOptions, ParseResult as BrowserParseResult, ProgressEvent as BrowserProgress, + TaskInfo as BrowserTask, TaskStatus as BrowserTaskStatus, +} from '../client/types.js'; + +/** Browser inputs are values, never local filesystem paths. */ +export type BrowserInput = File | { file: Blob | Uint8Array | ArrayBuffer; name: string } | { url: string }; +/** Never put a server API key in browser code; the browser client has no `apiKey` option. */ +export type BrowserClientOptions = Omit; +export type BrowserClient = DeckClient; /** Explicit browser entry; importing this module is also safe during SSR. */ export function createClient(options: BrowserClientOptions = {}): BrowserClient { - assertOptions(options, ['apiBase', 'token', 'spaceId', 'onUnauthorized', 'inspector'], 'client'); - if (options.token !== undefined) nonemptyString(options.token, 'token'); - if (options.spaceId !== undefined) nonemptyString(options.spaceId, 'spaceId'); - if (options.onUnauthorized !== undefined && typeof options.onUnauthorized !== 'function') { - throw DeckOpsError.usage('onUnauthorized must be a function.'); - } - if (options.inspector !== undefined && typeof options.inspector.inspect !== 'function') { - throw DeckOpsError.usage('inspector must provide an inspect() function.'); - } - const localInspector = options.inspector ?? createBrowserDocumentInspector(); - const deck = createBrowserTransport({ - ...(options.apiBase !== undefined ? { root: apiRoot(options.apiBase) } : {}), - ...(options.token !== undefined ? { token: options.token } : {}), - ...(options.spaceId !== undefined ? { spaceId: options.spaceId } : {}), - ...(options.onUnauthorized !== undefined ? { onUnauthorized: async () => { - // Token refresh must not implicitly move an in-flight upload/document - // to a different space. Account changes need an explicit new client. - const token = await options.onUnauthorized!(); - if (typeof token !== 'string' || !token.trim()) { - throw DeckOpsError.auth('Token refresh must return a nonempty token for the same user.'); - } - return token; - } } : {}), - // Set explicitly as well as using the upstream browser defaults. - allowGuestFallback: false, - retryMutations: false, - }); - - const convert = async (ref: BrowserConvertRef, settings: BrowserConvertOptions = {}): Promise => { - assertOptions(ref, ['irKey', 'taskId'], 'conversion reference'); - if ((ref.irKey !== undefined) === (ref.taskId !== undefined)) { - throw DeckOpsError.input('Provide exactly one of irKey or the parse taskId.'); - } - nonemptyString(ref.irKey ?? ref.taskId, 'IR reference'); - validateOperation(settings, ['to', 'anchors', 'splitPages', 'strict']); - if (settings.to !== undefined && settings.to !== 'markdown') { - throw DeckOpsError.unsupported('Only conversion to markdown is supported.'); - } - validateBooleans(settings, ['anchors', 'splitPages', 'strict']); - let taskId: string | undefined; - try { - throwIfAborted(settings.signal); - const result = await deck.convert(ref, { - to: 'markdown', - ...convertParams(settings), - ...(settings.spaceId !== undefined ? { spaceId: settings.spaceId } : {}), - ...(settings.signal !== undefined ? { signal: settings.signal } : {}), - onTask: (task) => { - taskId = task.id; - reportTask('convert', settings, task); - }, - wait: waitOptions('convert', settings), - }); - throwIfAborted(settings.signal); - if (result.markdownError) { - throw DeckOpsError.backend(`Markdown rendering failed: ${result.markdownError}`, { taskId: result.taskId }); - } - return { ...result, reusedParse: true }; - } catch (error) { - rethrow(error, settings.signal, taskId); - } - }; - - return { - convert, - async parse(input: BrowserInput, settings: BrowserParseOptions = {}): Promise> { - validateOperation(settings, ['profile', 'password', 'includeImages', 'stayImageAreaRate', 'mode', 'preflight']); - validateBooleans(settings, ['includeImages']); - if (settings.profile !== undefined && !['fast', 'balanced', 'quality'].includes(settings.profile)) { - throw DeckOpsError.usage('profile must be fast, balanced, or quality.'); - } - if (settings.mode !== undefined && !['source', 'runtime'].includes(settings.mode)) { - throw DeckOpsError.usage('mode must be source or runtime.'); - } - if (settings.password !== undefined && typeof settings.password !== 'string') { - throw DeckOpsError.usage('password must be a string.'); - } - if (settings.stayImageAreaRate !== undefined && - (!Number.isFinite(settings.stayImageAreaRate) || settings.stayImageAreaRate < 0 || settings.stayImageAreaRate > 1)) { - throw DeckOpsError.usage('stayImageAreaRate must be a number between 0 and 1.'); - } - if (settings.preflight !== undefined && !['off', 'validate', 'strict'].includes(settings.preflight)) { - throw DeckOpsError.usage('preflight must be off, validate, or strict.'); - } - throwIfAborted(settings.signal); - const inputSource = resolveBrowserInput(input); - validateParseFlagsForFormat(inputSource.format, settings); - let taskId: string | undefined; - // Leave the default to the SDK: it may have refreshed/resolved it since - // construction. Only per-operation overrides should be sent explicitly. - let spaceId = settings.spaceId; - try { - let inspected: PreflightOutcome = { warnings: [] }; - const preflightMode = settings.preflight ?? DEFAULT_PREFLIGHT_MODE; - if (preflightMode !== 'off') { - if (inputSource.kind === 'url') { - inspected.warnings.push('DeckProbe preflight is local-only and was skipped for the URL input.'); - } else { - settings.onProgress?.({ phase: 'preflight', status: 'running' }); - try { - const result = await localInspector.inspect( - inputSource, - preflightProbeOptions(inputSource.format), - settings.signal - ); - inspected = assessPreflight(result, { - mode: preflightMode, - format: inputSource.format, - passwordProvided: Boolean(settings.password), - }); - } catch (error) { - if (error instanceof DeckOpsError) throw error; - if (preflightMode === 'strict') { - throw DeckOpsError.input(`DeckProbe preflight failed to run: ${errorMessage(error)}`, { - hint: 'Retry with preflight validate/off.', - cause: error, - }); - } - inspected = { warnings: [`DeckProbe preflight failed to run: ${errorMessage(error)}. Cloud parsing will continue.`] }; - } - throwIfAborted(settings.signal); - settings.onProgress?.({ phase: 'preflight', status: 'completed' }); - } - } - const onUpload = (progress: number): void => settings.onProgress?.({ phase: 'upload', progress }); - let source: ParseSource; - if (inputSource.kind === 'url') { - source = { url: inputSource.url, ...(settings.mode !== undefined ? { mode: settings.mode } : {}) }; - } else if (inputSource.bytes >= PRE_UPLOAD_THRESHOLD) { - const uploaded = await deck.files.upload(inputSource.data, { - name: inputSource.name, - onProgress: onUpload, - ...(spaceId !== undefined ? { spaceId } : {}), - ...(settings.signal !== undefined ? { signal: settings.signal } : {}), - }); - throwIfAborted(settings.signal); - source = { fileId: uploaded.id, name: inputSource.name }; - } else { - // The name must reach upload normalization, not only parser routing. - source = { file: { input: inputSource.data, name: inputSource.name }, name: inputSource.name }; - } - const parsed = await deck.parse(source, { - ...parseParams(settings, false), - ...(spaceId !== undefined ? { spaceId } : {}), - ...(settings.signal !== undefined ? { signal: settings.signal } : {}), - upload: { onProgress: onUpload }, - onTask: (task) => { - taskId = task.id; - spaceId = task.spaceId; - reportTask('parse', settings, task); - }, - wait: waitOptions('parse', settings), - }); - throwIfAborted(settings.signal); - return new BrowserParsedDocument(parsed, (convertOptions = {}) => - convert({ irKey: parsed.irKey }, { ...(spaceId !== undefined ? { spaceId } : {}), ...convertOptions }), - inspected - ); - } catch (error) { - rethrow(error, settings.signal, taskId); - } - }, - async getTask(taskId, settings = {}) { - nonemptyString(taskId, 'taskId'); - assertOptions(settings, ['signal', 'spaceId'], 'task'); - if (settings.spaceId !== undefined) nonemptyString(settings.spaceId, 'spaceId'); - try { - throwIfAborted(settings.signal); - const task = await deck.tasks.get(taskId, settings); - throwIfAborted(settings.signal); - return { id: task.id, spaceId: task.spaceId, type: task.type, status: task.status, - ...(task.error !== undefined ? { error: task.error } : {}) }; - } catch (error) { - rethrow(error, settings.signal, taskId); - } - }, - }; -} - -function reportTask(phase: 'parse' | 'convert', options: BrowserOperationOptions, task: DeckTask): void { - options.onProgress?.({ phase, taskId: task.id, status: task.status }); -} - -function waitOptions(phase: 'parse' | 'convert', options: BrowserOperationOptions): WaitForTaskOptions { - return { - ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), - ...(options.signal !== undefined ? { signal: options.signal } : {}), - ...(options.useEventStream !== undefined ? { useEventStream: options.useEventStream } : {}), - ...(options.pollInterval !== undefined ? { pollInterval: options.pollInterval } : {}), - onProgress: (task) => reportTask(phase, options, task), - }; -} - -function throwIfAborted(signal: AbortSignal | undefined): void { - signal?.throwIfAborted(); -} - -function rethrow(error: unknown, signal: AbortSignal | undefined, taskId?: string): never { - throwIfAborted(signal); - if (error instanceof Error && error.name === 'AbortError') throw error; - throw translateError(error, 'browser', taskId); -} - -function assertOptions(value: unknown, allowed: readonly string[], label: string): asserts value is object { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw DeckOpsError.usage(`${label} options must be an object.`); - } - for (const key of Object.keys(value)) { - if (!allowed.includes(key)) { - const hint = key === 'apiKey' - ? 'Keep server API keys on your backend. Use a browser user token or an authenticated proxy.' - : 'The browser SDK accepts values and returns data; filesystem and CLI options are not supported.'; - throw DeckOpsError.usage(`Unknown ${label} option: ${key}.`, { hint }); - } - } -} - -function validateOperation(options: BrowserOperationOptions, flags: readonly string[]): void { - assertOptions(options, ['spaceId', 'timeout', 'signal', 'onProgress', 'useEventStream', 'pollInterval', ...flags], 'operation'); - if (options.spaceId !== undefined) nonemptyString(options.spaceId, 'spaceId'); - for (const key of ['timeout', 'pollInterval'] as const) { - if (options[key] !== undefined && (!Number.isFinite(options[key]) || options[key]! <= 0)) { - throw DeckOpsError.usage(`${key} must be a positive finite number.`); - } - } - if (options.onProgress !== undefined && typeof options.onProgress !== 'function') { - throw DeckOpsError.usage('onProgress must be a function.'); - } - validateBooleans(options, ['useEventStream']); -} - -function validateBooleans(options: object, names: readonly string[]): void { - const record = options as Record; - for (const name of names) { - if (record[name] !== undefined && typeof record[name] !== 'boolean') { - throw DeckOpsError.usage(`${name} must be a boolean.`); - } - } -} - -function nonemptyString(value: unknown, label: string): asserts value is string { - if (typeof value !== 'string' || !value.trim()) throw DeckOpsError.input(`${label} must be a nonempty string.`); -} - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - -function apiRoot(value: string): string { - nonemptyString(value, 'apiBase'); - try { - const base = typeof location === 'undefined' ? undefined : location.href; - const url = new URL(value, base); - if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { - throw new Error('invalid API root'); - } - return url.href.replace(/\/$/, ''); - } catch { - throw DeckOpsError.usage('apiBase must be an HTTP(S) API root, or a relative URL when running in a browser.'); + if (typeof options === 'object' && options !== null && 'apiKey' in options) { + throw DeckOpsError.usage('Unknown client option: apiKey.', { + hint: 'Keep server API keys on your backend. Use a browser user token or an authenticated proxy.', + }); } + return createClientWith(options, browserRuntime, 'browser'); } diff --git a/src/browser/inspector.ts b/src/browser/inspector.ts deleted file mode 100644 index ca975ca..0000000 --- a/src/browser/inspector.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type { ProbeCallOptions, ProbeResult } from '@deckflow/deckprobe'; -import type { BrowserSource } from './input.js'; - -interface WorkerRequest { - id: number; - name: string; - input: Blob | ArrayBuffer; - options: ProbeCallOptions; -} - -interface WorkerResponse { - id: number; - result?: ProbeResult; - error?: { name: string; message: string }; -} - -interface Pending { - resolve: (result: ProbeResult) => void; - reject: (error: Error) => void; -} - -export interface BrowserDocumentInspector { - inspect( - input: Extract, - options: ProbeCallOptions, - signal?: AbortSignal - ): Promise; -} - -/** Reuses one module Worker per DeckOps client and keeps WASM parsing off the UI thread. */ -export function createBrowserDocumentInspector(): BrowserDocumentInspector { - let worker: Worker | undefined; - let nextId = 1; - const pending = new Map(); - - const ensureWorker = (): Worker => { - if (worker) return worker; - worker = new Worker(new URL('./probe-worker.js', import.meta.url), { type: 'module' }); - worker.addEventListener('message', (event: MessageEvent) => { - const waiting = pending.get(event.data.id); - if (!waiting) return; - pending.delete(event.data.id); - if (event.data.error) { - const error = new Error(event.data.error.message); - error.name = event.data.error.name; - waiting.reject(error); - } else if (event.data.result) { - waiting.resolve(event.data.result); - } else { - waiting.reject(new Error('DeckProbe worker returned an empty response.')); - } - }); - worker.addEventListener('error', (event) => { - const error = new Error(event.message || 'DeckProbe worker failed to load.'); - for (const waiting of pending.values()) waiting.reject(error); - pending.clear(); - worker?.terminate(); - worker = undefined; - }); - return worker; - }; - - return { - inspect: async (input, options, signal) => { - signal?.throwIfAborted(); - const active = ensureWorker(); - const id = nextId++; - const data = input.data; - let payload: Blob | ArrayBuffer; - if (isBlob(data)) { - payload = data; - } else if (data instanceof ArrayBuffer) { - payload = data.slice(0); - } else { - payload = data.slice().buffer as ArrayBuffer; - } - const request: WorkerRequest = { id, name: input.name, input: payload, options }; - const result = new Promise((resolve, reject) => pending.set(id, { resolve, reject })); - if (payload instanceof ArrayBuffer) active.postMessage(request, [payload]); - else active.postMessage(request); - return waitForProbe(result, signal); - }, - }; -} - -function isBlob(value: Blob | Uint8Array | ArrayBuffer): value is Blob { - return typeof Blob !== 'undefined' && value instanceof Blob; -} - -async function waitForProbe(result: Promise, signal: AbortSignal | undefined): Promise { - if (!signal) return result; - signal.throwIfAborted(); - let abort: (() => void) | undefined; - const aborted = new Promise((_resolve, reject) => { - abort = () => reject(signal.reason ?? new DOMException('The operation was aborted.', 'AbortError')); - signal.addEventListener('abort', abort, { once: true }); - }); - try { - return await Promise.race([result, aborted]); - } finally { - if (abort) signal.removeEventListener('abort', abort); - } -} diff --git a/src/browser/probe-worker.ts b/src/browser/probe-worker.ts deleted file mode 100644 index 7802325..0000000 --- a/src/browser/probe-worker.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { probe, type ProbeCallOptions, type ProbeResult } from '@deckflow/deckprobe'; - -interface WorkerRequest { - id: number; - name: string; - input: Blob | ArrayBuffer; - options: ProbeCallOptions; -} - -interface WorkerResponse { - id: number; - result?: ProbeResult; - error?: { name: string; message: string }; -} - -const scope = globalThis as unknown as { - onmessage: ((event: MessageEvent) => void) | null; - postMessage(message: WorkerResponse): void; -}; - -scope.onmessage = (event) => { - void (async () => { - const { id, name, input, options } = event.data; - try { - const result = await probe(input instanceof Blob ? input : new Uint8Array(input), { ...options, name }); - scope.postMessage({ id, result }); - } catch (error) { - scope.postMessage({ - id, - error: { - name: error instanceof Error ? error.name : 'Error', - message: error instanceof Error ? error.message : String(error), - }, - }); - } - })(); -}; diff --git a/src/browser/types.ts b/src/browser/types.ts deleted file mode 100644 index 9448f03..0000000 --- a/src/browser/types.ts +++ /dev/null @@ -1,93 +0,0 @@ -import type { ConvertFlags, ParseFlags, ParseTaskType } from '../types.js'; -import type { DeckProbeReport, PreflightMode, PreflightSummary } from '../shared/preflight.js'; -import type { BrowserDocumentInspector } from './inspector.js'; - -/** Browser inputs are values, never local filesystem paths. */ -export type BrowserInput = - | File - | { file: Blob | Uint8Array | ArrayBuffer; name: string } - | { url: string }; - -export type BrowserTaskStatus = 'pending' | 'running' | 'completed' | 'failed'; - -export interface BrowserTask { - id: string; - spaceId: string; - type: string; - status: BrowserTaskStatus; - error?: string | null; -} - -export type BrowserProgress = - | { phase: 'preflight'; status: 'running' | 'completed' } - | { phase: 'upload'; progress: number } - | { phase: 'parse' | 'convert'; taskId: string; status: BrowserTaskStatus }; - -export interface BrowserClientOptions { - /** Cloud API root or an authenticated same-origin proxy, e.g. /api/deckops. */ - apiBase?: string; - /** User-scoped browser credential. Never put a server API key in browser code. */ - token?: string; - spaceId?: string; - /** Refresh the same user's token once after a 401; changing accounts/spaces requires a new client. */ - onUnauthorized?: () => Promise; - /** Optional custom local inspector; DeckOps uses its DeckProbe Worker adapter by default. */ - inspector?: BrowserDocumentInspector; -} - -export interface BrowserOperationOptions { - spaceId?: string; - /** Task wait timeout in seconds (not milliseconds). Defaults to 300. */ - timeout?: number; - /** Cancels local upload/request/waiting, not an already-created cloud task. */ - signal?: AbortSignal; - /** Task events contain the id immediately after submission, for later recovery. */ - onProgress?: (event: BrowserProgress) => void; - /** Prefer SSE, falling back to polling. Set false for polling only. */ - useEventStream?: boolean; - /** Polling interval in milliseconds. Defaults to 2000. */ - pollInterval?: number; -} - -export interface BrowserParseOptions extends ParseFlags, BrowserOperationOptions { - /** Defaults to validate for local files. URLs are skipped; use off to avoid Worker/WASM startup. */ - preflight?: PreflightMode; -} - -export interface BrowserConvertOptions - extends Pick, - BrowserOperationOptions {} - -export type BrowserConvertRef = { irKey: string; taskId?: never } | { taskId: string; irKey?: never }; - -export interface BrowserImage { - /** Signed, expiring URL used in the Markdown. Suitable for temporary preview only. */ - ref: string; - /** Stable identity, not a promise of indefinite cloud retention. */ - key: string; - suggestedPath: string; - bytes?: number; - hash?: string; -} - -export interface BrowserConvertResult { - taskId: string; - format: 'pdf' | 'pptx' | 'docx' | 'keynote' | 'html'; - schemaVersion: string; - to: 'markdown'; - markdown: string; - markdownPages?: string[]; - images: BrowserImage[]; - /** Conversion references stored IR; it never resubmits the source. */ - reusedParse: true; -} - -export interface BrowserParseResult { - taskId: string; - type: ParseTaskType | 'html.getByURL'; - irKey: string; - irSchemaVersion: string; - ir: R; -} - -export type { DeckProbeReport, PreflightMode, PreflightSummary }; diff --git a/src/client/index.ts b/src/client/index.ts new file mode 100644 index 0000000..78b2a11 --- /dev/null +++ b/src/client/index.ts @@ -0,0 +1,319 @@ +import { createTransport, type TransportClient } from '../cloud/transport.js'; +import type { DeckRuntime } from '../cloud/runtime.js'; +import type { ParseSource } from '../cloud/parse-facade.js'; +import type { DeckTask, WaitForTaskOptions } from '../cloud/contracts.js'; +import { DECK_TASK_TYPES } from '../cloud/contracts.js'; +import { DeckOpsError } from '../errors/index.js'; +import { PRE_UPLOAD_THRESHOLD } from '../shared/constants.js'; +import { translateError } from '../shared/errors.js'; +import { parseParams, convertParams } from '../shared/params.js'; +import { formatForTaskType, validateParseFlagsForFormat, validateConvertFlagsForFormat } from '../shared/validation.js'; +import type { ParseTaskType } from '../types.js'; +import { resolveInput } from './input.js'; +import type { + ClientInput, ClientOptions, ConvertOptions, ConvertRef, ConvertResult, OperationOptions, ParseOptions, ParseResult, TaskInfo, +} from './types.js'; + +export type * from './types.js'; + +/** + * The isomorphic SDK facade: input normalization, large-file pre-upload, task waiting and + * error translation. It never touches a filesystem; Node adds artifacts in `node/`. + */ + +export type HintContext = 'node' | 'browser'; +export type ParseType = ParseTaskType | 'html.getByURL'; + +/** An in-memory result: the server response plus a bound convert(). */ +export class CloudParsedDocument { + readonly taskId: string; + readonly spaceId: string; + readonly type: ParseType; + readonly irKey: string; + readonly irSchemaVersion: string; + private readonly snapshot: R; + + constructor(parsed: ParseResult, private readonly convertView: (options?: ConvertOptions) => Promise) { + this.taskId = parsed.taskId; + this.spaceId = parsed.spaceId; + this.type = parsed.type; + this.irKey = parsed.irKey; + this.irSchemaVersion = parsed.irSchemaVersion; + this.snapshot = parsed.ir; + } + + /** Returns the in-memory server response. Does not refetch it or extend cloud retention. */ + async ir(): Promise { + return this.snapshot; + } + + async convert(options: ConvertOptions = {}): Promise { + const format = formatForTaskType(this.type); + if (format) validateConvertFlagsForFormat(format, options); + return this.convertView(options); + } +} + +export interface DeckClient { + parse(input: Input, options?: ParseOptions): Promise>; + /** Accepts only an IR reference or parse task id, never a source file. */ + convert(ref: ConvertRef, options?: ConvertOptions): Promise; + /** Wait for a parse task an earlier call submitted and fetch its result: no upload, no new task. */ + resume(taskId: string, type: ParseType, options?: OperationOptions): Promise>; + /** Inspect a submitted task after a local timeout/disconnect without submitting it again. */ + getTask(taskId: string, options?: { signal?: AbortSignal; spaceId?: string }): Promise; +} + +const CLIENT_OPTIONS = ['apiBase', 'token', 'apiKey', 'spaceId', 'onUnauthorized']; +const PARSE_TYPES: readonly string[] = DECK_TASK_TYPES.filter((type) => type !== 'parse.convert'); + +export function createClientWith( + options: ClientOptions, runtime: DeckRuntime, context: HintContext +): DeckClient { + assertOptions(options, CLIENT_OPTIONS, 'client', context); + for (const key of ['token', 'apiKey', 'spaceId'] as const) { + if (options[key] !== undefined) nonemptyString(options[key], key); + } + if (options.onUnauthorized !== undefined && typeof options.onUnauthorized !== 'function') { + throw DeckOpsError.usage('onUnauthorized must be a function.'); + } + const deck: TransportClient = createTransport({ + ...(options.apiBase !== undefined ? { root: apiRoot(options.apiBase) } : {}), + ...(options.token !== undefined ? { token: options.token } : {}), + ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}), + ...(options.spaceId !== undefined ? { spaceId: options.spaceId } : {}), + ...(options.onUnauthorized !== undefined ? { onUnauthorized: async () => { + // Token refresh must not implicitly move an in-flight upload/document + // to a different space. Account changes need an explicit new client. + const token = await options.onUnauthorized!(); + if (typeof token !== 'string' || !token.trim()) { + throw DeckOpsError.auth('Token refresh must return a nonempty token for the same user.'); + } + return token; + } } : {}), + // A rejected credential is auth_error, never a silent guest; task POSTs are never replayed. + allowGuestFallback: false, + retryMutations: false, + }, runtime); + + const convert = async (ref: ConvertRef, settings: ConvertOptions = {}): Promise => { + assertOptions(ref, ['irKey', 'taskId'], 'conversion reference', context); + if ((ref.irKey !== undefined) === (ref.taskId !== undefined)) { + throw DeckOpsError.input('Provide exactly one of irKey or the parse taskId.'); + } + nonemptyString(ref.irKey ?? ref.taskId, 'IR reference'); + validateOperation(settings, ['to', 'anchors', 'splitPages', 'strict'], context); + if (settings.to !== undefined && settings.to !== 'markdown') { + throw DeckOpsError.unsupported('Only conversion to markdown is supported.'); + } + validateBooleans(settings, ['anchors', 'splitPages', 'strict']); + let taskId: string | undefined; + try { + throwIfAborted(settings.signal); + const result = await deck.convert(ref, { + to: 'markdown', + ...convertParams(settings), + ...(settings.spaceId !== undefined ? { spaceId: settings.spaceId } : {}), + ...(settings.signal !== undefined ? { signal: settings.signal } : {}), + onTask: (task) => { + taskId = task.id; + reportTask('convert', settings, task); + }, + wait: waitOptions('convert', settings), + }); + throwIfAborted(settings.signal); + if (result.markdownError) { + throw DeckOpsError.backend(`Markdown rendering failed: ${result.markdownError}`, { taskId: result.taskId }); + } + return { ...result, reusedParse: true }; + } catch (error) { + rethrow(error, context, settings.signal, taskId); + } + }; + + const document = (parsed: ParseResult): CloudParsedDocument => + new CloudParsedDocument(parsed, (convertOptions = {}) => + convert({ irKey: parsed.irKey }, { ...(parsed.spaceId ? { spaceId: parsed.spaceId } : {}), ...convertOptions })); + + return { + convert, + async parse(input: Input, settings: ParseOptions = {}): Promise> { + validateOperation(settings, ['profile', 'password', 'includeImages', 'stayImageAreaRate', 'mode', 'onSubmit'], context); + validateBooleans(settings, ['includeImages']); + if (settings.profile !== undefined && !['fast', 'balanced', 'quality'].includes(settings.profile)) { + throw DeckOpsError.usage('profile must be fast, balanced, or quality.'); + } + if (settings.mode !== undefined && !['source', 'runtime'].includes(settings.mode)) { + throw DeckOpsError.usage('mode must be source or runtime.'); + } + if (settings.password !== undefined && typeof settings.password !== 'string') { + throw DeckOpsError.usage('password must be a string.'); + } + if (settings.stayImageAreaRate !== undefined && + (!Number.isFinite(settings.stayImageAreaRate) || settings.stayImageAreaRate < 0 || settings.stayImageAreaRate > 1)) { + throw DeckOpsError.usage('stayImageAreaRate must be a number between 0 and 1.'); + } + if (settings.onSubmit !== undefined && typeof settings.onSubmit !== 'function') { + throw DeckOpsError.usage('onSubmit must be a function.'); + } + throwIfAborted(settings.signal); + const inputSource = await resolveInput(input, runtime, settings.signal); + const { onSubmit, ...flags } = settings; + validateParseFlagsForFormat(inputSource.format, flags); + let taskId: string | undefined; + // Leave the default to the transport: it may have resolved it since construction. + // Only per-operation overrides are sent explicitly. + let spaceId = settings.spaceId; + try { + const onUpload = (progress: number): void => settings.onProgress?.({ phase: 'upload', progress }); + let source: ParseSource; + if (inputSource.kind === 'url') { + source = { url: inputSource.url, ...(settings.mode !== undefined ? { mode: settings.mode } : {}) }; + } else if (inputSource.bytes >= PRE_UPLOAD_THRESHOLD) { + const uploaded = await deck.files.upload(inputSource.data, { + name: inputSource.name, + onProgress: onUpload, + ...(spaceId !== undefined ? { spaceId } : {}), + ...(settings.signal !== undefined ? { signal: settings.signal } : {}), + }); + throwIfAborted(settings.signal); + source = { fileId: uploaded.id, name: inputSource.name }; + } else { + // The name must reach upload normalization, not only parser routing. + source = { file: { input: inputSource.data, name: inputSource.name }, name: inputSource.name }; + } + const parsed = await deck.parse(source, { + ...parseParams(flags, false), + ...(spaceId !== undefined ? { spaceId } : {}), + ...(settings.signal !== undefined ? { signal: settings.signal } : {}), + ...(onSubmit !== undefined ? { onSubmit } : {}), + upload: { onProgress: onUpload }, + onTask: (task) => { + taskId = task.id; + spaceId = task.spaceId; + reportTask('parse', settings, task); + }, + wait: waitOptions('parse', settings), + }); + throwIfAborted(settings.signal); + return document({ ...parsed, spaceId: spaceId ?? '' }); + } catch (error) { + rethrow(error, context, settings.signal, taskId); + } + }, + async resume(taskId: string, type: ParseType, settings: OperationOptions = {}): Promise> { + nonemptyString(taskId, 'taskId'); + if (!PARSE_TYPES.includes(type)) throw DeckOpsError.usage(`Unknown parse task type: ${String(type)}.`); + validateOperation(settings, [], context); + let spaceId = settings.spaceId; + try { + throwIfAborted(settings.signal); + const parsed = await deck.resume(taskId, type, { + ...(spaceId !== undefined ? { spaceId } : {}), + ...(settings.signal !== undefined ? { signal: settings.signal } : {}), + wait: waitOptions('parse', { ...settings, onProgress: (event) => { + if (event.phase === 'parse') spaceId = event.spaceId; + settings.onProgress?.(event); + } }), + }); + throwIfAborted(settings.signal); + return document({ ...parsed, spaceId: spaceId ?? '' }); + } catch (error) { + rethrow(error, context, settings.signal, taskId); + } + }, + async getTask(taskId, settings = {}) { + nonemptyString(taskId, 'taskId'); + assertOptions(settings, ['signal', 'spaceId'], 'task', context); + if (settings.spaceId !== undefined) nonemptyString(settings.spaceId, 'spaceId'); + try { + throwIfAborted(settings.signal); + const task = await deck.tasks.get(taskId, settings); + throwIfAborted(settings.signal); + return { id: task.id, spaceId: task.spaceId, type: task.type, status: task.status, + ...(task.error !== undefined ? { error: task.error } : {}) }; + } catch (error) { + rethrow(error, context, settings.signal, taskId); + } + }, + }; +} + +function reportTask(phase: 'parse' | 'convert', options: OperationOptions, task: DeckTask): void { + options.onProgress?.({ phase, taskId: task.id, spaceId: task.spaceId, status: task.status }); +} + +function waitOptions(phase: 'parse' | 'convert', options: OperationOptions): WaitForTaskOptions { + return { + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), + ...(options.signal !== undefined ? { signal: options.signal } : {}), + ...(options.useEventStream !== undefined ? { useEventStream: options.useEventStream } : {}), + ...(options.pollInterval !== undefined ? { pollInterval: options.pollInterval } : {}), + onProgress: (task) => reportTask(phase, options, task), + }; +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + signal?.throwIfAborted(); +} + +function rethrow(error: unknown, context: HintContext, signal: AbortSignal | undefined, taskId?: string): never { + throwIfAborted(signal); + if (error instanceof Error && error.name === 'AbortError') throw error; + throw translateError(error, context, taskId); +} + +function assertOptions(value: unknown, allowed: readonly string[], label: string, context: HintContext): asserts value is object { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw DeckOpsError.usage(`${label} options must be an object.`); + } + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) { + const hint = context === 'browser' + ? 'The browser SDK accepts values and returns data; filesystem and CLI options are not supported.' + : 'Artifact, output and cache options belong to the Node operations, not the cloud client.'; + throw DeckOpsError.usage(`Unknown ${label} option: ${key}.`, { hint }); + } + } +} + +function validateOperation(options: OperationOptions, flags: readonly string[], context: HintContext): void { + assertOptions(options, ['spaceId', 'timeout', 'signal', 'onProgress', 'useEventStream', 'pollInterval', ...flags], 'operation', context); + if (options.spaceId !== undefined) nonemptyString(options.spaceId, 'spaceId'); + for (const key of ['timeout', 'pollInterval'] as const) { + if (options[key] !== undefined && (!Number.isFinite(options[key]) || options[key]! <= 0)) { + throw DeckOpsError.usage(`${key} must be a positive finite number.`); + } + } + if (options.onProgress !== undefined && typeof options.onProgress !== 'function') { + throw DeckOpsError.usage('onProgress must be a function.'); + } + validateBooleans(options, ['useEventStream']); +} + +function validateBooleans(options: object, names: readonly string[]): void { + const record = options as Record; + for (const name of names) { + if (record[name] !== undefined && typeof record[name] !== 'boolean') { + throw DeckOpsError.usage(`${name} must be a boolean.`); + } + } +} + +function nonemptyString(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !value.trim()) throw DeckOpsError.input(`${label} must be a nonempty string.`); +} + +function apiRoot(value: string): string { + nonemptyString(value, 'apiBase'); + try { + const base = typeof location === 'undefined' ? undefined : location.href; + const url = new URL(value, base); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) { + throw new Error('invalid API root'); + } + return url.href.replace(/\/$/, ''); + } catch { + throw DeckOpsError.usage('apiBase must be an HTTP(S) API root, or a relative URL when running in a browser.'); + } +} diff --git a/src/browser/input.ts b/src/client/input.ts similarity index 62% rename from src/browser/input.ts rename to src/client/input.ts index 6360ed2..e44cb52 100644 --- a/src/browser/input.ts +++ b/src/client/input.ts @@ -1,13 +1,14 @@ import { DeckOpsError } from '../errors/index.js'; +import type { DeckRuntime } from '../cloud/runtime.js'; import { routeExtension } from '../shared/input.js'; import { formatForTaskType, type FormatKey } from '../shared/validation.js'; -import type { BrowserInput } from './types.js'; +import type { ClientInput } from './types.js'; -export type BrowserSource = +export type ResolvedSource = | { kind: 'file'; data: Blob | Uint8Array | ArrayBuffer; name: string; bytes: number; format: Exclude } | { kind: 'url'; url: string; format: 'link' }; -export function resolveBrowserInput(input: BrowserInput): BrowserSource { +export async function resolveInput(input: ClientInput, runtime: DeckRuntime, signal?: AbortSignal): Promise { if (isBlob(input)) { const name = (input as Blob & { name?: unknown }).name; if (typeof name !== 'string' || !name.trim()) { @@ -15,8 +16,9 @@ export function resolveBrowserInput(input: BrowserInput): BrowserSource { } return fileSource(input, name); } + if (typeof input === 'string') return pathSource(input, undefined, runtime, signal); if (typeof input !== 'object' || input === null) { - throw DeckOpsError.input('Use a File, { file, name }, or { url }. Browser inputs cannot be filesystem paths.'); + throw DeckOpsError.input('Use a File, { file, name }, { url }, or (Node only) a file path.'); } if ('url' in input) { if ('file' in input || typeof input.url !== 'string') { @@ -33,16 +35,32 @@ export function resolveBrowserInput(input: BrowserInput): BrowserSource { } return { kind: 'url', url: url.href, format: 'link' }; } - if ('file' in input && 'name' in input && typeof input.name === 'string' && input.name.trim()) { - const data = input.file; - if (isBlob(data) || data instanceof Uint8Array || data instanceof ArrayBuffer) { - return fileSource(data, input.name); + if ('file' in input) { + if (typeof input.file === 'string') return pathSource(input.file, input.name, runtime, signal); + if ('name' in input && typeof input.name === 'string' && input.name.trim()) { + const data = input.file; + if (isBlob(data) || data instanceof Uint8Array || data instanceof ArrayBuffer) { + return fileSource(data, input.name); + } } } throw DeckOpsError.input('Binary input needs { file: Blob | Uint8Array | ArrayBuffer, name: "document.pdf" }.'); } -function fileSource(data: Blob | Uint8Array | ArrayBuffer, name: string): BrowserSource { +async function pathSource(file: string, name: string | undefined, runtime: DeckRuntime, signal?: AbortSignal): Promise { + if (!runtime.readFile) { + throw DeckOpsError.input('Use a File, { file, name }, or { url }. Browser inputs cannot be filesystem paths.'); + } + let read: { data: Uint8Array; name: string }; + try { + read = await runtime.readFile(file, signal); + } catch (cause) { + throw DeckOpsError.input(`Cannot read ${file}.`, { cause }); + } + return fileSource(read.data, name ?? read.name); +} + +function fileSource(data: Blob | Uint8Array | ArrayBuffer, name: string): ResolvedSource { const type = routeExtension(name); const format = formatForTaskType(type); if (!format || format === 'link') { diff --git a/src/client/types.ts b/src/client/types.ts new file mode 100644 index 0000000..ec0e3f0 --- /dev/null +++ b/src/client/types.ts @@ -0,0 +1,90 @@ +import type { ConvertFlags, ParseFlags, ParseTaskType } from '../types.js'; +import type { ConvertImage } from '../cloud/parse/types.js'; + +/** + * Inputs are values. A bare path string or `{ file: path }` is accepted only when the + * runtime can read files (Node); browsers reject it before any request. + */ +export type ClientInput = + | File + | { file: Blob | Uint8Array | ArrayBuffer; name: string } + | { file: string; name?: string } + | { url: string } + | string; + +export type TaskStatus = 'pending' | 'running' | 'completed' | 'failed'; + +export interface TaskInfo { + id: string; + spaceId: string; + type: string; + status: TaskStatus; + error?: string | null; +} + +export type ProgressEvent = + | { phase: 'upload'; progress: number } + | { phase: 'parse' | 'convert'; taskId: string; spaceId: string; status: TaskStatus }; + +export interface ClientOptions { + /** Cloud API root or an authenticated same-origin proxy, e.g. /api/deckops. */ + apiBase?: string; + /** User-scoped credential sent as X-Auth-Token. */ + token?: string; + /** Server API key. Never ship it to a browser; the browser entry rejects it. */ + apiKey?: string; + spaceId?: string; + /** Refresh the same user's token once after a 401; changing accounts/spaces requires a new client. */ + onUnauthorized?: () => Promise; +} + +export interface OperationOptions { + spaceId?: string; + /** Task wait timeout in seconds (not milliseconds). Defaults to 300. */ + timeout?: number; + /** Cancels local upload/request/waiting, not an already-created cloud task. */ + signal?: AbortSignal; + /** Task events contain the id immediately after submission, for later recovery. */ + onProgress?: (event: ProgressEvent) => void; + /** Prefer SSE, falling back to polling. Set false for polling only. */ + useEventStream?: boolean; + /** Polling interval in milliseconds. Defaults to 2000. */ + pollInterval?: number; +} + +export interface ParseOptions extends ParseFlags, OperationOptions { + /** + * Invoked right before the task-creation request is sent. A failure before it created no + * task; a failure after it without a response leaves the task's existence unknown. + */ + onSubmit?: () => void; +} + +export interface ConvertOptions extends Pick, OperationOptions {} + +export type ConvertRef = { irKey: string; taskId?: never } | { taskId: string; irKey?: never }; + +export interface ConvertResult { + taskId: string; + format: 'pdf' | 'pptx' | 'docx' | 'keynote' | 'html'; + schemaVersion: string; + to: 'markdown'; + markdown: string; + markdownPages?: string[]; + /** Signed, expiring image URLs. Durable copies are the caller's concern. */ + images: ConvertImage[]; + /** Conversion references stored IR; it never resubmits the source. */ + reusedParse: true; +} + +export interface ParseResult { + taskId: string; + spaceId: string; + type: ParseTaskType | 'html.getByURL'; + irKey: string; + irSchemaVersion: string; + /** The server response, verbatim. */ + ir: R; +} + +export type { ConvertImage }; diff --git a/src/cloud/browser.ts b/src/cloud/browser.ts index c59b14e..d634f52 100644 --- a/src/cloud/browser.ts +++ b/src/cloud/browser.ts @@ -1,10 +1,13 @@ import { createTransport } from './transport.js'; import { resolveAuthUuid } from './browser-auth-uuid.js'; import type { CreateDeckOptions } from './contracts.js'; +import type { DeckRuntime } from './runtime.js'; + +export const browserRuntime: DeckRuntime = { resolveAuthUuid, useFetchStreams: true }; export function createBrowserTransport(options: CreateDeckOptions = {}) { return createTransport( { allowGuestFallback: false, retryMutations: false, ...options }, - { resolveAuthUuid, useFetchStreams: true }, + browserRuntime, ); } diff --git a/tests/browser/client.test.ts b/tests/browser/client.test.ts index ed92910..5ccce50 100644 --- a/tests/browser/client.test.ts +++ b/tests/browser/client.test.ts @@ -1,7 +1,6 @@ import { File as NodeFile } from 'node:buffer'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { createClient, DeckOpsError } from '../../src/browser/index.js'; -import type { ProbeReport } from '@deckflow/deckprobe'; import { createFakeCloud, type FakeCloud } from './fake-cloud.mjs'; let cloud: FakeCloud; @@ -10,38 +9,7 @@ afterAll(async () => { await cloud.close(); }); const submissions = (name: string) => cloud.state(name).requests.filter((req) => req.path === '/tools/tasks' && req.method === 'POST'); -const browserProbeReport = (format: 'pdf' | 'pptx' | 'docx' | 'keynote' = 'pdf'): ProbeReport => { - const profile = { pdf: 'pdf', pptx: 'pptx', docx: 'docx', keynote: 'key' }[format]; - const formatFacts = { - pdf: [['pdf.page_count', 2]], - pptx: [['powerpoint.slide_count', 2], ['powerpoint.slide_size', { width: 10, height: 7.5 }]], - docx: [['word.page_count', 2]], - keynote: [['keynote.slide_count', 2]], - }[format] as Array<[string, unknown]>; - return ({ - schema_version: 2, tool_version: '2.4.0', status: 'ok', - input: { display_name: `fixture.${format}`, source_kind: 'browser_bytes', file_size: 12 }, - driver: { id: profile, profile }, - results: Object.fromEntries([ - ['document.format_profile', profile], ['document.extension_matches', true], ['security.encrypted', false], - ['security.has_macros', false], ['security.has_external_relationships', false], - ['security.has_embedded_files', true], ...formatFacts, - ].map(([target, value]) => [target, { - target, status: 'resolved', value, confidence: 'exact', confidence_score: 1, - path: 'fixture.path', source: 'fixture', - }])), - execution: { - probe_level: 'metadata', paths: ['fixture.path'], estimated_cost: 1, - actual_cost: { physical_bytes_read: 12, expanded_bytes: 0, random_reads: 0 }, unresolved_targets: [], - }, - diagnostics: [], - }); -}; - -const fixtureInspector = { inspect: async (input: { format: 'pdf' | 'pptx' | 'docx' | 'keynote' }) => browserProbeReport(input.format) }; -const clientFor = (name: string) => createClient({ - apiBase: cloud.apiBase(name), token: 'test-token', spaceId: 'test-space', inspector: fixtureInspector, -}); +const clientFor = (name: string) => createClient({ apiBase: cloud.apiBase(name), token: 'test-token', spaceId: 'test-space' }); describe('browser SDK over real HTTP', () => { it('accepts a File, maps PDF options, and returns in-memory IR', async () => { @@ -58,45 +26,12 @@ describe('browser SDK over real HTTP', () => { params: { parseProfile: 'quality', includeImages: false, password: 'document-password' }, files: [{ name: 'report.pdf', bytes: 12 }], }); - expect(progress).toContainEqual({ phase: 'parse', taskId: 'task-1', status: 'completed' }); + expect(progress).toContainEqual({ phase: 'parse', taskId: 'task-1', spaceId: 'test-space', status: 'completed' }); expect(cloud.state('file').requests.every((req) => req.headers['x-auth-token'] === 'test-token')).toBe(true); await doc.convert({ anchors: true }); expect(cloud.state('file').tasks[1]?.params.markdownMeta).toBe(true); }); - it('runs the default validate preflight before upload and exposes its facts', async () => { - const inspect = vi.fn(async () => browserProbeReport()); - const client = createClient({ - apiBase: cloud.apiBase('preflight'), token: 'test-token', spaceId: 'test-space', inspector: { inspect }, - }); - const progress: unknown[] = []; - const file = new NodeFile(['%PDF fixture'], 'report.pdf') as unknown as File; - const doc = await client.parse(file, { onProgress: (event) => progress.push(event) }); - expect(inspect).toHaveBeenCalledTimes(1); - expect(doc.inspection).toMatchObject({ profile: 'pdf', pageCount: 2, hasEmbeddedFiles: true }); - expect(doc.warnings).toContainEqual(expect.stringContaining('embedded files')); - expect(await doc.inspectionReport()).toMatchObject({ schema_version: 2, tool_version: '2.4.0' }); - expect(progress).toEqual(expect.arrayContaining([ - { phase: 'preflight', status: 'running' }, { phase: 'preflight', status: 'completed' }, - ])); - expect(submissions('preflight')).toHaveLength(1); - }); - - it('rejects a malformed local document before a browser request', async () => { - const client = createClient({ - apiBase: cloud.apiBase('preflight-malformed'), token: 'test-token', spaceId: 'test-space', - inspector: { inspect: async () => ({ - schema_version: 2 as const, tool_version: '2.4.0', status: 'error' as const, - error: { code: 'MALFORMED_INPUT', message: 'wrong container', exit_code: 4 }, - }) }, - }); - await expect(client.parse( - new NodeFile(['not a PDF'], 'report.pdf') as unknown as File, - {} - )).rejects.toMatchObject({ code: 'input_error' }); - expect(cloud.state('preflight-malformed').requests).toHaveLength(0); - }); - it('preserves the explicit filename for a Blob and routes keynote options', async () => { await clientFor('blob').parse({ file: new Blob(['keynote bytes']), name: 'slides.key' }, { stayImageAreaRate: 0.2 }); expect(submissions('blob')[0]?.body).toMatchObject({ @@ -117,19 +52,6 @@ describe('browser SDK over real HTTP', () => { expect(doc.type).toBe('html.getByURL'); expect(submissions('url')[0]?.body).toMatchObject({ type: 'html.getByURL', params: { url: 'https://example.com/article', mode: 'source' }, fileIds: [] }); expect(cloud.state('url').uploads).toHaveLength(0); - expect(doc.warnings).toContainEqual(expect.stringContaining('skipped for the URL input')); - }); - - it('keeps off as an explicit Worker/WASM escape hatch', async () => { - const inspect = vi.fn(async () => browserProbeReport()); - const client = createClient({ - apiBase: cloud.apiBase('preflight-off'), token: 'test-token', spaceId: 'test-space', inspector: { inspect }, - }); - const file = new NodeFile(['%PDF fixture'], 'report.pdf') as unknown as File; - const doc = await client.parse(file, { preflight: 'off' }); - expect(inspect).not.toHaveBeenCalled(); - expect(doc.inspection).toBeUndefined(); - expect(submissions('preflight-off')).toHaveLength(1); }); it('converts repeatedly by irKey, passes view options, and keeps pages/images in memory', async () => { @@ -139,7 +61,7 @@ describe('browser SDK over real HTTP', () => { expect(view).toMatchObject({ taskId: 'task-2', format: 'pptx', schemaVersion: 'result.v3', to: 'markdown', reusedParse: true, markdownPages: ['# Page one', '# Page two'], - images: [{ key: 'fixture/image.png', suggestedPath: 'assets/image.png', bytes: 42, hash: 'fixture-md5' }], + images: [{ key: 'fixture/image.png', suggestedPath: 'assets/image.png', bytes: 8, hash: expect.stringMatching(/^[0-9a-f]{32}$/) }], }); expect(view.markdown).toContain(view.images[0]?.ref); expect(cloud.state('convert').tasks[1]?.params).toEqual({ @@ -153,9 +75,7 @@ describe('browser SDK over real HTTP', () => { }); it('keeps per-operation spaces isolated during concurrent parse, wait, download, and conversion', async () => { - const client = createClient({ - apiBase: cloud.apiBase('sse_spaces'), token: 'test-token', spaceId: 'space-A', inspector: fixtureInspector, - }); + const client = createClient({ apiBase: cloud.apiBase('sse_spaces'), token: 'test-token', spaceId: 'space-A' }); const [docA, docB] = await Promise.all([ client.parse({ file: new Blob(['pdf']), name: 'a.pdf' }), client.parse({ url: 'https://example.com/b' }, { spaceId: 'space-B' }), @@ -201,8 +121,8 @@ describe('browser SDK over real HTTP', () => { it('reports SSE progress and closes the stream on completion', async () => { const events: unknown[] = []; await clientFor('sse').parse({ url: 'https://example.com/' }, { onProgress: (event) => events.push(event) }); - expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', status: 'running' }); - expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', status: 'completed' }); + expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', spaceId: 'test-space', status: 'running' }); + expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', spaceId: 'test-space', status: 'completed' }); await vi.waitFor(() => expect(cloud.state('sse').activeStreams).toBe(0)); expect(cloud.state('sse').closedStreams).toBe(1); }); @@ -213,7 +133,7 @@ describe('browser SDK over real HTTP', () => { pollInterval: 10, timeout: 2, onProgress: (event) => events.push(event), }); expect(doc.irKey).toBe('fixture/task-1/ir.json'); - expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', status: 'completed' }); + expect(events).toContainEqual({ phase: 'parse', taskId: 'task-1', spaceId: 'test-space', status: 'completed' }); const details = cloud.state('json-stream').requests.filter((req) => req.path === '/tools/tasks/task-1'); expect(details.filter((req) => req.headers['response-event-stream'] === 'yes')).toHaveLength(1); expect(details.filter((req) => req.headers['response-event-stream'] !== 'yes').length).toBeGreaterThanOrEqual(2); @@ -372,6 +292,16 @@ describe('browser SDK over real HTTP', () => { expect(submissions('pending_poll')).toHaveLength(1); }); + it('resumes a submitted parse by task id without uploading or creating another task', async () => { + const client = clientFor('resume'); + const doc = await client.parse({ url: 'https://example.com/' }); + const resumed = await client.resume(doc.taskId, 'html.getByURL'); + expect(resumed).toMatchObject({ taskId: doc.taskId, spaceId: 'test-space', irKey: doc.irKey }); + expect(await resumed.ir()).toEqual(await doc.ir()); + expect(submissions('resume')).toHaveLength(1); + await expect(client.resume(doc.taskId, 'nope.parse' as never)).rejects.toMatchObject({ code: 'usage_error' }); + }); + it('rejects invalid browser inputs and format-specific options before network work', async () => { const client = clientFor('invalid'); for (const input of ['/tmp/report.pdf', new Blob(['pdf']), { file: new Blob(['pdf']), name: '' }, @@ -383,6 +313,7 @@ describe('browser SDK over real HTTP', () => { await expect(client.convert({ irKey: 'a', taskId: 'b' } as never)).rejects.toBeInstanceOf(DeckOpsError); await expect(client.convert({ irKey: 'a' }, { to: 'html' } as never)).rejects.toMatchObject({ code: 'unsupported' }); expect(() => createClient({ apiKey: 'server-secret' } as never)).toThrow(DeckOpsError); + await expect(client.parse({ file: new Blob(['pdf']), name: 'a.pdf' }, { preflight: 'off' } as never)).rejects.toMatchObject({ code: 'usage_error' }); expect(cloud.state('invalid').requests).toHaveLength(0); }); }); diff --git a/tests/browser/fake-cloud.d.mts b/tests/browser/fake-cloud.d.mts index a3de6d8..475f1f9 100644 --- a/tests/browser/fake-cloud.d.mts +++ b/tests/browser/fake-cloud.d.mts @@ -21,4 +21,5 @@ export interface FakeCloud { state(name: string): FakeState; close(): Promise; } +export const FIXTURE_PNG: Buffer; export function createFakeCloud(options?: { staticRoot?: string; port?: number }): Promise; diff --git a/tests/browser/fake-cloud.mjs b/tests/browser/fake-cloud.mjs index d7d3348..a9594c5 100644 --- a/tests/browser/fake-cloud.mjs +++ b/tests/browser/fake-cloud.mjs @@ -1,8 +1,13 @@ +import { createHash } from 'node:crypto'; import http from 'node:http'; import fs from 'node:fs/promises'; import path from 'node:path'; /** A wire-protocol fixture, not a mocked SDK. Every operation uses real HTTP. */ +/** The one image every fixture conversion references; served with its real md5. */ +export const FIXTURE_PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const FIXTURE_PNG_MD5 = createHash('md5').update(FIXTURE_PNG).digest('hex'); + export async function createFakeCloud({ staticRoot, port = 0 } = {}) { const cases = new Map(); let origin; @@ -47,10 +52,7 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { try { const body = await fs.readFile(path.join(staticRoot, file)); res.writeHead(200, { - 'Content-Type': file.endsWith('.html') ? 'text/html; charset=utf-8' - : file.endsWith('.wasm') ? 'application/wasm' - : file.endsWith('.pdf') ? 'application/pdf' - : 'text/javascript; charset=utf-8', + 'Content-Type': file.endsWith('.html') ? 'text/html; charset=utf-8' : 'text/javascript; charset=utf-8', 'Cache-Control': 'no-store', }); res.end(body); @@ -91,6 +93,10 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { json(res, 401, { code: 'auth_error', message: 'Expired browser token' }); return; } if (route === '/user') { json(res, 200, { id: 'fixture-space' }); return; } + if (route === '/image.png') { + if (kind('dead-image')) { json(res, 404, { message: 'gone' }); return; } + res.writeHead(200, { 'Content-Type': 'image/png' }); res.end(FIXTURE_PNG); return; + } if (route.endsWith('/file/auth') && req.method === 'POST') { const id = `file-${state.uploads.length + 1}`; state.uploads.push({ ...body, id }); @@ -156,7 +162,7 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { markdown: `# Page one\n\n![](${ref})\n\n---\n\n# Page two`, ...(task.params.markdownPages && ['pptx', 'keynote'].includes(format) ? { markdownPages: ['# Page one', '# Page two'] } : {}), ...(kind('bad-markdown') ? { markdownError: 'Renderer failed' } : {}), - images: [{ ref, key: 'fixture/image.png', suggestedPath: 'assets/image.png', bytes: 42, hash: 'fixture-md5' }], + images: [{ ref, key: 'fixture/image.png', suggestedPath: 'assets/image.png', bytes: FIXTURE_PNG.length, hash: FIXTURE_PNG_MD5 }], }); } else { json(res, 200, { diff --git a/tsup.browser.config.ts b/tsup.browser.config.ts index 622f742..096c3c2 100644 --- a/tsup.browser.config.ts +++ b/tsup.browser.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: { index: 'src/browser/index.ts', 'probe-worker': 'src/browser/probe-worker.ts' }, + entry: { index: 'src/browser/index.ts' }, outDir: 'dist/browser', tsconfig: 'tsconfig.browser.json', format: ['esm'], @@ -12,8 +12,6 @@ export default defineConfig({ bundle: true, splitting: false, dts: { entry: { index: 'src/browser/index.ts' } }, - // Runs after every watch rebuild too, so dev:browser cannot lose the lazy WASM asset after a clean. - onSuccess: 'node scripts/copy-deckprobe-wasm.mjs', sourcemap: true, clean: true, }); diff --git a/type-tests/browser/consumer.ts b/type-tests/browser/consumer.ts index f7f6a8a..8b607cc 100644 --- a/type-tests/browser/consumer.ts +++ b/type-tests/browser/consumer.ts @@ -4,15 +4,12 @@ const client = createClient({ apiBase: '/api/deckops' }); const signal = new AbortController().signal; const onProgress = (event: BrowserProgress): void => { if (event.phase === 'upload') event.progress satisfies number; - else if (event.phase === 'preflight') event.status satisfies 'running' | 'completed'; - else event.taskId satisfies string; + else { event.taskId satisfies string; event.spaceId satisfies string; } }; async function browserConsumer(): Promise { const doc = await client.parse<{ document: unknown }>(new File(['bytes'], 'report.pdf'), { signal, onProgress }); (await doc.ir()).document satisfies unknown; - doc.inspection?.profile satisfies string | undefined; - await doc.inspectionReport(); await client.parse({ file: new Blob(['bytes']), name: 'slides.pptx' }); await client.parse({ file: new Uint8Array([1]), name: 'document.docx' }); await client.parse({ file: new ArrayBuffer(1), name: 'slides.key' }); @@ -22,8 +19,20 @@ async function browserConsumer(): Promise { } void browserConsumer; +// platform-slide-editor: parse with progress, read the IR, derive Markdown. +async function slideEditor(file: File): Promise { + const doc = await client.parse(file, { onProgress }); + const ir = await doc.ir(); + void ir; + const view = await doc.convert(); + return view.markdown; +} +void slideEditor; + // @ts-expect-error Server API keys are not part of the browser API. createClient({ apiKey: 'not-a-browser-credential' }); +// @ts-expect-error Local preflight no longer exists. +client.parse(new File(['bytes'], 'report.pdf'), { preflight: 'off' }); // @ts-expect-error Refresh cannot implicitly switch an in-flight operation's space. createClient({ onUnauthorized: async () => ({ token: 'token', spaceId: 'other' }) }); // @ts-expect-error Bare filesystem paths are Node-only. From 66183479e78882203a4cde8cfc1965f6df049282 Mon Sep 17 00:00:00 2001 From: Garfield Date: Mon, 19 Oct 2026 15:44:56 +0800 Subject: [PATCH 2/9] refactor(node): add artifact v3 and the Node operation layer over the client facade Build src/node/ beside the legacy tree: parse-op, convert-op and read-op call the isomorphic client directly. ir.json stores the server parse response verbatim; manifest v3 records the source hash, parse task, irKey, irSchemaVersion and a 7-day expiresAt, and refuses 2.x manifests with a re-parse hint. An unresolved cloud submission is journaled in submission.json and resumed on the next run; a task that cannot be resumed is forgotten so the next run submits anew; an expired parse hit is parsed again with a warning. Convert localizes images into assets/ (media type from the response header or extension) and escapes local Markdown links. The legacy core keeps running on types-legacy until the CLI switches over. Integration tests exercise the layer over real HTTP against the fake cloud. Co-Authored-By: Claude Fable 5.1 --- src/artifact/manifest.ts | 2 +- src/cli/commands/run-ops.ts | 2 +- src/cli/output.ts | 2 +- src/cloud/client.ts | 2 +- src/core/convert-op.ts | 6 +- src/core/input.ts | 2 +- src/core/parse-op.ts | 4 +- src/core/read-op.ts | 2 +- src/core/validation.ts | 2 +- src/engine/policy.ts | 2 +- src/engine/types.ts | 2 +- src/index.ts | 4 +- src/ir/cloud-adapter.ts | 2 +- src/node/artifact.ts | 110 ++++++ src/node/cloud.ts | 14 + src/node/convert-op.ts | 155 ++++++++ src/node/input.ts | 62 ++++ src/{assets => node}/localize.ts | 24 +- src/node/parse-op.ts | 245 ++++++++++++ src/node/read-op.ts | 102 +++++ src/{core => node}/timeout.ts | 0 src/node/validation.ts | 40 ++ src/shared/validation.ts | 6 + src/types-legacy.ts | 113 ++++++ src/types.ts | 83 ++--- tests/browser/fake-cloud.d.mts | 2 + tests/browser/fake-cloud.mjs | 5 + tests/integration/ops.test.ts | 617 ++++++++++--------------------- tests/unit/input.test.ts | 4 +- tests/unit/localize.test.ts | 13 +- tests/unit/manifest.test.ts | 73 ++-- tests/unit/timeout.test.ts | 2 +- 32 files changed, 1165 insertions(+), 539 deletions(-) create mode 100644 src/node/artifact.ts create mode 100644 src/node/cloud.ts create mode 100644 src/node/convert-op.ts create mode 100644 src/node/input.ts rename src/{assets => node}/localize.ts (81%) create mode 100644 src/node/parse-op.ts create mode 100644 src/node/read-op.ts rename src/{core => node}/timeout.ts (100%) create mode 100644 src/node/validation.ts create mode 100644 src/types-legacy.ts diff --git a/src/artifact/manifest.ts b/src/artifact/manifest.ts index 677a079..4a58dcc 100644 --- a/src/artifact/manifest.ts +++ b/src/artifact/manifest.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { DeckOpsError } from '../errors/index.js'; -import type { EngineMode, Manifest, ManifestV1, ManifestV1View, ManifestV2, ManifestView } from '../types.js'; +import type { EngineMode, Manifest, ManifestV1, ManifestV1View, ManifestV2, ManifestView } from '../types-legacy.js'; import { irPath, manifestPath } from './layout.js'; import { validateDeckIR } from '../ir/validate.js'; diff --git a/src/cli/commands/run-ops.ts b/src/cli/commands/run-ops.ts index 0461bbd..d3991f8 100644 --- a/src/cli/commands/run-ops.ts +++ b/src/cli/commands/run-ops.ts @@ -6,7 +6,7 @@ import { runParse } from '../../core/parse-op.js'; import { validateConvertFlags, validateParseFlags } from '../../core/validation.js'; import { DeckOpsError } from '../../errors/index.js'; import { readProductConfig } from '../../config/product.js'; -import type { CommonFlags, ConvertFlags, ParseFlags } from '../../types.js'; +import type { CommonFlags, ConvertFlags, ParseFlags } from '../../types-legacy.js'; import { DEFAULT_PREFLIGHT_MODE, type PreflightMode } from '../../shared/preflight.js'; import { printEnvelope, printError, type OutputContext } from '../output.js'; diff --git a/src/cli/output.ts b/src/cli/output.ts index 771ec65..778048c 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import type { DeckOpsError } from '../errors/index.js'; -import type { ConvertEnvelope, Envelope, ParseEnvelope } from '../types.js'; +import type { ConvertEnvelope, Envelope, ParseEnvelope } from '../types-legacy.js'; /** * Result rendering. `--json` prints the envelope verbatim (public contract); diff --git a/src/cloud/client.ts b/src/cloud/client.ts index 8ac138c..fb35b0f 100644 --- a/src/cloud/client.ts +++ b/src/cloud/client.ts @@ -6,7 +6,7 @@ import type { ConvertOptions as SdkConvertOptions, ConvertRef, ConvertResult, ParseOptions as SdkParseOptions, ParseResult, ParseSource, ResumeOptions, } from './parse-facade.js'; -import type { ParseTaskType } from '../types.js'; +import type { ParseTaskType } from '../types-legacy.js'; import type { ResolvedCredentials } from '../config/index.js'; import { PRE_UPLOAD_THRESHOLD } from '../shared/constants.js'; import { translateError } from '../shared/errors.js'; diff --git a/src/core/convert-op.ts b/src/core/convert-op.ts index 4d8cfc0..ea1b7b5 100644 --- a/src/core/convert-op.ts +++ b/src/core/convert-op.ts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import type { ConvertResult } from '../cloud/parse-facade.js'; import type { IrFormat } from '../cloud/parse/types.js'; -import { localizeImages, rewriteLinks } from '../assets/localize.js'; +import { localizeImages, rewriteLinks } from '../node/localize.js'; import { assetsDir, irPath, viewDir, probePath } from '../artifact/layout.js'; import { locallyExpired, viewHit, writeManifest } from '../artifact/manifest.js'; import type { CloudClient } from '../cloud/client.js'; @@ -19,12 +19,12 @@ import type { SourceIdentity } from '../local/common.js'; import { DEFAULT_LOCAL_LIMITS } from '../local/limits.js'; import type { PreflightMode } from '../shared/preflight.js'; import { convertParams } from '../shared/params.js'; -import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ManifestV2, OutputFile, ParseFlags } from '../types.js'; +import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ManifestV2, OutputFile, ParseFlags } from '../types-legacy.js'; import { MARKDOWN_RENDERER_VERSION, renderMarkdown } from '../views/markdown.js'; import type { NodeDocumentInspector } from './inspector.js'; import type { ResolvedInput } from './input.js'; import { runSourcePreflight } from './parse-op.js'; -import { withinOperationTimeout } from './timeout.js'; +import { withinOperationTimeout } from '../node/timeout.js'; export interface ConvertOpOptions { assessment?: Assessment; diff --git a/src/core/input.ts b/src/core/input.ts index d0ccd44..455b726 100644 --- a/src/core/input.ts +++ b/src/core/input.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { DeckOpsError } from '../errors/index.js'; -import type { Manifest, ParseTaskType } from '../types.js'; +import type { Manifest, ParseTaskType } from '../types-legacy.js'; import { readManifest } from '../artifact/manifest.js'; import { routeExtension } from '../shared/input.js'; diff --git a/src/core/parse-op.ts b/src/core/parse-op.ts index bfe01ad..a37dc1e 100644 --- a/src/core/parse-op.ts +++ b/src/core/parse-op.ts @@ -22,10 +22,10 @@ import { } from '../shared/preflight.js'; import { parseParams } from '../shared/params.js'; import { formatForTaskType } from '../shared/validation.js'; -import type { CommonFlags, Manifest, ManifestInspection, ManifestV2, OutputFile, ParseEnvelope, ParseFlags } from '../types.js'; +import type { CommonFlags, Manifest, ManifestInspection, ManifestV2, OutputFile, ParseEnvelope, ParseFlags } from '../types-legacy.js'; import { PDF_PARSER_VERSION, VERSION } from '../version.js'; import { createNodeDocumentInspector, type NodeDocumentInspector } from './inspector.js'; -import { withinOperationTimeout } from './timeout.js'; +import { withinOperationTimeout } from '../node/timeout.js'; import type { ResolvedInput } from './input.js'; export interface ParseOpOptions { diff --git a/src/core/read-op.ts b/src/core/read-op.ts index 115111f..e0423f1 100644 --- a/src/core/read-op.ts +++ b/src/core/read-op.ts @@ -11,7 +11,7 @@ import { assessCandidate, type Assessment } from '../quality/assessment.js'; import { finalQualityGate } from '../engine/router.js'; import type { RouteDecision } from '../engine/policy.js'; import type { DeckIR } from '../ir/schema.js'; -import type { OutputFile } from '../types.js'; +import type { OutputFile } from '../types-legacy.js'; import { DeckOpsError } from '../errors/index.js'; export interface ReadReport { diff --git a/src/core/validation.ts b/src/core/validation.ts index 5b9dd64..1425eb7 100644 --- a/src/core/validation.ts +++ b/src/core/validation.ts @@ -1,5 +1,5 @@ import { DeckOpsError } from '../errors/index.js'; -import type { ConvertFlags, ParseFlags } from '../types.js'; +import type { ConvertFlags, ParseFlags } from '../types-legacy.js'; import type { ResolvedInput } from './input.js'; import { formatForTaskType, diff --git a/src/engine/policy.ts b/src/engine/policy.ts index 082613f..807d8d1 100644 --- a/src/engine/policy.ts +++ b/src/engine/policy.ts @@ -1,5 +1,5 @@ import { DeckOpsError } from '../errors/index.js'; -import type { CommonFlags, ParseFlags } from '../types.js'; +import type { CommonFlags, ParseFlags } from '../types-legacy.js'; import type { Assessment } from '../quality/assessment.js'; import type { EngineParseInput } from './types.js'; import type { DeckProbeReport } from '../shared/preflight.js'; diff --git a/src/engine/types.ts b/src/engine/types.ts index 19e5dfb..35a881b 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -1,6 +1,6 @@ import type { ResolvedInput } from '../core/input.js'; import type { ParseCandidate } from '../ir/schema.js'; -import type { CommonFlags, ParseFlags } from '../types.js'; +import type { CommonFlags, ParseFlags } from '../types-legacy.js'; import type { SourceIdentity } from '../local/common.js'; export interface SupportDecision { diff --git a/src/index.ts b/src/index.ts index a9bb41d..728a035 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ import { validateConvertFlags, validateParseFlags } from './core/validation.js'; import { DeckOpsError } from './errors/index.js'; import { validateDeckIR } from './ir/validate.js'; import type { DeckProbeReport, PreflightMode, PreflightSummary } from './shared/preflight.js'; -import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ParseEnvelope, ParseFlags } from './types.js'; +import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ParseEnvelope, ParseFlags } from './types-legacy.js'; export { DeckOpsError, ERROR_CODES, EXIT_CODES, type ErrorCode } from './errors/index.js'; export { resolveCredentials, writeSharedCredentials, type ResolvedCredentials } from './config/index.js'; @@ -23,7 +23,7 @@ export type { CommonFlags, ConvertEnvelope, ConvertFlags, EngineMode, Envelope, LocalLimits, Manifest, ManifestAsset, ManifestInspection, ManifestV1, ManifestV2, ManifestView, OutputFile, ParseEnvelope, ParseFlags, ParseTaskType, PreflightMode, PreflightSummary, -} from './types.js'; +} from './types-legacy.js'; export type { DeckProbeReport } from './shared/preflight.js'; export { createNodeDocumentInspector, type NodeDocumentInspector } from './core/inspector.js'; diff --git a/src/ir/cloud-adapter.ts b/src/ir/cloud-adapter.ts index 4aa6d30..74d7955 100644 --- a/src/ir/cloud-adapter.ts +++ b/src/ir/cloud-adapter.ts @@ -12,7 +12,7 @@ import { result3ToDeckIr } from './result3-adapter.js'; import type { CandidateAsset, DeckIrNode, DeckIrPage, DeckIrRun, DocumentFormat, ParseCandidate, QualityCheck } from './schema.js'; import { orderSlideNodes } from './slide-order.js'; import { makeIr, qualityOf, type SourceIdentity } from '../local/common.js'; -import type { ParseTaskType } from '../types.js'; +import type { ParseTaskType } from '../types-legacy.js'; export async function cloudResultToCandidate(parsed: ParseResult, source: SourceIdentity, signal?: AbortSignal): Promise { const result3 = findResult3(parsed.ir); diff --git a/src/node/artifact.ts b/src/node/artifact.ts new file mode 100644 index 0000000..0e54c36 --- /dev/null +++ b/src/node/artifact.ts @@ -0,0 +1,110 @@ +import fs from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { DeckOpsError } from '../errors/index.js'; +import type { Manifest, ManifestView } from '../types.js'; + +/** + * Artifact layout (public contract): + * + * / + * ├── ir.json server parse response, verbatim + * ├── manifest.json written last — registered ⇒ exists + * ├── submission.json only while a cloud task has no result yet + * ├── assets/ images downloaded by convert, named by persistent key + * └── views/markdown/ index.md (+ 001.md… with --split-pages); written only by convert + */ +export const IR_FILE = 'ir.json'; +export const MANIFEST_FILE = 'manifest.json'; +export const SUBMISSION_FILE = 'submission.json'; +export const ASSETS_DIR = 'assets'; +export const VIEWS_DIR = 'views'; +export const IR_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; + +export const irPath = (dir: string): string => path.join(dir, IR_FILE); +export const manifestPath = (dir: string): string => path.join(dir, MANIFEST_FILE); +export const submissionPath = (dir: string): string => path.join(dir, SUBMISSION_FILE); +export const assetsDir = (dir: string): string => path.join(dir, ASSETS_DIR); +export const viewDir = (dir: string, to: string): string => path.join(dir, VIEWS_DIR, to); + +/** Default artifact directory for a source document: sibling dir named after it. */ +export function defaultArtifactDir(sourcePath: string): string { + const base = path.basename(sourcePath, path.extname(sourcePath)); + return path.join(path.dirname(sourcePath), base); +} + +export async function readManifest(dir: string): Promise { + const file = manifestPath(dir); + let parsed: unknown; + try { parsed = JSON.parse(await fs.readFile(file, 'utf-8')); } + catch (cause) { + if (!existsSync(file)) throw DeckOpsError.input(`${dir} is not an artifact: no ${MANIFEST_FILE}.`, { hint: 'Run `deckops parse ` to create one.', cause }); + throw DeckOpsError.input(`${file} is not valid JSON.`, { hint: 'The artifact is damaged. Re-run parse with --force.', cause }); + } + if (!isRecord(parsed)) throw DeckOpsError.input(`${file} is not a deckops manifest.`); + if (parsed.manifestVersion === 1 || parsed.manifestVersion === 2) { + throw DeckOpsError.input(`${dir} was created by DeckOps 2.x with a local engine and cannot be read by 3.x.`, { + hint: 'Run `deckops parse --force` to create a cloud artifact.', + }); + } + if (parsed.manifestVersion !== 3) throw DeckOpsError.input(`${file} is from an incompatible deckops version.`); + const manifest = parsed as unknown as Manifest; + if (!isRecord(manifest.parse) || !manifest.parse.irKey || !manifest.parse.type || !isRecord(manifest.source) || !manifest.source.sha256 || + !isRecord(manifest.views) || !isRecord(manifest.assets)) { + throw DeckOpsError.input(`${file} is not a valid manifest v3.`); + } + for (const relative of [...Object.keys(manifest.assets), ...Object.values(manifest.views).flatMap((view) => view?.files ?? [])]) { + if (!containedPath(dir, relative)) throw DeckOpsError.input(`${dir} registers an unsafe path: ${relative}.`); + } + return manifest; +} + +export async function writeManifest(dir: string, manifest: Manifest): Promise { + const file = manifestPath(dir); const tmp = `${file}.tmp`; + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); + await fs.rename(tmp, file); +} + +export function normalizeParseParams(params: Record): Record { + const clean: Record = { parseProfile: 'balanced', includeImages: true, ...params }; + if ('password' in clean) { clean.passwordProvided = typeof clean.password === 'string'; delete clean.password; } + return sortKeys(clean); +} + +export function sameParams(a: Record, b: Record): boolean { + return JSON.stringify(normalizeParseParams(a)) === JSON.stringify(normalizeParseParams(b)); +} + +/** Same bytes, same request, ir.json on disk. Expiry is the caller's decision (warn and re-parse). */ +export function parseHit(dir: string, manifest: Manifest, sha256: string | undefined, params: Record): boolean { + return Boolean(sha256) && manifest.source.sha256 === sha256 && existsSync(irPath(dir)) && sameParams(manifest.parse.params, params); +} + +export function viewHit(dir: string, view: ManifestView | undefined, params: Record): boolean { + if (!view || JSON.stringify(sortKeys(view.params)) !== JSON.stringify(sortKeys(params))) return false; + return view.files.length > 0 && view.files.every((file) => { const target = containedPath(dir, file); return Boolean(target && existsSync(target)); }); +} + +export function expired(manifest: Manifest, now: number = Date.now()): boolean { + const expires = Date.parse(manifest.parse.expiresAt); + return !Number.isFinite(expires) || now > expires; +} + +export function containedPath(dir: string, relative: string): string | undefined { + if (!relative || path.isAbsolute(relative)) return undefined; + const root = path.resolve(dir); const target = path.resolve(root, relative); + return target.startsWith(`${root}${path.sep}`) ? target : undefined; +} + +function sortKeys(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, canonical(value)])); +} + +function canonical(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonical); + if (isRecord(value)) return sortKeys(value); + return value; +} + +function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/src/node/cloud.ts b/src/node/cloud.ts new file mode 100644 index 0000000..4689669 --- /dev/null +++ b/src/node/cloud.ts @@ -0,0 +1,14 @@ +import { createClientWith, type DeckClient } from '../client/index.js'; +import { nodeRuntime } from '../cloud/node-runtime.js'; +import { resolveCredentials, type CredentialOverrides } from '../config/index.js'; + +/** The isomorphic client bound to the Node runtime and the shared credential chain. */ +export async function cloudClient(overrides: CredentialOverrides = {}): Promise { + const credentials = await resolveCredentials(overrides); + return createClientWith({ + apiBase: credentials.apiBase, + ...(credentials.token ? { token: credentials.token } : {}), + ...(credentials.apiKey ? { apiKey: credentials.apiKey } : {}), + ...(credentials.spaceId ? { spaceId: credentials.spaceId } : {}), + }, nodeRuntime, 'node'); +} diff --git a/src/node/convert-op.ts b/src/node/convert-op.ts new file mode 100644 index 0000000..5c2f8ce --- /dev/null +++ b/src/node/convert-op.ts @@ -0,0 +1,155 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import type { ConvertResult, DeckClient } from '../client/index.js'; +import { DeckOpsError } from '../errors/index.js'; +import { irFormatForTaskType } from '../shared/validation.js'; +import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, OutputFile, ParseFlags } from '../types.js'; +import { assetsDir, expired, viewDir, viewHit, writeManifest } from './artifact.js'; +import type { ResolvedInput } from './input.js'; +import { localizeImages, rewriteLinks, type LocalizedAssets } from './localize.js'; +import { freezeSource, operationTimeoutMs, parseFlagsOnly, sourceInput, type SourceInput } from './parse-op.js'; +import { withinOperationTimeout } from './timeout.js'; + +export interface ConvertOpOptions { + input: ResolvedInput; inputLabel: string; out?: string; flags: ConvertFlags; parseFlags?: ParseFlags; common: CommonFlags; + client: () => Promise; +} + +export async function runConvert(options: ConvertOpOptions): Promise { + if ((options.flags.to ?? 'markdown') !== 'markdown') throw DeckOpsError.unsupported(`--to ${String(options.flags.to)} is not supported yet.`); + let content: string | undefined; + const work: ConvertWork = { ...options, onContent: (value) => { content = value; } }; + const result = options.input.kind === 'artifact' + ? await convertArtifact(work, options.input.dir, options.input.manifest) + : await convertOneShot(work, options.input); + return { ...result, ...(content !== undefined ? { content } : {}) }; +} + +interface ConvertWork extends ConvertOpOptions { onContent: (markdown: string) => void } + +/** Artifact → irKey → parse.convert → images localized into assets/ → views/markdown/. */ +async function convertArtifact(options: ConvertWork, dir: string, manifest: Manifest): Promise { + const startedAt = Date.now(); + const { flags, common } = options; + const viewParams = viewParamsFor(flags); + const view = manifest.views.markdown; + const format = irFormatForTaskType(manifest.parse.type); + if (!options.out && !common.force && viewHit(dir, view, viewParams)) { + const outputs = await Promise.all(view!.files.map(async (file) => ({ file: path.join(dir, file), bytes: (await fs.stat(path.join(dir, file))).size }))); + return envelope(options, { startedAt, format, taskId: null, parseTaskId: manifest.parse.taskId, reusedParse: true, outputs, warnings: [] }); + } + if (expired(manifest)) { + throw new DeckOpsError('ir_expired', `The cloud IR behind ${dir} has expired.`, { hint: `Re-run \`deckops parse ${manifest.source.name} --force\`.` }); + } + const client = await options.client(); + const result = await withinOperationTimeout(operationTimeoutMs(common), common.signal, + (signal) => callConvert(client, { irKey: manifest.parse.irKey }, flags, common, signal)); + const localized = await localizeImages({ + images: result.images, destDir: assetsDir(dir), linkPrefix: '../../assets/', + ...(flags.keepRemoteImages !== undefined ? { keepRemote: flags.keepRemoteImages } : {}), + }); + const outputs: OutputFile[] = Object.values(localized.saved).map((asset) => ({ file: path.join(assetsDir(dir), asset.file), bytes: asset.bytes })); + const target = viewDir(dir, 'markdown'); + await fs.rm(target, { recursive: true, force: true }); await fs.mkdir(target, { recursive: true }); + const files: string[] = []; + const write = async (name: string, text: string): Promise => { + const body = rewriteLinks(text, localized.rewrites); const file = path.join(target, name); + await fs.writeFile(file, body, 'utf-8'); files.push(path.join('views', 'markdown', name)); outputs.push({ file, bytes: Buffer.byteLength(body) }); + }; + await write('index.md', result.markdown); + if (flags.splitPages && result.markdownPages) { + for (const [index, page] of result.markdownPages.entries()) await write(`${String(index + 1).padStart(3, '0')}.md`, page); + } + manifest.views.markdown = { taskId: result.taskId, schemaVersion: result.schemaVersion, params: viewParams, files, createdAt: new Date().toISOString() }; + for (const [file, asset] of Object.entries(localized.saved)) { + manifest.assets[`assets/${file}`] = { key: asset.key, hash: asset.hash, bytes: asset.bytes, ...(asset.mediaType ? { mediaType: asset.mediaType } : {}) }; + } + await writeManifest(dir, manifest); + const warnings = [...localized.warnings]; + if (options.out) { + const portable = await writePortable(options.out, result, flags, options); + outputs.push(...portable.outputs); warnings.push(...portable.warnings); + } + return envelope(options, { startedAt, format, taskId: result.taskId, parseTaskId: manifest.parse.taskId, reusedParse: true, outputs, warnings }); +} + +/** Document → parse → convert → portable Markdown; no artifact is left behind. */ +async function convertOneShot(options: ConvertWork, input: SourceInput): Promise { + const startedAt = Date.now(); + const { flags, common } = options; + const client = await options.client(); + const { parsed, result } = await withinOperationTimeout(operationTimeoutMs(common), common.signal, async (signal) => { + const source = await freezeSource(input, signal); + const parsed = await client.parse(sourceInput(input, source), { + ...parseFlagsOnly(options.parseFlags ?? {}), + ...(common.spaceId ? { spaceId: common.spaceId } : {}), ...(common.timeout ? { timeout: common.timeout } : {}), signal, + }); + const result = await callConvert(client, { irKey: parsed.irKey }, flags, { ...common, ...(parsed.spaceId ? { spaceId: parsed.spaceId } : {}) }, signal); + return { parsed, result }; + }, 'A one-shot convert leaves no artifact to resume; `deckops parse` records the task for reruns.'); + const portable = await writePortable(options.out ?? defaultPortableName(input, options.inputLabel), result, flags, options); + return envelope(options, { startedAt, format: result.format, taskId: result.taskId, parseTaskId: parsed.taskId, reusedParse: false, + outputs: portable.outputs, warnings: portable.warnings }); +} + +async function callConvert(client: DeckClient, ref: { irKey: string }, flags: ConvertFlags, common: CommonFlags, signal: AbortSignal): Promise { + return client.convert(ref, { + to: 'markdown', + ...(flags.anchors !== undefined ? { anchors: flags.anchors } : {}), + ...(flags.splitPages !== undefined ? { splitPages: flags.splitPages } : {}), + ...(flags.strict !== undefined ? { strict: flags.strict } : {}), + ...(common.spaceId ? { spaceId: common.spaceId } : {}), + ...(common.timeout ? { timeout: common.timeout } : {}), + signal, + }); +} + +/** `-o FILE` writes FILE.md plus FILE.assets/; `-o -` localizes into the cache and hands back the text. */ +async function writePortable(target: string, result: ConvertResult, flags: ConvertFlags, options: { onContent: (markdown: string) => void }): Promise<{ outputs: OutputFile[]; warnings: string[] }> { + const keep = flags.keepRemoteImages !== undefined ? { keepRemote: flags.keepRemoteImages } : {}; + if (target === '-') { + const dir = await stdoutAssetDir(); + const localized = await localizeImages({ images: result.images, destDir: dir, linkPrefix: `${dir}/`, ...keep }); + options.onContent(rewriteLinks(result.markdown, localized.rewrites)); + return { outputs: assetOutputs(dir, localized), warnings: localized.warnings }; + } + const base = target.endsWith('.md') ? target.slice(0, -3) : target; + const mdPath = `${base}.md`; const assetsName = `${path.basename(base)}.assets`; + const assetDir = path.join(path.dirname(mdPath), assetsName); + const localized = await localizeImages({ images: result.images, destDir: assetDir, linkPrefix: `${assetsName}/`, ...keep }); + await fs.mkdir(path.dirname(path.resolve(mdPath)), { recursive: true }); + const body = rewriteLinks(result.markdown, localized.rewrites); + await fs.writeFile(mdPath, body, 'utf-8'); + return { outputs: [{ file: mdPath, bytes: Buffer.byteLength(body) }, ...assetOutputs(assetDir, localized)], warnings: localized.warnings }; +} + +function assetOutputs(dir: string, localized: LocalizedAssets): OutputFile[] { + return Object.values(localized.saved).map((asset) => ({ file: path.join(dir, asset.file), bytes: asset.bytes })); +} + +async function stdoutAssetDir(): Promise { + const root = path.resolve(process.env.DECKOPS_CACHE_DIR ?? path.join(os.homedir(), '.cache', 'deckops'), 'exports'); + await fs.mkdir(root, { recursive: true, mode: 0o700 }); + return fs.mkdtemp(path.join(root, 'assets-')); +} + +function viewParamsFor(flags: ConvertFlags): Record { + return { + ...(flags.anchors !== undefined ? { anchors: flags.anchors } : {}), + ...(flags.splitPages !== undefined ? { splitPages: flags.splitPages } : {}), + ...(flags.strict !== undefined ? { strict: flags.strict } : {}), + ...(flags.keepRemoteImages !== undefined ? { keepRemoteImages: flags.keepRemoteImages } : {}), + }; +} + +function defaultPortableName(input: SourceInput, label: string): string { + if (input.kind === 'document') { const ext = path.extname(input.file); return input.file.slice(0, -ext.length); } + if (input.kind === 'stdin') return 'stdin'; + try { return new URL(input.url).hostname.replace(/[^\w.-]+/g, '-') || 'page'; } catch { return label; } +} + +function envelope(options: ConvertWork, extra: Omit & { startedAt: number }): ConvertEnvelope { + const { startedAt, ...rest } = extra; + return { ok: true, op: 'convert', input: options.inputLabel, to: 'markdown', ...rest, durationMs: Date.now() - startedAt }; +} diff --git a/src/node/input.ts b/src/node/input.ts new file mode 100644 index 0000000..7076479 --- /dev/null +++ b/src/node/input.ts @@ -0,0 +1,62 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { DeckOpsError } from '../errors/index.js'; +import type { Manifest, ParseTaskType } from '../types.js'; +import { readManifest } from './artifact.js'; +import { routeExtension } from '../shared/input.js'; + +export { EXTENSION_ROUTES, SUPPORTED_EXTENSIONS, extensionOf, routeExtension } from '../shared/input.js'; + +/** + * Input classification. Order matters: `-` → stdin, + * http(s) → link, directory with a manifest → artifact, file → document. + * A directory without a valid manifest is an error, not a guess. + */ + +export type ResolvedInput = + | { kind: 'document'; file: string; name: string; taskType: ParseTaskType } + | { kind: 'link'; url: string } + | { kind: 'stdin'; data: Buffer; name: string; taskType: ParseTaskType } + | { kind: 'artifact'; dir: string; manifest: Manifest }; + +export interface ResolveInputOptions { + /** Extension (e.g. "pdf") for stdin input, where there is no file name. */ + from?: string; + /** Injected for tests. */ + readStdin?: () => Buffer; +} + +export async function resolveInput(input: string, options: ResolveInputOptions = {}): Promise { + if (input === '-') { + if (!options.from) { + throw DeckOpsError.usage('Reading from stdin needs --from to pick a parser (e.g. --from pdf).'); + } + const ext = options.from.startsWith('.') ? options.from : `.${options.from}`; + const name = `stdin${ext.toLowerCase()}`; + const taskType = routeExtension(name); + const data = options.readStdin ? options.readStdin() : fs.readFileSync(0); + if (data.length === 0) { + throw DeckOpsError.input('stdin was empty.'); + } + return { kind: 'stdin', data, name, taskType }; + } + + if (/^https?:\/\//i.test(input)) { + return { kind: 'link', url: input }; + } + + let stat: fs.Stats; + try { + stat = fs.statSync(input); + } catch { + throw DeckOpsError.input(`No such file or directory: ${input}`); + } + + if (stat.isDirectory()) { + const manifest = await readManifest(input); + return { kind: 'artifact', dir: input, manifest }; + } + + const name = path.basename(input); + return { kind: 'document', file: input, name, taskType: routeExtension(name) }; +} diff --git a/src/assets/localize.ts b/src/node/localize.ts similarity index 81% rename from src/assets/localize.ts rename to src/node/localize.ts index 8d967bc..f542817 100644 --- a/src/assets/localize.ts +++ b/src/node/localize.ts @@ -7,7 +7,7 @@ import { DeckOpsError } from '../errors/index.js'; import type { ManifestAsset } from '../types.js'; /** - * Image localization (docs/rfc.md §4.4). The markdown a convert returns embeds + * Image localization. The markdown a convert returns embeds * signed URLs that expire in hours; a durable markdown needs the bytes on disk * and the links rewritten. Download failures FAIL the run by default — silently * shipping links that will rot is the worst outcome — with `keepRemote` as the @@ -36,6 +36,9 @@ export interface LocalizedAssets { } const RETRIES = 3; +const EXTENSION_MEDIA_TYPES: Record = { + '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', +}; /** * Pick unique on-disk names. `suggestedPath` basenames are unique in practice @@ -84,7 +87,7 @@ export async function localizeImages(options: LocalizeOptions): Promise => { const target = path.join(destDir, fileName); - const data = await downloadWithRetry(fetchImpl, image.ref, image.key); + const { data, mediaType } = await downloadWithRetry(fetchImpl, image.ref, image.key); // A wrong image is worse than a missing one: verify when the identity is known. if (image.hash) { const md5 = createHash('md5').update(data).digest('hex'); @@ -95,7 +98,8 @@ export async function localizeImages(options: LocalizeOptions): Promise encodeURIComponent(character)); +} + /** * Rewrite links by exact string match. `ref` is the very string the server * rendered into the text (upstream contract C3) — no URL normalization. @@ -146,7 +155,8 @@ export function rewriteLinks(text: string, rewrites: Map): strin return result; } -async function downloadWithRetry(fetchImpl: typeof fetch, url: string, key: string): Promise { +/** The media type comes from the response header (else the file extension), never from sniffing bytes. */ +async function downloadWithRetry(fetchImpl: typeof fetch, url: string, key: string): Promise<{ data: Buffer; mediaType: string | undefined }> { let lastError: unknown; for (let attempt = 0; attempt < RETRIES; attempt += 1) { try { @@ -154,7 +164,9 @@ async function downloadWithRetry(fetchImpl: typeof fetch, url: string, key: stri if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - return Buffer.from(await response.arrayBuffer()); + const header = response.headers.get('content-type')?.split(';')[0]?.trim(); + const mediaType = header && header !== 'application/octet-stream' ? header : undefined; + return { data: Buffer.from(await response.arrayBuffer()), mediaType }; } catch (error) { lastError = error; if (attempt < RETRIES - 1) { diff --git a/src/node/parse-op.ts b/src/node/parse-op.ts new file mode 100644 index 0000000..fc8e882 --- /dev/null +++ b/src/node/parse-op.ts @@ -0,0 +1,245 @@ +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import type { CloudParsedDocument, DeckClient, ParseOptions } from '../client/index.js'; +import { DeckOpsError } from '../errors/index.js'; +import { parseParams } from '../shared/params.js'; +import { irFormatForTaskType } from '../shared/validation.js'; +import type { CommonFlags, Manifest, OutputFile, ParseEnvelope, ParseFlags, ParseType } from '../types.js'; +import { VERSION } from '../version.js'; +import { + assetsDir, defaultArtifactDir, expired, IR_RETENTION_MS, irPath, manifestPath, normalizeParseParams, parseHit, readManifest, + submissionPath, viewDir, writeManifest, +} from './artifact.js'; +import type { ResolvedInput } from './input.js'; +import { withinOperationTimeout } from './timeout.js'; + +export interface ParseOpOptions { + input: ResolvedInput; inputLabel: string; out?: string; flags: ParseFlags; common: CommonFlags; + client: () => Promise; +} + +export type SourceInput = Exclude; + +/** Frozen source bytes (or URL) plus the identity the manifest records. */ +export interface FrozenSource { data?: Uint8Array; url?: string; name: string; sha256: string; bytes: number } + +interface Submission { + sourceHash: string; params: Record; status: 'submitted' | 'submission_unknown'; + taskId?: string; spaceId?: string; updatedAt: string; +} + +export async function runParse(options: ParseOpOptions): Promise { + if (options.input.kind === 'artifact') { + throw DeckOpsError.usage(`${options.inputLabel} is already an artifact.`, { hint: 'Run deckops convert to derive a view.' }); + } + const dir = options.out ?? defaultDirFor(options.input); + const lockPath = path.resolve(dir) + '.lock'; + await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 }); + let lock: Awaited>; + try { lock = await fs.open(lockPath, 'wx', 0o600); } + catch (cause) { + throw DeckOpsError.input('Artifact is already in use or its lock cannot be created.', { + hint: `Retry when the active operation finishes. A stale lock after a crash can be inspected at ${lockPath}.`, cause, + }); + } + try { + await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); + return await withinOperationTimeout(operationTimeoutMs(options.common), options.common.signal, + (signal) => parseLocked(options, options.input as SourceInput, dir, signal), + 'If a cloud task was created, rerunning the same command resumes it.'); + } finally { await lock.close(); await fs.rm(lockPath, { force: true }); } +} + +async function parseLocked(options: ParseOpOptions, input: SourceInput, dir: string, signal: AbortSignal): Promise { + const startedAt = Date.now(); + const { flags, common, inputLabel } = options; + const warnings: string[] = []; + const source = await freezeSource(input, signal); + const type: ParseType = input.kind === 'link' ? 'html.getByURL' : input.taskType; + const params = normalizeParseParams(parseParams(flags, input.kind === 'link')); + // A URL is never a cache hit: its content may have changed since the last parse. + if (!common.force && input.kind !== 'link') { + const existing = await readManifest(dir).catch(() => undefined); + if (existing && parseHit(dir, existing, source.sha256, params)) { + if (!expired(existing)) return envelope(inputLabel, dir, existing, { reused: true, startedAt, outputs: await outputsOf(dir), warnings }); + warnings.push(`The cloud IR behind ${dir} expired on ${existing.parse.expiresAt}; parsing the source again.`); + } + } + const journal = readSubmission(dir, source.sha256, params); + const client = await options.client(); + const operation = { + ...(common.spaceId ? { spaceId: common.spaceId } : {}), + ...(common.timeout ? { timeout: common.timeout } : {}), + signal, + }; + let parsed: CloudParsedDocument; + if (journal && !common.force) { + // The previous run submitted but got no result. With a task id, wait for that task and + // fetch its result: nothing is uploaded or billed again. Without one there is nothing to + // look up; refuse rather than risk paying twice. + if (!journal.taskId) { + throw DeckOpsError.backend('A previous cloud submission may have reached the cloud, but no task id came back.', { + hint: 'Run again with --force to resubmit; if the earlier task was created, it may be billed separately.', + }); + } + try { + parsed = await client.resume(journal.taskId, type, { ...operation, ...(journal.spaceId ? { spaceId: journal.spaceId } : {}) }); + } catch (error) { + // The task failed or is gone: forget it so the next run submits anew. Network trouble keeps it. + if (!signal.aborted && isTerminalTaskError(error)) { + rmSync(submissionPath(dir), { force: true }); + throw DeckOpsError.backend(`The cloud task ${journal.taskId} from an earlier run cannot be resumed: ${errorMessage(error)}`, { + taskId: journal.taskId, hint: 'Run the same command again to submit a new task.', cause: error, + }); + } + throw error; + } + warnings.push(`Resumed cloud task ${journal.taskId} submitted by an earlier run; nothing was uploaded or submitted again.`); + } else { + if (journal) { + warnings.push(`Resubmitted with --force over an unresolved cloud submission${journal.taskId ? ` (task ${journal.taskId})` : ''}; if that task was created, it may be billed separately.`); + } + const record = (state: Pick): void => + writeSubmission(dir, { sourceHash: source.sha256, params, ...state, updatedAt: new Date().toISOString() }); + let dispatched = false; + let taskId: string | undefined; + try { + parsed = await client.parse(sourceInput(input, source), { + ...parseFlagsOnly(flags), ...operation, + onSubmit: () => { dispatched = true; record({ status: 'submission_unknown' }); }, + onProgress: (event) => { + if (event.phase === 'parse' && taskId === undefined) { taskId = event.taskId; record({ status: 'submitted', taskId, spaceId: event.spaceId }); } + }, + }); + } catch (error) { + // The server refused the task request outright (expired login, quota, bad params): no task exists. + if (dispatched && taskId === undefined && rejectedByServer(error)) rmSync(submissionPath(dir), { force: true }); + throw error; + } + } + signal.throwIfAborted(); + const { manifest, outputs } = await persist(dir, parsed, source, params); + rmSync(submissionPath(dir), { force: true }); + return envelope(inputLabel, dir, manifest, { reused: false, startedAt, outputs, warnings }); +} + +/** Read the source once and verify it did not change meanwhile; the bytes are the snapshot. */ +export async function freezeSource(input: SourceInput, signal?: AbortSignal): Promise { + if (input.kind === 'link') return { url: input.url, name: input.url, sha256: sha256(input.url), bytes: 0 }; + if (input.kind === 'stdin') return { data: input.data, name: input.name, sha256: sha256(input.data), bytes: input.data.byteLength }; + const before = await fs.stat(input.file); + const data = await fs.readFile(input.file, { signal }); + const after = await fs.stat(input.file); + if (after.size !== before.size || after.mtimeMs !== before.mtimeMs || data.byteLength !== before.size) { + throw DeckOpsError.input('Source changed while it was being read.'); + } + return { data, name: input.name, sha256: sha256(data), bytes: data.byteLength }; +} + +export function sourceInput(input: SourceInput, source: FrozenSource): { file: Uint8Array; name: string } | { url: string } { + return input.kind === 'link' ? { url: input.url } : { file: source.data!, name: source.name }; +} + +/** Only the flags the cloud client accepts; artifact and output options stay here. */ +export function parseFlagsOnly(flags: ParseFlags): Pick { + return { + ...(flags.profile !== undefined ? { profile: flags.profile } : {}), + ...(flags.password !== undefined ? { password: flags.password } : {}), + ...(flags.includeImages !== undefined ? { includeImages: flags.includeImages } : {}), + ...(flags.stayImageAreaRate !== undefined ? { stayImageAreaRate: flags.stayImageAreaRate } : {}), + ...(flags.mode !== undefined ? { mode: flags.mode } : {}), + }; +} + +export function operationTimeoutMs(common: CommonFlags): number { + const value = common.timeout !== undefined ? common.timeout * 1000 : 120_000; + if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw DeckOpsError.usage('Timeout must be a positive duration within the Node.js timer range.'); + return value; +} + +async function persist(dir: string, parsed: CloudParsedDocument, source: FrozenSource, params: Record): Promise<{ manifest: Manifest; outputs: OutputFile[] }> { + await fs.mkdir(dir, { recursive: true, mode: 0o700 }); + // A fresh parse invalidates every derived view and its downloaded images. + await fs.rm(viewDir(dir, 'markdown'), { recursive: true, force: true }); + await fs.rm(assetsDir(dir), { recursive: true, force: true }); + const irJson = `${JSON.stringify(await parsed.ir(), null, 2)}\n`; + await fs.writeFile(irPath(dir), irJson, 'utf8'); + const createdAt = new Date(); + const manifest: Manifest = { + manifestVersion: 3, + source: { sha256: source.sha256, name: source.name, bytes: source.bytes }, + parse: { + taskId: parsed.taskId, type: parsed.type, irKey: parsed.irKey, irSchemaVersion: parsed.irSchemaVersion, params, + createdAt: createdAt.toISOString(), expiresAt: new Date(createdAt.getTime() + IR_RETENTION_MS).toISOString(), + }, + views: {}, assets: {}, producer: { deckops: VERSION }, + }; + await writeManifest(dir, manifest); + return { manifest, outputs: await outputsOf(dir) }; +} + +async function outputsOf(dir: string): Promise { + return Promise.all([irPath(dir), manifestPath(dir)].map(async (file) => ({ file, bytes: (await fs.stat(file)).size }))); +} + +function readSubmission(dir: string, sourceHash: string, params: Record): Submission | undefined { + let journal: Partial; + try { journal = JSON.parse(readFileSync(submissionPath(dir), 'utf8')) as Partial; } + catch { return undefined; } + if (journal.sourceHash !== sourceHash || JSON.stringify(journal.params) !== JSON.stringify(params)) return undefined; + if (journal.status !== 'submitted' && journal.status !== 'submission_unknown') return undefined; + return journal as Submission; +} + +function writeSubmission(dir: string, submission: Submission): void { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const file = submissionPath(dir); const temp = `${file}.tmp`; + writeFileSync(temp, `${JSON.stringify(submission)}\n`, { mode: 0o600 }); + renameSync(temp, file); +} + +function envelope(input: string, dir: string, manifest: Manifest, extra: { reused: boolean; startedAt: number; outputs: OutputFile[]; warnings: string[] }): ParseEnvelope { + return { + ok: true, op: 'parse', input, type: manifest.parse.type, format: irFormatForTaskType(manifest.parse.type), + taskId: manifest.parse.taskId, irKey: manifest.parse.irKey, irSchemaVersion: manifest.parse.irSchemaVersion, + expiresAt: manifest.parse.expiresAt, reusedParse: extra.reused, artifact: dir, + outputs: extra.outputs, warnings: extra.warnings, durationMs: Date.now() - extra.startedAt, + }; +} + +export function defaultDirFor(input: SourceInput): string { + if (input.kind === 'document') return defaultArtifactDir(input.file); + if (input.kind === 'stdin') return 'stdin'; + const url = new URL(input.url); + return `${url.hostname}${url.pathname}`.replace(/[^\w.-]+/g, '-').replace(/-+$/, '') || 'page'; +} + +function sha256(data: string | Uint8Array): string { + return createHash('sha256').update(data).digest('hex'); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** The server answered 4xx: the request was refused and no task was created. 5xx may have created one. */ +function rejectedByServer(error: unknown): boolean { + for (let current: unknown = error; current instanceof Error; current = (current as { cause?: unknown }).cause) { + const status = (current as { statusCode?: unknown }).statusCode; + if (typeof status === 'number' && status >= 400 && status < 500) return true; + } + return false; +} + +/** The task itself is over: the cloud reports it failed, or the task/result cannot be found. */ +function isTerminalTaskError(error: unknown): boolean { + for (let current: unknown = error; current instanceof Error; current = (current as { cause?: unknown }).cause) { + if (/^Task failed:|returned no task status/.test(current.message)) return true; + const status = (current as { statusCode?: unknown }).statusCode; + if (status === 403 || status === 404 || status === 410) return true; + if (current instanceof DeckOpsError && (current.code === 'ir_not_found' || current.code === 'ir_expired')) return true; + } + return false; +} diff --git a/src/node/read-op.ts b/src/node/read-op.ts new file mode 100644 index 0000000..289d987 --- /dev/null +++ b/src/node/read-op.ts @@ -0,0 +1,102 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { DeckOpsError } from '../errors/index.js'; +import type { OutputFile } from '../types.js'; +import { irPath, readManifest, viewDir } from './artifact.js'; +import { runConvert } from './convert-op.js'; +import { markdownLink, rewriteLinks } from './localize.js'; +import { runParse, type ParseOpOptions } from './parse-op.js'; +import { validateConvertFlags } from './validation.js'; + +export interface ReadReport { + schemaVersion: 'deckops.run.v2'; parseTaskId: string; convertTaskId?: string; cacheHit: boolean; artifactBase: string; warnings: string[]; +} +export interface ReadResult { + schemaVersion: 'deckops.read.v2'; ok: true; format: F; + content: (F extends 'ir' ? unknown : string) | null; + outputs: OutputFile[]; + assets: Array<{ path: string; lifecycle: 'cache' | 'output'; bytes: number }>; + report: ReadReport; +} +export interface ReadOpOptions extends Omit { + out?: string; format?: 'markdown' | 'ir'; reportFile?: string; anchors?: boolean; cacheDir?: string; +} + +/** Default entry: parse into the cache (or reuse), convert, hand back the content. */ +export async function runRead(options: ReadOpOptions): Promise { + const format = options.format ?? 'markdown'; + if (!['markdown', 'ir'].includes(format)) throw DeckOpsError.usage('format must be markdown or ir.'); + const output = options.out && options.out !== '-' ? path.resolve(options.out) : undefined; + if (format === 'ir' && output) throw DeckOpsError.usage('Use deckops parse -o DIR to export portable IR.'); + if (options.reportFile === '-') throw DeckOpsError.usage('--report must be a file; stdout is reserved for content.'); + const reportFile = options.reportFile ? path.resolve(options.reportFile) : undefined; + const sourceFile = options.input.kind === 'document' ? path.resolve(options.input.file) : undefined; + const paths = [output, reportFile, sourceFile].filter((x): x is string => !!x); + for (let i = 0; i < paths.length; i++) for (let j = i + 1; j < paths.length; j++) { + const canonical = async (p: string) => fs.realpath(p).catch(() => fs.realpath(path.dirname(p)).then(parent => path.join(parent, path.basename(p))).catch(() => p)); + if (await canonical(paths[i]!) === await canonical(paths[j]!)) throw DeckOpsError.usage('Source, output and report paths must be distinct.'); + } + const convertFlags = options.anchors !== undefined ? { anchors: options.anchors } : {}; + validateConvertFlags(options.input, convertFlags); + options.common.signal?.throwIfAborted(); + + let artifactBase: string; let cacheHit: boolean; let warnings: string[]; + if (options.input.kind === 'artifact') { + artifactBase = path.resolve(options.input.dir); cacheHit = true; warnings = []; + } else { + const root = options.cacheDir ?? process.env.DECKOPS_CACHE_DIR ?? path.join(os.homedir(), '.cache', 'deckops'); + // URL fetches and password-bearing parses are never persistently reused by identity alone. + const key = createHash('sha256').update(JSON.stringify([options.input.kind === 'document' ? path.resolve(options.inputLabel) : options.inputLabel, { ...options.flags, password: undefined }])).digest('hex'); + const unique = options.input.kind !== 'document' || options.flags.password ? `-${randomUUID()}` : ''; + artifactBase = path.resolve(root, key + unique); + const parsed = await runParse({ ...options, out: artifactBase }); + cacheHit = parsed.reusedParse; warnings = parsed.warnings; + } + let manifest = await readManifest(artifactBase); + const report: ReadReport = { schemaVersion: 'deckops.run.v2', parseTaskId: manifest.parse.taskId, cacheHit, artifactBase, warnings }; + const outputs: OutputFile[] = []; + let content: unknown = null; + let assets: ReadResult['assets'] = []; + if (format === 'ir') { + content = JSON.parse(await fs.readFile(irPath(artifactBase), 'utf8')); + } else { + const converted = await runConvert({ input: { kind: 'artifact', dir: artifactBase, manifest }, inputLabel: artifactBase, flags: convertFlags, common: options.common, client: options.client }); + manifest = await readManifest(artifactBase); + report.convertTaskId = converted.taskId ?? manifest.views.markdown?.taskId ?? ''; + report.warnings = [...new Set([...warnings, ...converted.warnings])]; + assets = Object.entries(manifest.assets).map(([relative, asset]) => ({ path: path.join(artifactBase, relative), lifecycle: 'cache' as const, bytes: asset.bytes })); + const base = output?.endsWith('.md') ? output.slice(0, -3) : output; + // Immutable asset directory: a failed overwrite must not damage the previous Markdown's links. + const assetsName = base ? `${path.basename(base)}.assets-${randomUUID().slice(0, 8)}` : undefined; + const prefix = output ? `${assetsName}/` : `${path.join(artifactBase, 'assets')}/`; + const view = await fs.readFile(path.join(viewDir(artifactBase, 'markdown'), 'index.md'), 'utf8'); + const markdown = rewriteLinks(view, new Map(Object.keys(manifest.assets).map((relative) => + [markdownLink(`../../${relative}`), markdownLink(`${prefix}${path.basename(relative)}`)]))); + if (output) { + await fs.mkdir(path.dirname(output), { recursive: true }); + const assetDir = path.join(path.dirname(output), assetsName!); + const temp = `${output}.${randomUUID()}.tmp`; + try { + if (assets.length) await fs.mkdir(assetDir, { recursive: false }); + for (const asset of assets) { const file = path.join(assetDir, path.basename(asset.path)); await fs.copyFile(asset.path, file); outputs.push({ file, bytes: asset.bytes }); } + await fs.writeFile(temp, markdown, 'utf8'); + options.common.signal?.throwIfAborted(); + await fs.rename(temp, output); outputs.unshift({ file: output, bytes: Buffer.byteLength(markdown) }); + } catch (cause) { + await fs.rm(temp, { force: true }); await fs.rm(assetDir, { recursive: true, force: true }); + throw DeckOpsError.input('Could not deliver Markdown and its assets.', { cause }); + } + assets = assets.map((asset) => ({ ...asset, path: path.join(assetDir, path.basename(asset.path)), lifecycle: 'output' as const })); + } else { + content = markdown; + } + } + if (reportFile) { + await fs.mkdir(path.dirname(reportFile), { recursive: true }); + const temp = `${reportFile}.${randomUUID()}.tmp`; + try { await fs.writeFile(temp, `${JSON.stringify(report, null, 2)}\n`); await fs.rename(temp, reportFile); } finally { await fs.rm(temp, { force: true }); } + } + return { schemaVersion: 'deckops.read.v2', ok: true, format, content: content as string | null, outputs, assets, report }; +} diff --git a/src/core/timeout.ts b/src/node/timeout.ts similarity index 100% rename from src/core/timeout.ts rename to src/node/timeout.ts diff --git a/src/node/validation.ts b/src/node/validation.ts new file mode 100644 index 0000000..dc0a503 --- /dev/null +++ b/src/node/validation.ts @@ -0,0 +1,40 @@ +import { DeckOpsError } from '../errors/index.js'; +import type { ConvertFlags, ParseFlags } from '../types.js'; +import type { ResolvedInput } from './input.js'; +import { formatForTaskType, validateConvertFlagsForFormat, validateParseFlagsForFormat, type FormatKey } from '../shared/validation.js'; + +/** + * Flag × format validation. A flag that cannot apply is a usage error before any task is + * sent — the backend silently ignores unknown params, so the client is the only place this + * can be caught. + */ + +export function formatOf(input: ResolvedInput): FormatKey | undefined { + if (input.kind === 'link') return 'link'; + if (input.kind === 'artifact') return formatForTaskType(input.manifest.parse.type); + return formatForTaskType(input.taskType); +} + +export function validateParseFlags(input: ResolvedInput, flags: ParseFlags): void { + if (flags.profile && !['fast', 'balanced', 'quality'].includes(flags.profile)) throw DeckOpsError.usage('--profile must be fast, balanced, or quality.'); + if (flags.mode && !['source', 'runtime'].includes(flags.mode)) throw DeckOpsError.usage('--mode must be source or runtime.'); + if (flags.stayImageAreaRate !== undefined && (!Number.isFinite(flags.stayImageAreaRate) || flags.stayImageAreaRate < 0 || flags.stayImageAreaRate > 1)) { + throw DeckOpsError.usage('--stay-image-area-rate must be a number between 0 and 1.'); + } + if (input.kind === 'artifact') { + const set = Object.entries(flags).filter(([, value]) => value !== undefined); + if (set.length > 0) { + throw DeckOpsError.usage(`Parse flags (${set.map(([name]) => `--${kebab(name)}`).join(', ')}) cannot apply to an artifact — it is already parsed.`); + } + return; + } + validateParseFlagsForFormat(formatOf(input), flags); +} + +export function validateConvertFlags(input: ResolvedInput, flags: ConvertFlags): void { + validateConvertFlagsForFormat(formatOf(input), flags); +} + +function kebab(name: string): string { + return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); +} diff --git a/src/shared/validation.ts b/src/shared/validation.ts index 54d3902..7394439 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -3,6 +3,12 @@ import type { ConvertFlags, ParseFlags } from '../types.js'; export type FormatKey = 'pdf' | 'pptx' | 'docx' | 'keynote' | 'link'; +/** IR format names as the convert result reports them. */ +export function irFormatForTaskType(type: string): 'pdf' | 'pptx' | 'docx' | 'keynote' | 'html' { + const format = formatForTaskType(type); + return format === 'link' || format === undefined ? 'html' : format; +} + export function formatForTaskType(type: string): FormatKey | undefined { switch (type) { case 'pdf.pdfParse': diff --git a/src/types-legacy.ts b/src/types-legacy.ts new file mode 100644 index 0000000..a987139 --- /dev/null +++ b/src/types-legacy.ts @@ -0,0 +1,113 @@ +/** Public artifact, option and machine-readable envelope contracts. */ + +import type { IrFormat } from './cloud/parse/types.js'; +export type { ConvertImage } from './cloud/parse/types.js'; +import type { DeckIR, DocumentFormat, QualityReport } from './ir/schema.js'; +import type { LocalLimits } from './local/limits.js'; +import type { PreflightMode, PreflightSummary } from './shared/preflight.js'; + +export type ParseTaskType = 'pdf.pdfParse' | 'pptx.parse' | 'docx.parseTextAndImage' | 'keynote.parseTextAndImage'; +export type EngineMode = 'local' | 'cloud' | 'auto'; +export type OutputEngine = 'local' | 'cloud' | 'artifact-cache'; + +export interface ManifestView { + engine: 'local' | 'cloud'; + rendererVersion: string; + taskId?: string; + params: Record; + files: string[]; + createdAt: string; +} + +export interface ManifestAsset { key: string; hash: string; bytes: number; mediaType?: string } + +export interface ManifestInspection { + file: string; schemaVersion: 2; toolVersion: string; requestVersion: number; + status: 'ok' | 'partial'; summary: PreflightSummary; createdAt: string; +} + +export interface ManifestV1View { + taskId: string; params: Record; files: string[]; createdAt: string; +} + +export interface ManifestV1 { + manifestVersion: 1; + source: { sha256?: string; name: string; bytes?: number }; + parse: { + taskId: string; type: ParseTaskType | 'html.getByURL'; irKey: string; irSchemaVersion: string; + params: Record; createdAt: string; + }; + inspection?: ManifestInspection; + views: Partial>; + assets: Record; + producer: { deckparse: string; sdk: string }; +} + +export interface ManifestV2 { + manifestVersion: 2; + source: { sha256: string; name: string; bytes: number }; + parse: { + engine: 'local' | 'cloud'; format: DocumentFormat; schemaVersion: 'deckir.v1'; + parser: { name: string; version: string }; params: Record; createdAt: string; + remote: { taskId: string; irKey: string; expiresAt?: string | undefined } | null; + }; + inspection?: ManifestInspection; + quality: QualityReport; + views: Partial>; + assets: Record; + producer: { deckparse: string; sdk?: string }; +} + +export type Manifest = ManifestV1 | ManifestV2; + +export interface ParseFlags { + profile?: 'fast' | 'balanced' | 'quality'; + password?: string; + includeImages?: boolean; + pageFurniture?: 'off' | 'drop' | 'extract'; + overlaidText?: 'auto' | 'keep' | 'drop'; + trackedChanges?: 'final' | 'original' | 'all'; + stayImageAreaRate?: number; + mode?: 'source' | 'runtime'; +} + +export interface ConvertFlags { + to?: 'markdown'; anchors?: boolean; splitPages?: boolean; strict?: boolean; keepRemoteImages?: boolean; +} + +export interface CommonFlags { + signal?: AbortSignal; + policySource?: Record; + cloudLimits?: { maxSourceBytes?: number; maxSourcePages?: number; maxCost?: { amount: string; currency: string } }; + spaceId?: string; timeout?: number; force?: boolean; + engine?: EngineMode; allowUpload?: boolean; failOnDegraded?: boolean; + limits?: Partial; +} + +export interface OutputFile { file: string; bytes: number } + +export interface ParseEnvelope { + assessment?: import('./shared/assessment-types.js').Assessment; + decision?: import('./shared/policy-types.js').RouteDecision; + ok: true; op: 'parse'; input: string; engine: OutputEngine; type: ParseTaskType | 'html.getByURL'; + format: DocumentFormat; quality: QualityReport; taskId: string | null; reusedParse: boolean; + irKey?: string; irSchemaVersion: 'deckir.v1'; artifact: string; inspection?: PreflightSummary; + outputs: OutputFile[]; warnings: string[]; durationMs: number; +} + +export interface ConvertEnvelope { + assessment?: import('./shared/assessment-types.js').Assessment; + decision?: import('./shared/policy-types.js').RouteDecision; + content?: string; + ok: true; op: 'convert'; input: string; to: 'markdown'; engine: OutputEngine; format: IrFormat; + taskId: string | null; parseTaskId?: string; inspection?: PreflightSummary; reusedParse: boolean; + quality?: QualityReport; outputs: OutputFile[]; warnings: string[]; durationMs: number; +} + +export interface ErrorEnvelope { + ok: false; op: 'parse' | 'convert' | 'install' | 'read'; error: { code: string; message: string; hint?: string; taskId?: string }; +} + +export type Envelope = ParseEnvelope | ConvertEnvelope | ErrorEnvelope; + +export type { DeckIR, DocumentFormat, IrFormat, LocalLimits, PreflightMode, PreflightSummary, QualityReport }; diff --git a/src/types.ts b/src/types.ts index a987139..2585a2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,19 +1,14 @@ -/** Public artifact, option and machine-readable envelope contracts. */ +/** Public artifact, option and machine-readable envelope contracts (3.0). */ import type { IrFormat } from './cloud/parse/types.js'; -export type { ConvertImage } from './cloud/parse/types.js'; -import type { DeckIR, DocumentFormat, QualityReport } from './ir/schema.js'; -import type { LocalLimits } from './local/limits.js'; -import type { PreflightMode, PreflightSummary } from './shared/preflight.js'; +export type { ConvertImage, IrFormat } from './cloud/parse/types.js'; export type ParseTaskType = 'pdf.pdfParse' | 'pptx.parse' | 'docx.parseTextAndImage' | 'keynote.parseTextAndImage'; -export type EngineMode = 'local' | 'cloud' | 'auto'; -export type OutputEngine = 'local' | 'cloud' | 'artifact-cache'; +export type ParseType = ParseTaskType | 'html.getByURL'; export interface ManifestView { - engine: 'local' | 'cloud'; - rendererVersion: string; - taskId?: string; + taskId: string; + schemaVersion: string; params: Record; files: string[]; createdAt: string; @@ -21,44 +16,20 @@ export interface ManifestView { export interface ManifestAsset { key: string; hash: string; bytes: number; mediaType?: string } -export interface ManifestInspection { - file: string; schemaVersion: 2; toolVersion: string; requestVersion: number; - status: 'ok' | 'partial'; summary: PreflightSummary; createdAt: string; -} - -export interface ManifestV1View { - taskId: string; params: Record; files: string[]; createdAt: string; -} - -export interface ManifestV1 { - manifestVersion: 1; - source: { sha256?: string; name: string; bytes?: number }; - parse: { - taskId: string; type: ParseTaskType | 'html.getByURL'; irKey: string; irSchemaVersion: string; - params: Record; createdAt: string; - }; - inspection?: ManifestInspection; - views: Partial>; - assets: Record; - producer: { deckparse: string; sdk: string }; -} - -export interface ManifestV2 { - manifestVersion: 2; +export interface ManifestV3 { + manifestVersion: 3; + /** URL sources record sha256(url) and bytes 0. */ source: { sha256: string; name: string; bytes: number }; parse: { - engine: 'local' | 'cloud'; format: DocumentFormat; schemaVersion: 'deckir.v1'; - parser: { name: string; version: string }; params: Record; createdAt: string; - remote: { taskId: string; irKey: string; expiresAt?: string | undefined } | null; + taskId: string; type: ParseType; irKey: string; irSchemaVersion: string; + params: Record; createdAt: string; expiresAt: string; }; - inspection?: ManifestInspection; - quality: QualityReport; - views: Partial>; + views: Partial>; assets: Record; - producer: { deckparse: string; sdk?: string }; + producer: { deckops: string }; } -export type Manifest = ManifestV1 | ManifestV2; +export type Manifest = ManifestV3; export interface ParseFlags { profile?: 'fast' | 'balanced' | 'quality'; @@ -76,32 +47,24 @@ export interface ConvertFlags { } export interface CommonFlags { - signal?: AbortSignal; - policySource?: Record; - cloudLimits?: { maxSourceBytes?: number; maxSourcePages?: number; maxCost?: { amount: string; currency: string } }; - spaceId?: string; timeout?: number; force?: boolean; - engine?: EngineMode; allowUpload?: boolean; failOnDegraded?: boolean; - limits?: Partial; + signal?: AbortSignal; spaceId?: string; timeout?: number; force?: boolean; } export interface OutputFile { file: string; bytes: number } export interface ParseEnvelope { - assessment?: import('./shared/assessment-types.js').Assessment; - decision?: import('./shared/policy-types.js').RouteDecision; - ok: true; op: 'parse'; input: string; engine: OutputEngine; type: ParseTaskType | 'html.getByURL'; - format: DocumentFormat; quality: QualityReport; taskId: string | null; reusedParse: boolean; - irKey?: string; irSchemaVersion: 'deckir.v1'; artifact: string; inspection?: PreflightSummary; - outputs: OutputFile[]; warnings: string[]; durationMs: number; + ok: true; op: 'parse'; input: string; type: ParseType; format: IrFormat; + taskId: string; irKey: string; irSchemaVersion: string; expiresAt: string; + reusedParse: boolean; artifact: string; outputs: OutputFile[]; warnings: string[]; durationMs: number; } export interface ConvertEnvelope { - assessment?: import('./shared/assessment-types.js').Assessment; - decision?: import('./shared/policy-types.js').RouteDecision; + ok: true; op: 'convert'; input: string; to: 'markdown'; format: IrFormat; + /** The convert task; null when a cached view was returned. */ + taskId: string | null; parseTaskId: string; reusedParse: boolean; + /** Markdown, only for `-o -`. */ content?: string; - ok: true; op: 'convert'; input: string; to: 'markdown'; engine: OutputEngine; format: IrFormat; - taskId: string | null; parseTaskId?: string; inspection?: PreflightSummary; reusedParse: boolean; - quality?: QualityReport; outputs: OutputFile[]; warnings: string[]; durationMs: number; + outputs: OutputFile[]; warnings: string[]; durationMs: number; } export interface ErrorEnvelope { @@ -109,5 +72,3 @@ export interface ErrorEnvelope { } export type Envelope = ParseEnvelope | ConvertEnvelope | ErrorEnvelope; - -export type { DeckIR, DocumentFormat, IrFormat, LocalLimits, PreflightMode, PreflightSummary, QualityReport }; diff --git a/tests/browser/fake-cloud.d.mts b/tests/browser/fake-cloud.d.mts index 475f1f9..9a10c7e 100644 --- a/tests/browser/fake-cloud.d.mts +++ b/tests/browser/fake-cloud.d.mts @@ -14,6 +14,8 @@ export interface FakeState { activeStreams: number; closedStreams: number; abortedCreates: number; + /** Fail this many result downloads with 503 (set by a test). */ + failDownloads?: number; } export interface FakeCloud { origin: string; diff --git a/tests/browser/fake-cloud.mjs b/tests/browser/fake-cloud.mjs index a9594c5..949c846 100644 --- a/tests/browser/fake-cloud.mjs +++ b/tests/browser/fake-cloud.mjs @@ -151,6 +151,11 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { if (taskMatch[3] === 'start') { json(res, 200, { ...task, status: 'running' }); return; } if (taskMatch[3] === 'download') { if (kind('hanging-download')) return; + // A test sets failDownloads to lose that many results after the task completed. + if (state.failDownloads > 0) { + state.failDownloads -= 1; + json(res, 503, { message: 'Result download unavailable' }); return; + } if (task.type === 'parse.convert') { const ref = `${origin}/api/${caseName}/v1/image.png?expires=fixture`; const parsed = state.tasks.find((item) => item.type !== 'parse.convert' && diff --git a/tests/integration/ops.test.ts b/tests/integration/ops.test.ts index 9c0bff5..00c2937 100644 --- a/tests/integration/ops.test.ts +++ b/tests/integration/ops.test.ts @@ -1,498 +1,269 @@ -import { createHash } from 'node:crypto'; import fs from 'node:fs'; -import http from 'node:http'; import os from 'node:os'; import path from 'node:path'; -import { afterAll, describe, expect, it, vi } from 'vitest'; -import type { ConvertResult, ParseResult } from '../../src/cloud/parse-facade.js'; -import type { ProbeReport, ProbeResult } from '@deckflow/deckprobe'; -import type { CloudClient } from '../../src/cloud/client.js'; -import type { NodeDocumentInspector } from '../../src/core/inspector.js'; -import { runConvert } from '../../src/core/convert-op.js'; -import { resolveInput } from '../../src/core/input.js'; -import { runParse } from '../../src/core/parse-op.js'; -import { DeckOpsError } from '../../src/errors/index.js'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { createClientWith } from '../../src/client/index.js'; +import { nodeRuntime } from '../../src/cloud/node-runtime.js'; +import { runConvert } from '../../src/node/convert-op.js'; +import { resolveInput } from '../../src/node/input.js'; +import { runParse } from '../../src/node/parse-op.js'; +import { runRead } from '../../src/node/read-op.js'; +import type { CommonFlags } from '../../src/types.js'; +import { createFakeCloud, FIXTURE_PNG, type FakeCloud } from '../browser/fake-cloud.mjs'; /** - * Full operation flows against a fake cloud client, with fixtures shaped like - * real dev-environment responses. Asserts the things scripts depend on: - * artifact layout, reuse semantics, reusedParse, crash-safe ordering. + * The Node operation layer over real HTTP against the wire-protocol fixture: artifact layout, + * reuse, image localization, resume after a lost result, and retention. */ -const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-ops-')); -const cloudCommon = (extra: Record = {}) => ({ engine: 'cloud' as const, ...extra }); - -const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); -const pngMd5 = createHash('md5').update(png).digest('hex'); - -/** Local HTTP server standing in for signed OSS URLs. */ -const server = http.createServer((req, res) => { - if (req.url?.startsWith('/img')) { - res.writeHead(200, { 'Content-Type': 'image/png' }); - res.end(png); - return; - } - res.writeHead(404); - res.end(); -}); -await new Promise((resolve) => server.listen(0, resolve)); -const port = (server.address() as { port: number }).port; -const signedUrl = `http://127.0.0.1:${port}/img?Expires=123&sig=abc`; -afterAll(() => server.close()); - -const parseResult = (over: Partial = {}): ParseResult => ({ - taskId: 't_parse_1', - type: 'pdf.pdfParse', - irKey: '2026-08/aa/ir.json', - irSchemaVersion: 'result.v3', - ir: { - irKey: '2026-08/aa/ir.json', - irSchemaVersion: 'result.v3', - document: { schemaVersion: 'result.v3', elements: [] }, - images: [{ assetPath: 'assets/p1_i0000.png', key: '2026-08/aa/p1.png', bytes: png.length, hash: pngMd5, accessURL: signedUrl }], - }, - ...over, -}); - -const convertResult = (over: Partial = {}): ConvertResult => ({ - taskId: 't_convert_1', - format: 'pdf', - schemaVersion: 'result.v3', - to: 'markdown', - markdown: `# Title\n\n![](${signedUrl})\n`, - images: [{ ref: signedUrl, key: '2026-08/aa/p1.png', suggestedPath: 'assets/p1_i0000.png', bytes: png.length, hash: pngMd5 }], - ...over, -}); +let cloud: FakeCloud; +beforeAll(async () => { cloud = await createFakeCloud(); }); +afterAll(async () => { await cloud.close(); }); -const fakeClient = (overrides: Partial = {}): CloudClient & { parseCalls: number; convertCalls: number } => { - const client = { - parseCalls: 0, - convertCalls: 0, - parse: vi.fn(async () => { - client.parseCalls += 1; - return parseResult(); - }) as never, - convert: vi.fn(async () => { - client.convertCalls += 1; - return convertResult(); - }) as never, - resume: vi.fn(async () => { throw new Error('resume is not expected in this test'); }) as never, - ...overrides, - }; - return client as never; -}; +const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-ops-')); +const clientFor = (name: string) => async () => createClientWith({ apiBase: cloud.apiBase(name), token: 'test-token' }, nodeRuntime, 'node'); +const created = (name: string) => cloud.state(name).tasks; +const readJson = (file: string) => JSON.parse(fs.readFileSync(file, 'utf8')); -const writeSource = (dir: string): string => { - const file = path.join(dir, 'doc.pdf'); - fs.writeFileSync(file, 'fake pdf bytes'); +const writeSource = (dir: string, name = 'doc.pdf'): string => { + const file = path.join(dir, name); + fs.writeFileSync(file, 'fake document bytes'); return file; }; -const probeReport = (): ProbeReport => ({ - schema_version: 2, - tool_version: '2.4.0', - status: 'ok', - input: { display_name: 'doc.pdf', source_kind: 'browser_bytes', file_size: 14 }, - driver: { id: 'pdf', profile: 'pdf' }, - results: Object.fromEntries([ - ['document.format_profile', 'pdf'], - ['document.extension_matches', true], - ['security.encrypted', false], - ['security.has_macros', false], - ['security.has_external_relationships', true], - ['security.has_embedded_files', false], - ['pdf.page_count', 3], - ].map(([target, value]) => [target, { - target, status: 'resolved', value, confidence: 'exact', confidence_score: 1, - path: 'fixture.path', source: 'fixture', - }])), - execution: { - probe_level: 'metadata', paths: ['fixture.path'], estimated_cost: 1, - actual_cost: { physical_bytes_read: 14, expanded_bytes: 0, random_reads: 0 }, unresolved_targets: [], - }, - diagnostics: [], -}); +const parse = async (name: string, source: string, out: string, extra: { flags?: object; common?: CommonFlags } = {}) => + runParse({ input: await resolveInput(source), inputLabel: source, out, flags: extra.flags ?? {}, common: extra.common ?? {}, client: clientFor(name) }); -const fakeInspector = (result: ProbeResult = probeReport()): NodeDocumentInspector & { inspect: ReturnType } => ({ - inspect: vi.fn(async () => result), -}) as never; +const convert = async (name: string, input: string, extra: { flags?: object; common?: CommonFlags; out?: string } = {}) => + runConvert({ input: await resolveInput(input), inputLabel: input, ...(extra.out ? { out: extra.out } : {}), flags: extra.flags ?? {}, common: extra.common ?? {}, client: clientFor(name) }); describe('parse → artifact', () => { - it('lays out ir.json + assets + manifest and reuses on the second run', async () => { + it('stores the server IR verbatim with manifest v3, then reuses it without a task', async () => { const dir = tmp(); const source = writeSource(dir); const out = path.join(dir, 'artifact'); - const client = fakeClient(); - - const first = await runParse({ - input: await resolveInput(source), - inputLabel: source, - out, - flags: {}, - common: cloudCommon(), - preflight: 'off', - client, - }); - expect(first.reusedParse).toBe(false); - expect(first.engine).toBe('cloud'); - expect(first.irKey).toBe('2026-08/aa/ir.json'); - expect(fs.existsSync(path.join(out, 'ir.json'))).toBe(true); - expect(fs.existsSync(path.join(out, 'manifest.json'))).toBe(true); - // pdf parse results carry an image index → assets localized at parse time - const assetFiles = fs.readdirSync(path.join(out, 'assets')); - expect(assetFiles).toHaveLength(1); - expect(fs.readFileSync(path.join(out, 'assets', assetFiles[0]!))).toEqual(png); - // views belong to convert; parse must never create them - expect(fs.existsSync(path.join(out, 'views'))).toBe(false); - - const second = await runParse({ - input: await resolveInput(source), - inputLabel: source, - out, - flags: {}, - common: cloudCommon(), - preflight: 'off', - client, + + const first = await parse('parse', source, out); + expect(first).toMatchObject({ + ok: true, op: 'parse', type: 'pdf.pdfParse', format: 'pdf', taskId: 'task-1', irKey: 'fixture/task-1/ir.json', + irSchemaVersion: 'result.v3', reusedParse: false, artifact: out, warnings: [], }); - expect(second.reusedParse).toBe(true); - expect(second.engine).toBe('artifact-cache'); - expect(second.taskId).toBeNull(); - expect(client.parseCalls).toBe(1); // the whole point - - // --force redoes the work - const forced = await runParse({ - input: await resolveInput(source), - inputLabel: source, - out, - flags: {}, - common: cloudCommon({ force: true }), - preflight: 'off', - client, + expect(Date.parse(first.expiresAt) - Date.now()).toBeGreaterThan(6.9 * 24 * 3600 * 1000); + expect(first.outputs.map((output) => path.basename(output.file))).toEqual(['ir.json', 'manifest.json']); + expect(readJson(path.join(out, 'ir.json'))).toEqual({ + irKey: 'fixture/task-1/ir.json', irSchemaVersion: 'result.v3', + document: { schemaVersion: 'result.v3', elements: [{ text: 'Hello browser' }] }, images: [], }); - expect(forced.reusedParse).toBe(false); - expect(client.parseCalls).toBe(2); - - // different params = different request = no hit - await runParse({ - input: await resolveInput(source), - inputLabel: source, - out, - flags: { profile: 'quality' }, - common: cloudCommon(), - preflight: 'off', - client, + expect(readJson(path.join(out, 'manifest.json'))).toMatchObject({ + manifestVersion: 3, + source: { name: 'doc.pdf', bytes: 19, sha256: expect.stringMatching(/^[0-9a-f]{64}$/) }, + parse: { taskId: 'task-1', type: 'pdf.pdfParse', irKey: 'fixture/task-1/ir.json', params: { parseProfile: 'balanced', includeImages: true } }, + views: {}, assets: {}, producer: { deckops: expect.any(String) }, }); - expect(client.parseCalls).toBe(3); - }); + // Views and images belong to convert; a finished parse leaves no submission log. + for (const name of ['views', 'assets', 'submission.json']) expect(fs.existsSync(path.join(out, name))).toBe(false); - it('resumes the task an interrupted run submitted instead of submitting again', async () => { - const dir = tmp(); - const source = writeSource(dir); - const out = path.join(dir, 'artifact'); - // 第一次:任务建好了,取结果时断网(实测 read ECONNRESET)。 - const client = fakeClient({ - parse: vi.fn(async (_source, options) => { - client.parseCalls += 1; - options?.onTask?.({ id: 't_parse_1', spaceId: 'space_1' } as never); - throw new DeckOpsError('backend_error', 'Cannot reach the DeckFlow API (GET https://x/v1/tools/tasks/t_parse_1/download): read ECONNRESET'); - }) as never, - resume: vi.fn(async () => parseResult()) as never, - }); - const run = async () => runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), preflight: 'off', client }); - await expect(run()).rejects.toThrow('ECONNRESET'); - const journal = JSON.parse(fs.readFileSync(path.join(out, 'upgrade.json'), 'utf8')); - expect(journal).toMatchObject({ status: 'submitted', taskId: 't_parse_1', spaceId: 'space_1' }); - - // 第二次:接着等同一个任务,不再上传、不再建任务。 - const second = await run(); - expect(client.parseCalls).toBe(1); - expect(client.resume).toHaveBeenCalledWith('t_parse_1', 'pdf.pdfParse', expect.objectContaining({ spaceId: 'space_1' })); - expect(second.engine).toBe('cloud'); - expect(second.warnings.join('\n')).toContain('Resumed cloud task t_parse_1'); - expect(JSON.parse(fs.readFileSync(path.join(out, 'upgrade.json'), 'utf8')).status).toBe('completed'); + const second = await parse('parse', source, out); + expect(second).toMatchObject({ reusedParse: true, taskId: 'task-1', irKey: 'fixture/task-1/ir.json' }); + expect(created('parse')).toHaveLength(1); + + await parse('parse', source, out, { common: { force: true } }); + await parse('parse', source, out, { flags: { profile: 'quality' } }); + expect(created('parse')).toHaveLength(3); + expect(cloud.state('parse').uploads).toHaveLength(0); // small sources travel inline with the task }); - it('gives up on a resumed task the cloud reports failed, so the next run submits anew', async () => { - const dir = tmp(); - const source = writeSource(dir); - const out = path.join(dir, 'artifact'); - fs.mkdirSync(out, { recursive: true }); - const client = fakeClient({ resume: vi.fn(async () => { throw new Error('Task failed: worker crashed'); }) as never }); - const run = async () => runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), preflight: 'off', client }); - // 先让一次正常运行写出 sourceHash 与 params,再把日志改成「已提交」。 - await run(); - const journalPath = path.join(out, 'upgrade.json'); - const journal = JSON.parse(fs.readFileSync(journalPath, 'utf8')); - fs.writeFileSync(journalPath, JSON.stringify({ ...journal, status: 'submitted', taskId: 't_gone' })); - fs.rmSync(path.join(out, 'manifest.json')); - fs.rmSync(path.join(out, 'candidates'), { recursive: true, force: true }); - - await expect(run()).rejects.toMatchObject({ taskId: 't_gone', hint: expect.stringContaining('submit a new task') }); - expect(JSON.parse(fs.readFileSync(journalPath, 'utf8')).status).toBe('failed'); - const parseCalls = client.parseCalls; - await run(); - expect(client.parseCalls).toBe(parseCalls + 1); + it('never reuses a URL parse by identity alone', async () => { + const out = path.join(tmp(), 'page'); + await parse('url', 'https://example.com/article', out); + const again = await parse('url', 'https://example.com/article', out); + expect(again).toMatchObject({ reusedParse: false, type: 'html.getByURL', format: 'html' }); + expect(created('url')).toHaveLength(2); }); - it('refuses to parse an artifact, pointing at convert', async () => { + it('refuses to parse an artifact and to read a DeckOps 2.x artifact', async () => { const dir = tmp(); - const source = writeSource(dir); const out = path.join(dir, 'artifact'); - const client = fakeClient(); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), preflight: 'off', client }); + await parse('refuse', writeSource(dir), out); + await expect(parse('refuse', out, out)).rejects.toMatchObject({ code: 'usage_error' }); - await expect( - runParse({ input: await resolveInput(out), inputLabel: out, out, flags: {}, common: cloudCommon(), client }) - ).rejects.toMatchObject({ code: 'usage_error' }); + const legacy = path.join(dir, 'legacy'); + fs.mkdirSync(legacy); + fs.writeFileSync(path.join(legacy, 'manifest.json'), JSON.stringify({ manifestVersion: 2 })); + await expect(convert('refuse', legacy)).rejects.toMatchObject({ code: 'input_error', hint: expect.stringContaining('--force') }); }); +}); - it('runs DeckProbe before cloud parse, persists probe.json, and reuses it with the artifact', async () => { +describe('resume', () => { + it('waits for the task an interrupted run submitted instead of submitting again', async () => { const dir = tmp(); const source = writeSource(dir); const out = path.join(dir, 'artifact'); - const client = fakeClient(); - const inspector = fakeInspector(); + cloud.state('resume').failDownloads = 1; - const first = await runParse({ - input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), client, - inspector, - }); - expect(inspector.inspect).toHaveBeenCalledTimes(1); - expect(first.inspection).toMatchObject({ profile: 'pdf', pageCount: 3, hasExternalRelationships: true }); - expect(first.warnings).toContainEqual(expect.stringContaining('external relationships')); - expect(JSON.parse(fs.readFileSync(path.join(out, 'probe.json'), 'utf-8'))).toMatchObject({ schema_version: 2 }); - expect(JSON.parse(fs.readFileSync(path.join(out, 'manifest.json'), 'utf-8'))).toMatchObject({ - inspection: { file: 'probe.json', schemaVersion: 2, requestVersion: 2, summary: { pageCount: 3 } }, + await expect(parse('resume', source, out)).rejects.toMatchObject({ code: 'backend_error' }); + expect(readJson(path.join(out, 'submission.json'))).toMatchObject({ + status: 'submitted', taskId: 'task-1', spaceId: 'fixture-space', sourceHash: expect.any(String), params: expect.any(Object), }); + expect(fs.existsSync(path.join(out, 'manifest.json'))).toBe(false); - const second = await runParse({ - input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), client, - preflight: 'strict', inspector, - }); - expect(second.reusedParse).toBe(true); - expect(inspector.inspect).toHaveBeenCalledTimes(1); - expect(client.parseCalls).toBe(1); + const second = await parse('resume', source, out); + expect(second).toMatchObject({ reusedParse: false, taskId: 'task-1' }); + expect(second.warnings.join('\n')).toContain('Resumed cloud task task-1'); + expect(created('resume')).toHaveLength(1); // nothing uploaded or billed again + expect(fs.existsSync(path.join(out, 'submission.json'))).toBe(false); }); - it('rejects a malformed DeckProbe report before any cloud task', async () => { + it('forgets a task that cannot be resumed, so the next run submits anew', async () => { const dir = tmp(); const source = writeSource(dir); - const client = fakeClient(); - const inspector = fakeInspector({ - schema_version: 2, tool_version: '2.4.0', status: 'error', - error: { code: 'MALFORMED_INPUT', message: 'wrong container', exit_code: 4 }, - }); - await expect(runParse({ - input: await resolveInput(source), inputLabel: source, flags: {}, common: cloudCommon(), client, - preflight: 'validate', inspector, - })).rejects.toMatchObject({ code: 'input_error' }); - expect(client.parseCalls).toBe(0); - }); + const out = path.join(dir, 'artifact'); + cloud.state('gone').failDownloads = 1; + await expect(parse('gone', source, out)).rejects.toMatchObject({ code: 'backend_error' }); + const journal = path.join(out, 'submission.json'); + fs.writeFileSync(journal, JSON.stringify({ ...readJson(journal), taskId: 'task-missing' })); - it('skips URL input under the default validate policy without invoking the inspector', async () => { - const dir = tmp(); - const client = fakeClient(); - const inspector = fakeInspector(); - const envelope = await runParse({ - input: { kind: 'link', url: 'https://example.com/article' }, - inputLabel: 'https://example.com/article', - out: path.join(dir, 'artifact'), - flags: {}, common: cloudCommon(), client, inspector, + await expect(parse('gone', source, out)).rejects.toMatchObject({ + code: 'backend_error', taskId: 'task-missing', hint: expect.stringContaining('submit a new task'), }); - expect(inspector.inspect).not.toHaveBeenCalled(); - expect(envelope.warnings).toContainEqual(expect.stringContaining('skipped for the URL input')); - expect(client.parseCalls).toBe(1); - }); + expect(fs.existsSync(journal)).toBe(false); - it('keeps off as an explicit local preflight escape hatch', async () => { - const dir = tmp(); - const source = writeSource(dir); - const inspector = fakeInspector(); - const envelope = await runParse({ - input: await resolveInput(source), inputLabel: source, out: path.join(dir, 'artifact'), - flags: {}, common: cloudCommon(), client: fakeClient(), preflight: 'off', inspector, - }); - expect(inspector.inspect).not.toHaveBeenCalled(); - expect(envelope.inspection).toBeUndefined(); - }); - - it.each([ - ['test.pdf', 'pdf', 'pdf'], - ['test.pptx', 'powerpoint', 'pptx'], - ['test.docx', 'word', 'docx'], - ['test.key', 'keynote', 'key'], - ] as const)('uses the packaged Node DeckProbe adapter on the real %s fixture', async (fixture, driver, profile) => { - const dir = tmp(); - const source = path.resolve('tests/generated', fixture); - const out = path.join(dir, 'artifact'); - const envelope = await runParse({ - input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), client: fakeClient(), - preflight: 'strict', - }); - expect(envelope.inspection).toMatchObject({ profile, encrypted: false }); - expect(envelope.inspection?.pageCount ?? envelope.inspection?.slideCount).toBeGreaterThan(0); - expect(JSON.parse(fs.readFileSync(path.join(out, 'probe.json'), 'utf-8'))).toMatchObject({ - schema_version: 2, status: 'ok', driver: { id: driver, profile }, - }); + const third = await parse('gone', source, out); + expect(third).toMatchObject({ reusedParse: false, taskId: 'task-2' }); + expect(created('gone')).toHaveLength(2); }); }); describe('convert ', () => { - const setup = async () => { + const setup = async (name: string, file = 'doc.pdf') => { const dir = tmp(); - const source = writeSource(dir); const out = path.join(dir, 'artifact'); - const client = fakeClient(); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), preflight: 'off', client }); - return { dir, out, client }; + await parse(name, writeSource(dir, file), out); + return { dir, out }; }; - it('derives the view from the irKey without re-parsing, then reuses it', async () => { - const { out, client } = await setup(); - - const first = await runConvert({ - input: await resolveInput(out), - inputLabel: out, - flags: {}, - common: cloudCommon(), - preflight: 'off', - client, - }); - expect(first.reusedParse).toBe(true); // standard path never re-parses - expect(first.engine).toBe('cloud'); - expect(client.parseCalls).toBe(1); // still just the one parse - expect(client.convertCalls).toBe(1); - expect((client.convert as ReturnType).mock.calls[0]?.[0]).toEqual({ irKey: '2026-08/aa/ir.json' }); - - const body = fs.readFileSync(path.join(out, 'views/markdown/index.md'), 'utf-8'); - expect(body).toContain('../../assets/p1_i0000.png'); - expect(body).not.toContain('Expires='); // durability: no signed URLs on disk - - const second = await runConvert({ - input: await resolveInput(out), - inputLabel: out, - flags: {}, - common: cloudCommon(), - client, + it('localizes images into assets/, writes views/markdown/, then reuses the view', async () => { + const { dir, out } = await setup('convert'); + const first = await convert('convert', out); + expect(first).toMatchObject({ ok: true, op: 'convert', to: 'markdown', format: 'pdf', taskId: 'task-2', parseTaskId: 'task-1', reusedParse: true }); + expect(created('convert').map((task) => task.type)).toEqual(['pdf.pdfParse', 'parse.convert']); + expect(created('convert')[1]?.params).toEqual({ irKey: 'fixture/task-1/ir.json', to: 'markdown' }); + + const body = fs.readFileSync(path.join(out, 'views/markdown/index.md'), 'utf8'); + expect(body).toContain('![](../../assets/image.png)'); + expect(body).not.toContain('expires='); // no signed URLs on disk + expect(fs.readFileSync(path.join(out, 'assets/image.png'))).toEqual(FIXTURE_PNG); + expect(readJson(path.join(out, 'manifest.json'))).toMatchObject({ + views: { markdown: { taskId: 'task-2', schemaVersion: 'result.v3', params: {}, files: ['views/markdown/index.md'] } }, + assets: { 'assets/image.png': { key: 'fixture/image.png', bytes: FIXTURE_PNG.length, mediaType: 'image/png', hash: expect.any(String) } }, }); - expect(second.engine).toBe('artifact-cache'); - expect(client.convertCalls).toBe(1); + + const cached = await convert('convert', out); + expect(cached).toMatchObject({ taskId: null, parseTaskId: 'task-1', reusedParse: true }); + expect(created('convert')).toHaveLength(2); + + // Different view options or an explicit output render again from the same irKey. + await convert('convert', out, { flags: { anchors: true } }); + const portable = await convert('convert', out, { out: path.join(dir, 'export.md'), flags: { anchors: true } }); + expect(created('convert').map((task) => task.type)).toEqual(['pdf.pdfParse', 'parse.convert', 'parse.convert', 'parse.convert']); + expect(fs.readFileSync(path.join(dir, 'export.md'), 'utf8')).toContain('![](export.assets/image.png)'); + expect(portable.outputs.map((output) => path.relative(dir, output.file))).toEqual(expect.arrayContaining(['export.md', 'export.assets/image.png'])); }); - it('fails fast locally when the IR is past its 7-day retention', async () => { - const { out, client } = await setup(); - const manifestPath = path.join(out, 'manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')); - manifest.parse.createdAt = new Date(Date.now() - 8 * 24 * 3600 * 1000).toISOString(); - fs.writeFileSync(manifestPath, JSON.stringify(manifest)); - - await expect( - runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: cloudCommon(), client }) - ).rejects.toMatchObject({ code: 'ir_expired' }); - expect(client.convertCalls).toBe(0); // saved the round trip + it('writes one file per page with --split-pages', async () => { + const { out } = await setup('split', 'slides.pptx'); + await convert('split', out, { flags: { splitPages: true } }); + expect(fs.readdirSync(path.join(out, 'views/markdown')).sort()).toEqual(['001.md', '002.md', 'index.md']); + expect(readJson(path.join(out, 'manifest.json')).views.markdown.files).toEqual([ + 'views/markdown/index.md', 'views/markdown/001.md', 'views/markdown/002.md', + ]); }); - it('treats markdownError as failure, not as content', async () => { - const { out, client } = await setup(); - (client.convert as ReturnType).mockImplementationOnce(async () => - convertResult({ markdown: '', markdownError: 'renderer exploded' }) - ); - await expect( - runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: cloudCommon(), client }) - ).rejects.toMatchObject({ code: 'backend_error' }); + it('treats markdownError as failure and a dead image as asset_error unless --keep-remote-images', async () => { + const bad = await setup('bad-markdown'); + await expect(convert('bad-markdown', bad.out)).rejects.toMatchObject({ code: 'backend_error' }); + + const dead = await setup('dead-image'); + await expect(convert('dead-image', dead.out)).rejects.toMatchObject({ code: 'asset_error' }); + const tolerated = await convert('dead-image', dead.out, { flags: { keepRemoteImages: true } }); + expect(tolerated.warnings).toHaveLength(1); + expect(fs.readFileSync(path.join(dead.out, 'views/markdown/index.md'), 'utf8')).toContain('image.png?expires=fixture'); }); }); -describe('convert (one-shot)', () => { - it('chains parse + convert, writes a portable copy, leaves no artifact', async () => { +describe('retention', () => { + const expire = (out: string): void => { + const file = path.join(out, 'manifest.json'); + const manifest = readJson(file); + manifest.parse.expiresAt = new Date(Date.now() - 60_000).toISOString(); + fs.writeFileSync(file, JSON.stringify(manifest)); + }; + + it('fails convert locally with ir_expired, and re-parses an expired hit with a warning', async () => { const dir = tmp(); const source = writeSource(dir); - const client = fakeClient(); - const target = path.join(dir, 'doc.md'); - - const envelope = await runConvert({ - input: await resolveInput(source), - inputLabel: source, - out: target, - flags: {}, - common: cloudCommon(), - preflight: 'off', - client, - }); - expect(envelope.reusedParse).toBe(false); - expect(envelope.parseTaskId).toBe('t_parse_1'); - expect(envelope.taskId).toBe('t_convert_1'); - - const body = fs.readFileSync(target, 'utf-8'); - expect(body).toContain('doc.assets/p1_i0000.png'); - expect(fs.readFileSync(path.join(dir, 'doc.assets/p1_i0000.png'))).toEqual(png); - // one-shot leaves no artifact behind - expect(fs.existsSync(path.join(dir, 'doc', 'manifest.json'))).toBe(false); + const out = path.join(dir, 'artifact'); + await parse('expired-ir', source, out); + expire(out); + + await expect(convert('expired-ir', out)).rejects.toMatchObject({ code: 'ir_expired', hint: expect.stringContaining('--force') }); + expect(created('expired-ir')).toHaveLength(1); // saved the round trip + + const again = await parse('expired-ir', source, out); + expect(again).toMatchObject({ reusedParse: false, taskId: 'task-2' }); + expect(again.warnings.join('\n')).toContain('expired'); + expect(Date.parse(again.expiresAt)).toBeGreaterThan(Date.now()); }); +}); - it('rejects --to anything but markdown', async () => { +describe('convert (one-shot)', () => { + it('chains parse + convert, writes a portable copy, and leaves no artifact', async () => { const dir = tmp(); const source = writeSource(dir); - await expect( - runConvert({ - input: await resolveInput(source), - inputLabel: source, - flags: { to: 'html' as never }, - common: cloudCommon(), - client: fakeClient(), - }) - ).rejects.toMatchObject({ code: 'unsupported' }); + const envelope = await convert('oneshot', source, { out: path.join(dir, 'doc.md') }); + expect(envelope).toMatchObject({ reusedParse: false, parseTaskId: 'task-1', taskId: 'task-2', format: 'pdf' }); + expect(fs.readFileSync(path.join(dir, 'doc.md'), 'utf8')).toContain('![](doc.assets/image.png)'); + expect(fs.readFileSync(path.join(dir, 'doc.assets/image.png'))).toEqual(FIXTURE_PNG); + expect(fs.existsSync(path.join(dir, 'doc', 'manifest.json'))).toBe(false); }); - it('preflights a one-shot source and exposes the local facts without writing an artifact', async () => { - const dir = tmp(); - const source = writeSource(dir); - const inspector = fakeInspector(); - const envelope = await runConvert({ - input: await resolveInput(source), inputLabel: source, out: path.join(dir, 'portable.md'), - flags: {}, parseFlags: {}, common: cloudCommon(), client: fakeClient(), inspector, - }); - expect(envelope.inspection).toMatchObject({ profile: 'pdf', pageCount: 3 }); - expect(envelope.warnings).toContainEqual(expect.stringContaining('external relationships')); - expect(inspector.inspect).toHaveBeenCalledTimes(1); - expect(fs.existsSync(path.join(dir, 'probe.json'))).toBe(false); + it('rejects --to anything but markdown before any request', async () => { + const source = writeSource(tmp()); + await expect(convert('to-html', source, { flags: { to: 'html' } })).rejects.toMatchObject({ code: 'unsupported' }); + expect(cloud.state('to-html').requests).toHaveLength(0); }); }); -describe('asset failure policy', () => { - it('fails the convert when an image cannot be fetched, unless keepRemoteImages', async () => { +describe('read', () => { + const read = async (name: string, source: string, extra: Record = {}) => + runRead({ input: await resolveInput(source), inputLabel: source, flags: {}, common: {}, client: clientFor(name), ...extra }); + + it('parses into the cache, converts, and hands back Markdown with escaped local image links', async () => { const dir = tmp(); const source = writeSource(dir); - const out = path.join(dir, 'artifact'); - const deadUrl = `http://127.0.0.1:${port}/gone`; - const client = fakeClient({ - convert: (async () => - convertResult({ - markdown: `![](${deadUrl})`, - images: [{ ref: deadUrl, key: 'k', suggestedPath: 'assets/gone.png', bytes: 1, hash: 'h' }], - })) as never, - }); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: cloudCommon(), preflight: 'off', client }); - - await expect( - runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: cloudCommon(), client }) - ).rejects.toMatchObject({ code: 'asset_error' }); - - const tolerated = await runConvert({ - input: await resolveInput(out), - inputLabel: out, - flags: { keepRemoteImages: true }, - common: cloudCommon(), - client, - }); - expect(tolerated.warnings.length).toBeGreaterThan(0); - expect(fs.readFileSync(path.join(out, 'views/markdown/index.md'), 'utf-8')).toContain(deadUrl); - }); -}); - -describe('library handle', () => { - it('exposes parse → handle → convert without re-parsing', async () => { - // Covered end-to-end by the CLI paths above; here just the error type surface. - expect(new DeckOpsError('ir_expired', 'x').exitCode).toBe(5); + const cacheDir = path.join(dir, 'cache with spaces'); + const first = await read('read', source, { cacheDir }); + expect(first).toMatchObject({ schemaVersion: 'deckops.read.v2', ok: true, format: 'markdown', outputs: [] }); + expect(first.report).toMatchObject({ schemaVersion: 'deckops.run.v2', parseTaskId: 'task-1', convertTaskId: 'task-2', cacheHit: false, warnings: [] }); + const escaped = path.join(first.report.artifactBase, 'assets').replaceAll(' ', '%20'); + expect(first.content).toContain(`![](${escaped}/image.png)`); + expect(first.assets).toEqual([{ path: path.join(first.report.artifactBase, 'assets/image.png'), lifecycle: 'cache', bytes: FIXTURE_PNG.length }]); + + const second = await read('read', source, { cacheDir }); + expect(second.report.cacheHit).toBe(true); + expect(created('read')).toHaveLength(2); // cached parse and cached view + + const ir = await read('read', source, { cacheDir, format: 'ir' }); + expect(ir.content).toMatchObject({ irKey: 'fixture/task-1/ir.json' }); + + const out = path.join(dir, 'out.md'); + const reportFile = path.join(dir, 'run.json'); + const written = await read('read', source, { cacheDir, out, reportFile }); + expect(written.content).toBeNull(); + expect(written.assets[0]).toMatchObject({ lifecycle: 'output' }); + expect(fs.readFileSync(out, 'utf8')).toMatch(/!\[\]\(out\.assets-[0-9a-f]{8}\/image\.png\)/); + expect(readJson(reportFile)).toMatchObject({ schemaVersion: 'deckops.run.v2', parseTaskId: 'task-1', cacheHit: true }); + await expect(read('read', source, { out: source })).rejects.toMatchObject({ code: 'usage_error' }); + await expect(read('read', source, { format: 'ir', out })).rejects.toMatchObject({ code: 'usage_error' }); }); }); diff --git a/tests/unit/input.test.ts b/tests/unit/input.test.ts index 30c9397..641f759 100644 --- a/tests/unit/input.test.ts +++ b/tests/unit/input.test.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { extensionOf, resolveInput, routeExtension } from '../../src/core/input.js'; +import { extensionOf, resolveInput, routeExtension } from '../../src/node/input.js'; import { DeckOpsError } from '../../src/errors/index.js'; const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-input-')); @@ -48,7 +48,7 @@ describe('resolveInput', () => { fs.mkdirSync(artifact); fs.writeFileSync( path.join(artifact, 'manifest.json'), - JSON.stringify({ manifestVersion: 1, parse: { irKey: 'k', type: 'pptx.parse', irSchemaVersion: 'pptx.v1', taskId: 't', params: {}, createdAt: new Date().toISOString() }, source: { name: 'a' }, views: {}, assets: {}, producer: { deckparse: '0', sdk: '0' } }) + JSON.stringify({ manifestVersion: 3, parse: { irKey: 'k', type: 'pptx.parse', irSchemaVersion: 'result.v3', taskId: 't', params: {}, createdAt: new Date().toISOString(), expiresAt: new Date().toISOString() }, source: { name: 'a', sha256: 'abc', bytes: 1 }, views: {}, assets: {}, producer: { deckops: '3.0.0' } }) ); expect((await resolveInput(artifact)).kind).toBe('artifact'); }); diff --git a/tests/unit/localize.test.ts b/tests/unit/localize.test.ts index 260a96d..2083b29 100644 --- a/tests/unit/localize.test.ts +++ b/tests/unit/localize.test.ts @@ -3,7 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { assignFileNames, localizeImages, rewriteLinks } from '../../src/assets/localize.js'; +import { assignFileNames, localizeImages, rewriteLinks } from '../../src/node/localize.js'; const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-assets-')); @@ -46,10 +46,19 @@ describe('localizeImages', () => { }); expect(fs.readFileSync(path.join(dir, 'one.png'))).toEqual(png); expect(result.rewrites.get('https://signed.example/img?sig=1')).toBe('../../assets/one.png'); - expect(result.saved['one.png']).toMatchObject({ key: '2026-08/aa/one.png', bytes: png.length }); + expect(result.saved['one.png']).toMatchObject({ key: '2026-08/aa/one.png', bytes: png.length, mediaType: 'image/png' }); expect(result.warnings).toEqual([]); }); + it('records the media type from the response header, falling back to the extension', async () => { + const typed = (async () => new Response(new Uint8Array(png), { headers: { 'Content-Type': 'image/webp; q=1' } })) as unknown as typeof fetch; + const result = await localizeImages({ images: [image()], destDir: tmp(), linkPrefix: 'a/', fetchImpl: typed }); + expect(result.saved['one.png']?.mediaType).toBe('image/webp'); + const untyped = (async () => new Response(new Uint8Array(png), { headers: { 'Content-Type': 'application/octet-stream' } })) as unknown as typeof fetch; + const other = await localizeImages({ images: [image({ suggestedPath: 'assets/one.bin' })], destDir: tmp(), linkPrefix: 'a/', fetchImpl: untyped }); + expect(other.saved['one.bin']).not.toHaveProperty('mediaType'); + }); + it('a corrupted download fails the run — a wrong image is worse than a missing one', async () => { await expect( localizeImages({ diff --git a/tests/unit/manifest.test.ts b/tests/unit/manifest.test.ts index c8b050c..c26c3b1 100644 --- a/tests/unit/manifest.test.ts +++ b/tests/unit/manifest.test.ts @@ -4,41 +4,45 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; import { IR_RETENTION_MS, - locallyExpired, + expired, normalizeParseParams, parseHit, readManifest, sameParams, viewHit, writeManifest, -} from '../../src/artifact/manifest.js'; +} from '../../src/node/artifact.js'; import type { Manifest } from '../../src/types.js'; const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-manifest-')); -const manifest = (overrides: Partial = {}): Manifest => ({ - manifestVersion: 1, - source: { name: 'a.pdf', sha256: 'abc' }, - parse: { - taskId: 't1', - type: 'pdf.pdfParse', - irKey: '2026-08/aa/ir.json', - irSchemaVersion: 'result.v3', - params: normalizeParseParams({}), - createdAt: new Date().toISOString(), - ...overrides, - }, - views: {}, - assets: {}, - producer: { deckparse: '0.1.0', sdk: '0.8.0-next.0' }, -}); +const manifest = (overrides: Partial = {}): Manifest => { + const createdAt = new Date(); + return { + manifestVersion: 3, + source: { name: 'a.pdf', sha256: 'abc', bytes: 3 }, + parse: { + taskId: 't1', + type: 'pdf.pdfParse', + irKey: '2026-10/aa/ir.json', + irSchemaVersion: 'result.v3', + params: normalizeParseParams({}), + createdAt: createdAt.toISOString(), + expiresAt: new Date(createdAt.getTime() + IR_RETENTION_MS).toISOString(), + ...overrides, + }, + views: {}, + assets: {}, + producer: { deckops: '3.0.0' }, + }; +}; describe('param normalization', () => { it('defaults and explicit defaults are the same request', () => { expect(sameParams({}, { parseProfile: 'balanced', includeImages: true })).toBe(true); expect(sameParams({}, { parseProfile: 'quality' })).toBe(false); expect(sameParams({ password: 'x' }, { password: 'x' })).toBe(true); - expect(sameParams({ limits: { sourceBytes: 1, assetBytes: 2 } }, { limits: { assetBytes: 2, sourceBytes: 1 } })).toBe(true); + expect(normalizeParseParams({ password: 'secret' })).toEqual({ includeImages: true, parseProfile: 'balanced', passwordProvided: true }); }); }); @@ -51,12 +55,12 @@ describe('hit detection', () => { expect(parseHit(dir, m, 'abc', {})).toBe(true); expect(parseHit(dir, m, 'other', {})).toBe(false); expect(parseHit(dir, m, 'abc', { parseProfile: 'quality' })).toBe(false); - expect(parseHit(dir, m, undefined, {})).toBe(false); // links never hit + expect(parseHit(dir, m, undefined, {})).toBe(false); }); it('view hit needs same params and every registered file present', () => { const dir = tmp(); - const view = { taskId: 't2', params: { splitPages: true }, files: ['views/markdown/index.md'], createdAt: 'x' }; + const view = { taskId: 't2', schemaVersion: 'result.v3', params: { splitPages: true }, files: ['views/markdown/index.md'], createdAt: 'x' }; expect(viewHit(dir, view, { splitPages: true })).toBe(false); // file missing fs.mkdirSync(path.join(dir, 'views/markdown'), { recursive: true }); fs.writeFileSync(path.join(dir, 'views/markdown/index.md'), '# hi'); @@ -66,12 +70,11 @@ describe('hit detection', () => { }); }); -describe('retention fast-fail', () => { - it('flags an IR older than 7 days, tolerates a fresh one', () => { - const fresh = manifest(); - expect(locallyExpired(fresh)).toBe(false); - const stale = manifest({ createdAt: new Date(Date.now() - IR_RETENTION_MS - 60_000).toISOString() }); - expect(locallyExpired(stale)).toBe(true); +describe('retention', () => { + it('reads expiry from expiresAt, recorded as createdAt + 7 days', () => { + expect(expired(manifest())).toBe(false); + expect(expired(manifest({ expiresAt: new Date(Date.now() - 60_000).toISOString() }))).toBe(true); + expect(expired(manifest({ expiresAt: 'not a date' }))).toBe(true); }); }); @@ -89,4 +92,20 @@ describe('read / write', () => { const empty = tmp(); await expect(readManifest(empty)).rejects.toMatchObject({ code: 'input_error' }); }); + + it.each([1, 2])('refuses a DeckOps 2.x manifest v%i and says how to recreate it', async (version) => { + const dir = tmp(); + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ ...manifest(), manifestVersion: version })); + await expect(readManifest(dir)).rejects.toMatchObject({ + code: 'input_error', + message: expect.stringContaining('DeckOps 2.x'), + hint: expect.stringContaining('deckops parse --force'), + }); + }); + + it('rejects registered paths that escape the artifact', async () => { + const dir = tmp(); + await writeManifest(dir, { ...manifest(), assets: { '../outside.png': { key: 'k', hash: 'h', bytes: 1 } } }); + await expect(readManifest(dir)).rejects.toMatchObject({ code: 'input_error', message: expect.stringContaining('unsafe path') }); + }); }); diff --git a/tests/unit/timeout.test.ts b/tests/unit/timeout.test.ts index 533d585..a213e81 100644 --- a/tests/unit/timeout.test.ts +++ b/tests/unit/timeout.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { withinOperationTimeout } from '../../src/core/timeout.js'; +import { withinOperationTimeout } from '../../src/node/timeout.js'; /** 一个只在信号中止时才结束的操作,像一次连不上的请求。 */ const waitForAbort = (signal: AbortSignal): Promise => new Promise((_, reject) => { From b967cf40e7cc93fa71a48b4e5b896c7cf28d67d7 Mon Sep 17 00:00:00 2001 From: Garfield Date: Mon, 19 Oct 2026 15:48:24 +0800 Subject: [PATCH 3/9] refactor(cli): drive the CLI through the Node operation layer with the 3.0 options read, parse and convert now run src/node/ over the isomorphic client with shared credentials. --engine, --allow-upload, --fail-on-degraded, --preflight, the local --max-* limits, --worker-heap-mb and the PDF/DOCX local-only parse flags are gone and fail as unknown options (usage_error); the capabilities and config migrate commands and the DECKOPS_ENGINE / DECKOPS_ALLOW_UPLOAD / DECKOPS_FAIL_ON_DEGRADED variables are removed. Product configuration keeps only timeout. formats prints the cloud matrix. read emits deckops.read.v2 and writes deckops.run.v2 reports. check-read runs the built CLI against the fake cloud; conformance targets a real backend with credentials only. Co-Authored-By: Claude Fable 5.1 --- scripts/check-read.mjs | 102 +++++++++++++--------- scripts/conformance.mjs | 65 ++++++-------- src/cli.ts | 49 ++--------- src/cli/commands/aux.ts | 18 ++-- src/cli/commands/run-ops.ts | 158 +++++++++-------------------------- src/cli/output.ts | 30 ++----- src/config/migrate.ts | 77 ----------------- src/config/product.ts | 22 ++--- src/shared/validation.ts | 3 - src/types.ts | 3 - tests/unit/migration.test.ts | 82 ------------------ tests/unit/shared.test.ts | 36 ++------ 12 files changed, 163 insertions(+), 482 deletions(-) delete mode 100644 src/config/migrate.ts delete mode 100644 tests/unit/migration.test.ts diff --git a/scripts/check-read.mjs b/scripts/check-read.mjs index d1516bb..ec94bd8 100644 --- a/scripts/check-read.mjs +++ b/scripts/check-read.mjs @@ -1,48 +1,70 @@ import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; +import { execFile } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { zipSync, strToU8 } from 'fflate'; +import { createFakeCloud, FIXTURE_PNG } from '../tests/browser/fake-cloud.mjs'; + +/** The built CLI's content contract over real HTTP against the wire-protocol fixture. */ const root = path.resolve(import.meta.dirname, '..'); const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-read-cli-')); const config = path.join(temp, 'config'); fs.mkdirSync(config); -const env = { ...process.env, DECKOPS_CACHE_DIR: path.join(temp, 'cache with spaces'), DECKOPS_CONFIG_DIR: config, DECKOPS_ENGINE: '', DECKOPS_ALLOW_UPLOAD: '', DECKOPS_FAIL_ON_DEGRADED: '' }; -delete env.DECKOPS_ENGINE; -const run = (args, success = true) => { const result = spawnSync(process.execPath, [path.join(root, 'dist/cli.js'), ...args], { cwd: root, env, encoding: 'utf8', timeout: 15000 }); if (success) assert.equal(result.status, 0, result.stderr || result.stdout); else assert.notEqual(result.status, 0); return result; }; +const cloud = await createFakeCloud(); +const env = { + ...process.env, DECKOPS_CACHE_DIR: path.join(temp, 'cache with spaces'), DECKOPS_CONFIG_DIR: config, DECKFLOW_CONFIG_DIR: config, + DECKOPS_API_BASE: cloud.apiBase('read'), DECKOPS_TOKEN: 'test-token', +}; +for (const name of ['DECKOPS_API_KEY', 'DECKFLOW_API_KEY', 'DECKFLOW_TOKEN', 'DECKOPS_SPACE_ID', 'DECKFLOW_SPACE_ID', 'DECKFLOW_API_BASE']) delete env[name]; +// Async: the fixture answers from this process, so a synchronous spawn would deadlock. +const run = (args, success = true) => new Promise((resolve) => { + execFile(process.execPath, [path.join(root, 'dist/cli.js'), ...args], { cwd: root, env, encoding: 'utf8', timeout: 15000 }, (error, stdout, stderr) => { + const status = error ? error.code : 0; + if (success) assert.equal(status, 0, stderr || stdout); else assert.notEqual(status, 0, stdout); + resolve({ status, stdout, stderr }); + }); +}); +const json = async (args, success = true) => JSON.parse((await run(args, success)).stdout); try { - const file = path.join(root, 'tests/generated/test.docx'); - const expected = 'DeckOps deterministic migration document fixture\n'; - assert.equal(run([file]).stdout, expected); - assert.equal(run(['--engine', 'local', file, '--quiet']).stdout, expected); - const json = JSON.parse(run([file, '--json']).stdout); assert.equal(json.schemaVersion, 'deckops.read.v1'); assert.equal(json.content, expected); - const ir = JSON.parse(run([file, '--format', 'ir']).stdout); assert.equal(ir.schemaVersion, 'deckir.v1'); - fs.writeFileSync(path.join(config, 'config.json'), JSON.stringify({ engine: 'auto', allowUpload: true, failOnDegraded: true })); - assert.equal(JSON.parse(run([file, '--json']).stdout).report.decision.policy.upload, 'allow'); - assert.equal(JSON.parse(run([file, '--engine', 'local', '--json']).stdout).report.decision.policy.upload, 'deny'); - const denied = JSON.parse(run([file, '--no-allow-upload', '--no-fail-on-degraded', '--json']).stdout); - assert.equal(denied.report.decision.policy.upload, 'deny'); assert.equal(denied.report.decision.policy.acceptance, 'best-effort'); - assert.equal(JSON.parse(run([file, '--output', file, '--json'], false).stdout).schemaVersion, 'deckops.read.v1'); - fs.writeFileSync(path.join(config, 'config.json'), '{}'); - assert.equal(run(['convert', file, '-o', '-']).stdout, expected); - const converted = JSON.parse(run(['convert', file, '-o', '-', '--json']).stdout); assert.equal(converted.content, expected); - const out = path.join(temp, 'out.md'), report = path.join(temp, 'run.json'); - assert.equal(run([file, '-o', out, '--report', report]).stdout, ''); assert.equal(fs.readFileSync(out, 'utf8'), expected); - assert.equal(JSON.parse(fs.readFileSync(report, 'utf8')).schemaVersion, 'deckops.run.v1'); - // Minimal document with an embedded image tests actual Worker output and cache link lifetime. - const imageDoc = path.join(temp, 'image.docx'); - const parts = { - '[Content_Types].xml': '', - 'word/document.xml': 'Picture example', - 'word/_rels/document.xml.rels': '', - }; - fs.writeFileSync(imageDoc, zipSync({ ...Object.fromEntries(Object.entries(parts).map(([k,v]) => [k,strToU8(v)])), 'word/media/a.png': Buffer.from('89504e470d0a1a0a','hex') })); - const picture = JSON.parse(run([imageDoc, '--preflight', 'off', '--json']).stdout); - assert.ok(picture.assets.length); assert.ok(picture.content.includes('%20')); for(const asset of picture.assets) assert.ok(fs.existsSync(asset.path)); - const imageOut = path.join(temp, 'portable.md'); const written = JSON.parse(run([imageDoc, '-o', imageOut, '--preflight', 'off', '--json']).stdout); - for(const asset of written.assets) { assert.equal(asset.lifecycle, 'output'); assert.ok(fs.existsSync(asset.path)); } - assert.ok(fs.readFileSync(imageOut, 'utf8').includes('portable.assets-')); - const pdf = JSON.parse(run([path.join(root, 'tests/generated/test.pdf'), '--json']).stdout); assert.equal(pdf.report.selected.parseEngine, 'local'); - assert.equal(JSON.parse(run(['capabilities', '--json']).stdout).schemaVersion, 'deckops.capabilities.v1'); - console.log('Built CLI content contract passed: stdout/JSON, config precedence, IR, images, PDF Worker, portable output and legacy convert.'); -} finally { fs.rmSync(temp, { recursive: true, force: true }); } + const file = path.join(root, 'tests/generated/test.pdf'); + const page = (prefix) => `# Page one\n\n![](${prefix}image.png)\n\n---\n\n# Page two`; + + const first = await json([file, '--json']); + assert.equal(first.schemaVersion, 'deckops.read.v2'); assert.equal(first.report.schemaVersion, 'deckops.run.v2'); + const cached = path.join(first.report.artifactBase, 'assets').replaceAll(' ', '%20') + '/'; + assert.equal(first.content, page(cached)); assert.ok(first.content.includes('%20')); + for (const asset of first.assets) { assert.equal(asset.lifecycle, 'cache'); assert.deepEqual(fs.readFileSync(asset.path), FIXTURE_PNG); } + assert.equal((await run([file])).stdout, page(cached)); + assert.equal((await run([file, '--quiet'])).stdout, page(cached)); + assert.equal(JSON.parse((await run([file, '--format', 'ir'])).stdout).irKey, 'fixture/task-1/ir.json'); + assert.equal(cloud.state('read').tasks.length, 2, 'repeat reads reuse the cached parse and view'); + + const exported = path.join(temp, 'portable.md'), report = path.join(temp, 'run.json'); + assert.equal((await run([file, '-o', exported, '--report', report])).stdout, ''); + assert.match(fs.readFileSync(exported, 'utf8'), /!\[\]\(portable\.assets-[0-9a-f]{8}\/image\.png\)/); + assert.equal(JSON.parse(fs.readFileSync(report, 'utf8')).schemaVersion, 'deckops.run.v2'); + const written = await json([file, '-o', exported, '--json']); + for (const asset of written.assets) { assert.equal(asset.lifecycle, 'output'); assert.ok(fs.existsSync(asset.path)); } + + assert.ok((await run(['convert', file, '-o', '-'])).stdout.startsWith('# Page one\n\n![](')); + assert.ok((await json(['convert', file, '-o', '-', '--json'])).content.startsWith('# Page one')); + const artifact = path.join(temp, 'artifact'); + const parsed = await json(['parse', file, '-o', artifact, '--json']); + assert.equal(parsed.op, 'parse'); assert.equal(parsed.reusedParse, false); assert.equal(parsed.format, 'pdf'); + assert.equal((await json(['parse', file, '-o', artifact, '--json'])).reusedParse, true); + const view = await json(['convert', artifact, '--json']); + assert.equal(view.parseTaskId, parsed.taskId); assert.match(fs.readFileSync(path.join(artifact, 'views/markdown/index.md'), 'utf8'), /\.\.\/\.\.\/assets\/image\.png/); + assert.equal((await json(['convert', artifact, '--json'])).taskId, null); + + // Removed 2.x surface fails as a usage error; the error envelope keeps its shape. + for (const args of [[file, '--engine', 'cloud'], [file, '--preflight', 'off'], [file, '--allow-upload'], ['parse', file, '--tracked-changes', 'all'], ['config', 'migrate']]) { + assert.equal((await run(args, false)).status, 2, args.join(' ')); + } + assert.equal((await run(['--timeout', '0', file], false)).status, 2); + const collision = await json([file, '--output', file, '--json'], false); + assert.equal(collision.schemaVersion, 'deckops.read.v2'); assert.equal(collision.error.code, 'usage_error'); + assert.equal((await json(['formats', '--json'])).length, 6); + console.log('Built CLI content contract passed: stdout/JSON, IR, cached and exported images, reports, parse/convert artifacts and removed options.'); +} finally { + await cloud.close(); + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/scripts/conformance.mjs b/scripts/conformance.mjs index 5566a85..bdfd38f 100644 --- a/scripts/conformance.mjs +++ b/scripts/conformance.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * Conformance: drive the built CLI through the local-first contract. Cloud - * checks are additive when credentials are explicitly supplied. + * Conformance: drive the built CLI against a real DeckOps Cloud backend. Run it by + * hand before a release; it needs credentials and spends quota. * * Fixtures come from tests/test-data/test. (see its README; untracked, * bring your own). CONFORMANCE_ environment variables override, and @@ -9,8 +9,8 @@ * * DECKOPS_API_BASE=… DECKOPS_TOKEN=… node scripts/conformance.mjs * - * Uses an isolated DECKFLOW_CONFIG_DIR so inherited credentials from other - * DeckFlow tools cannot leak into the run. + * Uses an isolated DECKFLOW_CONFIG_DIR so only the credentials in the environment + * are used, never ones inherited from other DeckFlow tools. */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, cpSync, mkdirSync } from 'node:fs'; @@ -24,6 +24,10 @@ if (!existsSync(cli)) { console.error('Build first: pnpm build'); process.exit(1); } +if (!process.env.DECKOPS_TOKEN && !process.env.DECKOPS_API_KEY) { + console.error('Set DECKOPS_TOKEN or DECKOPS_API_KEY (and DECKOPS_API_BASE for a non-production backend).'); + process.exit(1); +} const work = mkdtempSync(path.join(tmpdir(), 'deckops-conformance-')); const configDir = path.join(work, 'config'); @@ -59,13 +63,8 @@ const sampleFor = (ext) => { return existsSync(fixture) ? fixture : undefined; }; -const samples = [ - ['pdf', sampleFor('pdf')], - ['pptx', sampleFor('pptx')], - ['docx', sampleFor('docx')], -]; - -for (const [format, sample] of samples) { +for (const format of ['pdf', 'pptx', 'docx', 'key']) { + const sample = sampleFor(format); if (!sample) { console.log(`- ${format}: no sample (set CONFORMANCE_${format.toUpperCase()}), skipped`); continue; @@ -74,55 +73,41 @@ for (const [format, sample] of samples) { cpSync(sample, local); const artifact = path.join(work, `artifact-${format}`); - check(`${format}: local parse produces permanent deckir.v1`, () => { + check(`${format}: parse stores the server IR with manifest v3`, () => { const envelope = runJson(['parse', local, '-o', artifact]); - if (!envelope.ok || envelope.engine !== 'local' || envelope.irKey !== undefined || envelope.irSchemaVersion !== 'deckir.v1') throw new Error(JSON.stringify(envelope)); - if (!existsSync(path.join(artifact, 'ir.json'))) throw new Error('no ir.json'); + if (!envelope.ok || envelope.reusedParse || !envelope.taskId || !envelope.irKey || !envelope.irSchemaVersion || !envelope.expiresAt) { + throw new Error(JSON.stringify(envelope)); + } const manifest = JSON.parse(readFileSync(path.join(artifact, 'manifest.json'), 'utf8')); - if (manifest.manifestVersion !== 2 || manifest.parse.remote !== null) throw new Error('not a local manifest v2'); + if (manifest.manifestVersion !== 3 || manifest.parse.irKey !== envelope.irKey) throw new Error('not a manifest v3'); + const ir = JSON.parse(readFileSync(path.join(artifact, 'ir.json'), 'utf8')); + if (ir.irKey !== envelope.irKey) throw new Error('ir.json is not the server response'); }); - check(`${format}: second parse reuses locally (zero cloud calls)`, () => { + check(`${format}: second parse reuses the artifact (zero cloud calls)`, () => { const envelope = runJson(['parse', local, '-o', artifact]); - if (!envelope.reusedParse || envelope.engine !== 'artifact-cache' || envelope.taskId !== null) { - throw new Error(JSON.stringify(envelope)); - } + if (!envelope.reusedParse) throw new Error(JSON.stringify(envelope)); }); check(`${format}: convert derives the view without re-parsing`, () => { const envelope = runJson(['convert', artifact]); - if (!envelope.ok || !envelope.reusedParse) throw new Error(JSON.stringify(envelope)); + if (!envelope.ok || !envelope.reusedParse || !envelope.taskId) throw new Error(JSON.stringify(envelope)); const view = readFileSync(path.join(artifact, 'views/markdown/index.md'), 'utf-8'); - if (/Expires=/.test(view)) throw new Error('view still contains expiring links'); + if (/Expires=/i.test(view)) throw new Error('view still contains expiring links'); }); - check(`${format}: second convert hits the local view`, () => { + check(`${format}: second convert hits the cached view`, () => { const envelope = runJson(['convert', artifact]); - if (envelope.engine !== 'artifact-cache') throw new Error(JSON.stringify(envelope)); + if (envelope.taskId !== null) throw new Error(JSON.stringify(envelope)); }); } -const keynote = sampleFor('key'); -if (keynote) { - check('key: local mode refuses private IWA instead of guessing', () => { - try { run(['parse', keynote, '--engine', 'local']); throw new Error('should have failed'); } - catch (error) { if (error.status !== 3) throw new Error(`exit ${error.status}, wanted 3`); } - }); - if (process.env.DECKOPS_TOKEN || process.env.DECKOPS_API_KEY) { - check('key: explicit cloud mode remains available', () => { - const artifact = path.join(work, 'artifact-key-cloud'); - const envelope = runJson(['parse', keynote, '--engine', 'cloud', '-o', artifact]); - if (!envelope.ok || envelope.engine !== 'cloud' || !envelope.irKey) throw new Error(JSON.stringify(envelope)); - }); - } -} - if (process.env.CONFORMANCE_URL) { check('url: one-shot convert produces durable markdown', () => { const target = path.join(work, 'page.md'); const envelope = runJson(['convert', process.env.CONFORMANCE_URL, '-o', target]); - if (!envelope.ok || envelope.engine !== 'local' || envelope.taskId !== null) throw new Error(JSON.stringify(envelope)); - if (/Expires=/.test(readFileSync(target, 'utf-8'))) throw new Error('markdown contains expiring links'); + if (!envelope.ok || !envelope.taskId || !envelope.parseTaskId) throw new Error(JSON.stringify(envelope)); + if (/Expires=/i.test(readFileSync(target, 'utf-8'))) throw new Error('markdown contains expiring links'); }); } diff --git a/src/cli.ts b/src/cli.ts index 9c34f37..26764db 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -12,12 +12,10 @@ import { runConvertCommand, runParseCommand, runReadCommand, type RawCliOptions import { runInstallCommand } from './cli/commands/install.js'; import { printError } from './cli/output.js'; import { DeckOpsError } from './errors/index.js'; -import { migrateConfig, type MigrationOptions } from './config/migrate.js'; -import { CAPABILITIES } from './engine/policy.js'; import { VERSION } from './version.js'; /** - * CLI assembly (docs/rfc.md §5.1). Bare invocation reads Markdown; explicit parse retains durable IR artifacts. + * CLI assembly. Bare invocation reads Markdown; explicit parse retains durable IR artifacts. * `extract` / `modify` / `export` are reserved so adding them later is not a * breaking change. */ @@ -26,7 +24,6 @@ const KNOWN_COMMANDS = new Set([ 'parse', 'convert', 'formats', - 'capabilities', 'read', 'auth', 'config', @@ -42,22 +39,8 @@ function commonOptions(command: Command): Command { .option('-o, --output ', 'output file or directory (- for stdout on convert)') .option('--from ', 'input format for stdin (e.g. pdf)') .option('--space ', 'cloud space id') - .option('--timeout ', 'task wait timeout') - .option('--preflight ', 'local DeckProbe check: off|validate|strict (default: validate)') - .option('--engine ', 'parse engine: local|cloud|auto (default: local)') - .option('--allow-upload', 'allow auto mode to upload when local quality is insufficient') - .option('--no-allow-upload', 'deny upload for this invocation') - .option('--fail-on-degraded', 'reject the final result when quality is degraded') - .option('--no-fail-on-degraded', 'allow best-effort results for this invocation') - .option('--max-source-bytes ', 'local parser source-size limit') - .option('--max-expanded-bytes ', 'local OOXML cumulative expanded-size limit') - .option('--max-part-bytes ', 'local OOXML single-part expanded-size limit') - .option('--max-asset-bytes ', 'local single-asset size limit') - .option('--max-total-asset-bytes ', 'local cumulative asset-size limit') - .option('--max-zip-entries ', 'local OOXML ZIP entry limit') - .option('--max-url-bytes ', 'local HTML response-size limit') - .option('--worker-heap-mb ', 'local OOXML worker heap limit') - .option('--force', 'ignore local reuse and redo the work') + .option('--timeout ', 'overall operation timeout in seconds (default: 120)') + .option('--force', 'ignore artifact reuse and redo the work') .option('--api-key ', 'API key (overrides stored credentials)') .option('--token ', 'user token (overrides stored credentials)') .option('--api-base ', 'API base URL') @@ -70,11 +53,8 @@ function parseOptions(command: Command): Command { .option('--profile ', 'pdf: fast|balanced|quality') .option('--password ', 'pdf: password for encrypted documents') .option('--no-images', 'pdf: skip image extraction') - .option('--page-furniture ', 'pdf: off|drop|extract') - .option('--overlaid-text ', 'pdf: auto|keep|drop') - .option('--tracked-changes ', 'docx: final|original|all') .option('--stay-image-area-rate ', 'keynote: image area keep rate 0-1') - .option('--mode ', 'url: source|runtime'); + .option('--mode ', 'url: source|runtime (default: runtime)'); } function convertOptions(command: Command): Command { @@ -89,7 +69,7 @@ function convertOptions(command: Command): Command { async function main(): Promise { const program = new Command('deckops') .version(VERSION) - .description('Read documents as Markdown by default. Use parse for durable IR artifacts.') + .description('Read documents as Markdown through DeckOps Cloud. Use parse for durable IR artifacts.') // Commander errors (unknown option, missing argument) are usage errors: // exit 2 per the contract, not commander's default 1. .exitOverride(); @@ -97,11 +77,8 @@ async function main(): Promise { parseOptions(commonOptions(program.command('read ', { hidden: true, isDefault: true }))) .option('--format ', 'content format: markdown|ir', 'markdown') .option('--report ', 'write an independent run report') - .option('--anchors', 'include element provenance comments') - .option('--split-pages', 'use explicit convert for split output') + .option('--anchors', 'pdf: per-element provenance comments in markdown') .action((input: string, options: RawCliOptions) => runReadCommand(input, options)); - program.command('capabilities').description('Inspect local/cloud capabilities and upgrade requirements') - .option('--json', 'machine-readable capability registry').action(() => { process.stdout.write(JSON.stringify(CAPABILITIES, null, 2) + '\n'); }); parseOptions(commonOptions(program.command('parse ').description('document → IR artifact'))).action( (input: string, options: RawCliOptions) => runParseCommand(input, options) @@ -112,14 +89,14 @@ async function main(): Promise { commonOptions( program .command('convert ') - .description('IR artifact (or document, one-shot) → view; markdown in v1') + .description('IR artifact (or document, one-shot) → markdown view') ) ) ).action((input: string, options: RawCliOptions) => runConvertCommand(input, options)); program .command('formats') - .description('what parses to what') + .description('supported inputs and views') .option('--json', 'machine-readable output') .action((options: { json?: boolean }) => runFormats(options)); @@ -140,14 +117,6 @@ async function main(): Promise { runConfigList(options) ); config.command('set ').action((key: string, value: string) => runConfigSet(key, value)); - config.command('migrate').description('import legacy configuration once; keep existing values and source files') - .option('--from-parse ', 'legacy DeckParse configuration directory') - .option('--from-tools ', 'legacy tool configuration directory') - .option('--shared-dir ', 'shared credential directory') - .option('--ops-dir ', 'new DeckOps configuration directory') - .option('--tools-dir ', 'DeckTools configuration directory') - .option('--dry-run', 'report paths and field names without writing') - .action(async (options: MigrationOptions) => { process.stdout.write(`${JSON.stringify(await migrateConfig(options), null, 2)}\n`); }); const collectAgent = (value: string, previous: string[]): string[] => [...previous, value]; program @@ -169,7 +138,7 @@ async function main(): Promise { .allowUnknownOption(true) .action(() => { throw DeckOpsError.notImplemented( - `\`${verb}\` is reserved for a future release. v1 ships parse and convert.`, + `\`${verb}\` is reserved for a future release. This version ships read, parse and convert.`, { hint: 'See the roadmap: https://github.com/deckflow/deckops#roadmap' } ); }); diff --git a/src/cli/commands/aux.ts b/src/cli/commands/aux.ts index 6dad75d..ffe57e9 100644 --- a/src/cli/commands/aux.ts +++ b/src/cli/commands/aux.ts @@ -12,14 +12,14 @@ import { import { DeckOpsError } from '../../errors/index.js'; import { readProductConfig, writeProductOption } from '../../config/product.js'; -/** `deckops formats` — the support matrix, honest about what fails. */ +/** `deckops formats` — the cloud support matrix, honest about what fails. */ export function runFormats(options: { json?: boolean }): void { const rows = [ - { input: '.pdf', parse: '✅ local', markdown: '✅ local', notes: 'no OCR; --page-furniture --overlaid-text' }, - { input: '.pptx', parse: '✅ local', markdown: '✅ local', notes: 'Chart/SmartArt may degrade; --split-pages' }, - { input: '.docx', parse: '✅ local', markdown: '✅ local', notes: '--tracked-changes final|original|all' }, - { input: '.key', parse: '☁ cloud', markdown: '☁ cloud', notes: 'local IWA parsing unsupported' }, - { input: 'http(s) URL', parse: '✅ source / ☁ runtime', markdown: '✅', notes: '--mode source|runtime' }, + { input: '.pdf', parse: '☁ pdf.pdfParse', markdown: '✅', notes: '--profile --password --no-images; --anchors' }, + { input: '.pptx', parse: '☁ pptx.parse', markdown: '✅', notes: '--split-pages' }, + { input: '.docx', parse: '☁ docx.parseTextAndImage', markdown: '✅', notes: '' }, + { input: '.key', parse: '☁ keynote.parseTextAndImage', markdown: '✅', notes: '--stay-image-area-rate; --split-pages' }, + { input: 'http(s) URL', parse: '☁ html.getByURL', markdown: '✅', notes: '--mode source|runtime (default runtime)' }, { input: '.doc .ppt .xls(x) .pages .numbers', parse: '❌ unsupported', markdown: '❌', notes: 'clear error + hint' }, ]; if (options.json) { @@ -28,7 +28,7 @@ export function runFormats(options: { json?: boolean }): void { } for (const row of rows) { process.stdout.write( - `${row.input.padEnd(36)} parse ${row.parse.padEnd(18)} markdown ${row.markdown.padEnd(4)} ${chalk.dim(row.notes)}\n` + `${row.input.padEnd(36)} parse ${row.parse.padEnd(30)} markdown ${row.markdown.padEnd(4)} ${chalk.dim(row.notes)}\n` ); } } @@ -104,13 +104,13 @@ export async function runConfigList(options: { json?: boolean }): Promise process.stdout.write(`\nProduct defaults\n ${JSON.stringify(data.defaults)}\n`); } -const CONFIG_KEYS = new Set(['api-key', 'token', 'space-id', 'api-base', 'engine', 'allow-upload', 'fail-on-degraded', 'timeout', 'preflight']); +const CONFIG_KEYS = new Set(['api-key', 'token', 'space-id', 'api-base', 'timeout']); export async function runConfigSet(key: string, value: string): Promise { if (!CONFIG_KEYS.has(key)) { throw DeckOpsError.usage(`Unknown config key "${key}". Known: ${[...CONFIG_KEYS].join(', ')}.`); } - if (['engine', 'allow-upload', 'fail-on-degraded', 'timeout', 'preflight'].includes(key)) { + if (key === 'timeout') { const file = writeProductOption(key, value); process.stdout.write(`${key} saved to ${displayPath(file)}.\n`); return; diff --git a/src/cli/commands/run-ops.ts b/src/cli/commands/run-ops.ts index d3991f8..f616801 100644 --- a/src/cli/commands/run-ops.ts +++ b/src/cli/commands/run-ops.ts @@ -1,17 +1,17 @@ import { translateError as translate } from '../../shared/errors.js'; -import { runRead } from '../../core/read-op.js'; -import { runConvert } from '../../core/convert-op.js'; -import { resolveInput, type ResolvedInput } from '../../core/input.js'; -import { runParse } from '../../core/parse-op.js'; -import { validateConvertFlags, validateParseFlags } from '../../core/validation.js'; +import { cloudClient } from '../../node/cloud.js'; +import { runConvert } from '../../node/convert-op.js'; +import { resolveInput } from '../../node/input.js'; +import { runParse } from '../../node/parse-op.js'; +import { runRead } from '../../node/read-op.js'; +import { validateConvertFlags, validateParseFlags } from '../../node/validation.js'; import { DeckOpsError } from '../../errors/index.js'; import { readProductConfig } from '../../config/product.js'; -import type { CommonFlags, ConvertFlags, ParseFlags } from '../../types-legacy.js'; -import { DEFAULT_PREFLIGHT_MODE, type PreflightMode } from '../../shared/preflight.js'; -import { printEnvelope, printError, type OutputContext } from '../output.js'; +import type { CommonFlags, ConvertFlags, ParseFlags } from '../../types.js'; +import { printEnvelope, printError, printWarnings, type OutputContext } from '../output.js'; /** - * Shared command driver for the two verbs: resolve input, validate flags, + * Shared command driver for the verbs: resolve input, validate flags, * build the client, run, render, exit. Exit codes come from the error code * table — the process exits non-zero on any failure. */ @@ -26,18 +26,6 @@ export interface RawCliOptions { force?: boolean; space?: string; timeout?: string; - preflight?: string; - engine?: string; - allowUpload?: boolean; - failOnDegraded?: boolean; - maxSourceBytes?: string; - maxExpandedBytes?: string; - maxPartBytes?: string; - maxAssetBytes?: string; - maxTotalAssetBytes?: string; - maxZipEntries?: string; - maxUrlBytes?: string; - workerHeapMb?: string; apiKey?: string; token?: string; apiBase?: string; @@ -45,9 +33,6 @@ export interface RawCliOptions { profile?: string; password?: string; images?: boolean; - pageFurniture?: string; - overlaidText?: string; - trackedChanges?: string; stayImageAreaRate?: string; mode?: string; // convert-side @@ -64,9 +49,6 @@ export function parseFlagsOf(options: RawCliOptions): ParseFlags { if (options.password !== undefined) flags.password = options.password; // commander's --no-images sets images: false; only forward the negation. if (options.images === false) flags.includeImages = false; - if (options.pageFurniture !== undefined) flags.pageFurniture = options.pageFurniture as NonNullable; - if (options.overlaidText !== undefined) flags.overlaidText = options.overlaidText as NonNullable; - if (options.trackedChanges !== undefined) flags.trackedChanges = options.trackedChanges as NonNullable; if (options.stayImageAreaRate !== undefined) flags.stayImageAreaRate = Number(options.stayImageAreaRate); if (options.mode !== undefined) flags.mode = options.mode as NonNullable; return flags; @@ -83,73 +65,22 @@ export function convertFlagsOf(options: RawCliOptions): ConvertFlags { } export function commonFlagsOf(options: RawCliOptions): CommonFlags { - const defaults = readProductConfig(); - const engine = options.engine ?? process.env.DECKOPS_ENGINE ?? defaults.engine ?? 'local'; - const allowUpload = options.allowUpload ?? envBoolean('DECKOPS_ALLOW_UPLOAD') ?? defaults.allowUpload; - const failOnDegraded = options.failOnDegraded ?? envBoolean('DECKOPS_FAIL_ON_DEGRADED') ?? defaults.failOnDegraded; - const timeout = options.timeout === undefined ? defaults.timeout : Number(options.timeout); - if (!['local', 'cloud', 'auto'].includes(engine)) throw DeckOpsError.usage('--engine must be local, cloud, or auto.'); - if (options.engine === 'local' && options.allowUpload === true) throw DeckOpsError.usage('Explicit --engine local conflicts with --allow-upload.'); - if (engine === 'cloud' && allowUpload === false) throw DeckOpsError.usage('--engine cloud conflicts with upload denial.'); + const timeout = options.timeout === undefined ? readProductConfig().timeout : Number(options.timeout); if (timeout !== undefined && (!Number.isSafeInteger(timeout) || timeout <= 0)) throw DeckOpsError.usage('--timeout must be a positive integer number of seconds.'); - const limitEntries = [ - ['sourceBytes', '--max-source-bytes', options.maxSourceBytes], ['zipExpandedBytes', '--max-expanded-bytes', options.maxExpandedBytes], - ['zipEntryBytes', '--max-part-bytes', options.maxPartBytes], ['zipEntries', '--max-zip-entries', options.maxZipEntries], - ['assetBytes', '--max-asset-bytes', options.maxAssetBytes], ['assetTotalBytes', '--max-total-asset-bytes', options.maxTotalAssetBytes], - ['urlBytes', '--max-url-bytes', options.maxUrlBytes], ['workerHeapMb', '--worker-heap-mb', options.workerHeapMb], - ] as const; - const limits: Record = {}; - for (const [name, flag, raw] of limitEntries) { - if (raw === undefined) continue; - const value = Number(raw); - if (!Number.isSafeInteger(value) || value <= 0) throw DeckOpsError.usage(`${flag} must be a positive integer.`); - limits[name] = value; - } return { ...(options.space !== undefined ? { spaceId: options.space } : {}), ...(timeout !== undefined ? { timeout } : {}), ...(options.force !== undefined ? { force: options.force } : {}), - policySource: { engine: options.engine !== undefined ? 'cli' : process.env.DECKOPS_ENGINE !== undefined ? 'environment' : defaults.engine !== undefined ? 'config' : 'builtin', allowUpload: options.allowUpload !== undefined ? 'cli' : process.env.DECKOPS_ALLOW_UPLOAD !== undefined ? 'environment' : defaults.allowUpload !== undefined ? 'config' : 'builtin' }, - engine: engine as NonNullable, - ...(allowUpload !== undefined ? { allowUpload } : {}), - ...(failOnDegraded !== undefined ? { failOnDegraded } : {}), - ...(Object.keys(limits).length ? { limits } : {}), }; } -function envBoolean(name: string): boolean | undefined { - const value = process.env[name]?.trim().toLowerCase(); - if (value === undefined || value === '') return undefined; - if (['1', 'true', 'yes', 'allow'].includes(value)) return true; - if (['0', 'false', 'no', 'deny'].includes(value)) return false; - throw DeckOpsError.usage(`${name} must be true/false or 1/0.`); -} - -export function preflightModeOf(options: RawCliOptions): PreflightMode { - const value = options.preflight ?? readProductConfig().preflight ?? DEFAULT_PREFLIGHT_MODE; - if (!['off', 'validate', 'strict'].includes(value)) { - throw DeckOpsError.usage('--preflight must be off, validate, or strict.'); - } - return value as PreflightMode; -} - -/** Existing artifacts have no source bytes to inspect; only reject an explicitly requested mode there. */ -export function preflightModeForConvert(input: ResolvedInput, options: RawCliOptions): PreflightMode | undefined { - if (input.kind === 'artifact' && options.preflight === undefined) return undefined; - return preflightModeOf(options); -} - -async function clientFor(options: RawCliOptions) { - const [{ resolveCredentials }, { createCloudClient }] = await Promise.all([ - import('../../config/index.js'), import('../../cloud/client.js'), - ]); - const credentials = await resolveCredentials({ +function clientFor(options: RawCliOptions) { + return () => cloudClient({ ...(options.apiKey ? { apiKey: options.apiKey } : {}), ...(options.token ? { token: options.token } : {}), ...(options.apiBase ? { apiBase: options.apiBase } : {}), ...(options.space ? { spaceId: options.space } : {}), }); - return createCloudClient(credentials); } export async function runParseCommand(inputArg: string, options: RawCliOptions): Promise { @@ -166,8 +97,7 @@ export async function runParseCommand(inputArg: string, options: RawCliOptions): ...(options.output ? { out: options.output } : {}), flags, common: commonFlagsOf(options), - preflight: preflightModeOf(options), - cloud: () => clientFor(options), + client: clientFor(options), }); printEnvelope(envelope, ctx); } catch (error) { @@ -181,7 +111,6 @@ export async function runConvertCommand(inputArg: string, options: RawCliOptions const input = await resolveInput(inputArg, options.from ? { from: options.from } : {}); const parseFlags = parseFlagsOf(options); const convertFlags = convertFlagsOf(options); - const preflight = preflightModeForConvert(input, options); validateParseFlags(input, parseFlags); validateConvertFlags(input, convertFlags); @@ -192,8 +121,7 @@ export async function runConvertCommand(inputArg: string, options: RawCliOptions flags: convertFlags, parseFlags, common: commonFlagsOf(options), - ...(preflight !== undefined ? { preflight } : {}), - cloud: () => clientFor(options), + client: clientFor(options), }); printEnvelope(envelope, ctx); } catch (error) { @@ -201,6 +129,32 @@ export async function runConvertCommand(inputArg: string, options: RawCliOptions } } +export async function runReadCommand(inputArg: string, options: RawCliOptions): Promise { + const ctx: OutputContext = { json: Boolean(options.json), quiet: Boolean(options.quiet) }; + try { + const input = await resolveInput(inputArg, options.from ? { from: options.from } : {}); + const flags = parseFlagsOf(options); + validateParseFlags(input, flags); + const result = await runRead({ + input, inputLabel: inputArg, flags, common: commonFlagsOf(options), client: clientFor(options), + ...(options.output !== undefined ? { out: options.output } : {}), + ...(options.format !== undefined ? { format: options.format as 'markdown' | 'ir' } : {}), + ...(options.report ? { reportFile: options.report } : {}), + ...(options.anchors !== undefined ? { anchors: options.anchors } : {}), + }); + if (ctx.json) { + process.stdout.write(`${JSON.stringify(result)}\n`); + return; + } + if (result.content !== null) process.stdout.write(typeof result.content === 'string' ? result.content : `${JSON.stringify(result.content)}\n`); + if (ctx.quiet) return; + printWarnings(result.report.warnings); + if (result.format === 'ir') process.stderr.write(`artifactBase: ${result.report.artifactBase}\n`); + } catch (error) { + fail(error, 'read', ctx); + } +} + /** `deckops parse doc.pdf --anchors` is a category error, not a silent no-op. */ function rejectConvertFlagsOnParse(options: RawCliOptions): void { const set = (['to', 'anchors', 'splitPages', 'strict', 'keepRemoteImages'] as const).filter( @@ -218,35 +172,3 @@ function fail(error: unknown, op: 'parse' | 'convert' | 'read', ctx: OutputConte printError(translated, op, ctx); process.exit(translated.exitCode); } - -export async function runReadCommand(inputArg: string, options: RawCliOptions): Promise { - try { - const input = await resolveInput(inputArg, options.from ? { from: options.from } : {}); - const flags = parseFlagsOf(options); validateParseFlags(input, flags); - const result = await runRead({ input, inputLabel: inputArg, flags, common: commonFlagsOf(options), preflight: preflightModeOf(options), - cloud: () => clientFor(options), ...(options.output !== undefined ? { out: options.output } : {}), - ...(options.format !== undefined ? { format: options.format as 'markdown' | 'ir' } : {}), ...(options.report ? { reportFile: options.report } : {}), - ...(options.anchors !== undefined ? { anchors: options.anchors } : {}), ...(options.splitPages !== undefined ? { splitPages: options.splitPages } : {}) }); - if (options.json) process.stdout.write(JSON.stringify(result) + '\n'); - else if (result.content !== null) process.stdout.write(typeof result.content === 'string' ? result.content : JSON.stringify(result.content) + '\n'); - if (!options.json) { - const issue = result.report.assessment.issues.find(i => i.severity !== 'info' && i.impact !== 'informational'); - if (issue) process.stderr.write(`quality: ${issue.message}\n`); - const summary = result.report.assessment.summary; - if (issue && summary) process.stderr.write(`coverage: ${summary.parsedPages}/${summary.sourcePages ?? '?'} pages/slides; missing=${summary.missingPageCount}; failed=${summary.failedPages.length}; searchableTextCharacters=${summary.searchableTextCharacters}\n`); - const recommendation = result.report.assessment.recommendation; - if (recommendation) process.stderr.write(`recommendation: ${recommendation.message}\n`); - const delivery = result.report.warnings.find(w => w.startsWith('Replaced ')); - if (delivery) process.stderr.write(`delivery: ${delivery}\n`); - const decision = result.report.decision; - if (decision && (issue || decision.reason === 'upgrade_failed')) { - process.stderr.write(`route: ${decision.action}; ${decision.reason}\n`); - if (decision.next?.message) process.stderr.write(`hint: ${decision.next.message}\n`); - if (decision.next) process.stderr.write(`next: ${decision.next.argv.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(' ')}\n`); - } - if (result.format === 'ir') process.stderr.write(`artifactBase: ${result.report.artifactBase}\n`); - const preflight = result.report.warnings.find(w => w.includes('preflight') || w.includes('DeckProbe')); - if (preflight) process.stderr.write(`${preflight}\n`); - } - } catch (error) { fail(error, 'read', { json: Boolean(options.json), quiet: Boolean(options.quiet) }); } -} diff --git a/src/cli/output.ts b/src/cli/output.ts index 778048c..de65975 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; import type { DeckOpsError } from '../errors/index.js'; -import type { ConvertEnvelope, Envelope, ParseEnvelope } from '../types-legacy.js'; +import type { ConvertEnvelope, Envelope, ParseEnvelope } from '../types.js'; /** * Result rendering. `--json` prints the envelope verbatim (public contract); @@ -21,7 +21,6 @@ export function printEnvelope(envelope: Envelope, ctx: OutputContext): void { if (!envelope.ok) { return; } - printAssessment(envelope); if (envelope.op === 'convert' && envelope.content !== undefined) { process.stdout.write(envelope.content); printWarnings(envelope.warnings); return; } if (ctx.quiet) { return; @@ -39,20 +38,17 @@ function printParse(envelope: ParseEnvelope): void { : chalk.green('✓ parsed'); process.stdout.write(`${head} ${chalk.dim(`[${envelope.type}]`)}\n`); process.stdout.write(` artifact ${envelope.artifact}\n`); - process.stdout.write(` engine ${envelope.engine}\n`); - process.stdout.write(` quality ${envelope.quality.status}\n`); process.stdout.write(` schema ${envelope.irSchemaVersion}\n`); - if (envelope.irKey) process.stdout.write(` irKey ${envelope.irKey}\n`); - if (envelope.taskId) { - process.stdout.write(` task ${envelope.taskId}\n`); - } + process.stdout.write(` irKey ${envelope.irKey}\n`); + process.stdout.write(` task ${envelope.taskId}\n`); + process.stdout.write(` expires ${envelope.expiresAt}\n`); process.stdout.write(chalk.dim(` ${envelope.durationMs} ms\n`)); printWarnings(envelope.warnings); } function printConvert(envelope: ConvertEnvelope): void { const head = - envelope.engine === 'artifact-cache' + envelope.taskId === null ? chalk.green('✓ reused existing view') : chalk.green(`✓ converted to ${envelope.to}`); process.stdout.write(`${head} ${chalk.dim(`[${envelope.format}]`)}\n`); @@ -67,7 +63,7 @@ function printConvert(envelope: ConvertEnvelope): void { printWarnings(envelope.warnings); } -function printWarnings(warnings: string[]): void { +export function printWarnings(warnings: string[]): void { for (const warning of warnings) { process.stderr.write(`${chalk.yellow('warning:')} ${warning}\n`); } @@ -78,7 +74,7 @@ export function printError(error: DeckOpsError, op: 'parse' | 'convert' | 'insta process.stdout.write( `${JSON.stringify( { - ...(op === 'read' ? { schemaVersion: 'deckops.read.v1' } : {}), + ...(op === 'read' ? { schemaVersion: 'deckops.read.v2' } : {}), ok: false, op, error: { @@ -99,15 +95,3 @@ export function printError(error: DeckOpsError, op: 'parse' | 'convert' | 'insta process.stderr.write(`${chalk.dim('hint:')} ${error.hint}\n`); } } - -function printAssessment(envelope: ParseEnvelope | ConvertEnvelope): void { - const assessment = envelope.assessment; - if (assessment?.status === 'needs_attention') { - const s = assessment.summary; - if (s) process.stderr.write(`quality: ${s.parsedPages}/${s.sourcePages ?? '?'} pages/slides; missing=${s.missingPageCount}; failed=${s.failedPages.length}; searchableTextCharacters=${s.searchableTextCharacters}\n`); - const first = assessment.issues.find(i => i.severity !== 'info' && i.impact !== 'informational'); - if (first) process.stderr.write(`quality: ${first.message}\n`); - } - if (assessment?.recommendation) process.stderr.write(`recommendation: ${assessment.recommendation.message}\n`); - if (envelope.decision?.next) process.stderr.write(`next: ${envelope.decision.next.argv.map(arg => "'" + arg.replaceAll("'", "'\\''") + "'").join(' ')}\n`); -} diff --git a/src/config/migrate.ts b/src/config/migrate.ts deleted file mode 100644 index 53f3a12..0000000 --- a/src/config/migrate.ts +++ /dev/null @@ -1,77 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { deckflowDir, deckopsDir, DIR_MODE, SECRET_FILE_MODE } from './paths.js'; -import { sanitizeProductConfig } from './product.js'; - -export interface MigrationOptions { - fromParse?: string; - fromTools?: string; - sharedDir?: string; - opsDir?: string; - toolsDir?: string; - dryRun?: boolean; -} - -async function readObject(file: string): Promise> { - try { - const raw: unknown = JSON.parse(await fs.readFile(file, 'utf8')); - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`Expected a JSON object: ${file}`); - return raw as Record; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; - throw error; - } -} - -/** Explicit administrative operation, never invoked by parse/convert or config loading. */ -export async function migrateConfig(options: MigrationOptions = {}) { - const shared = options.sharedDir ?? deckflowDir(); - const ops = options.opsDir ?? (options.sharedDir ? path.join(shared, 'deckops') : deckopsDir()); - const tools = options.toolsDir ?? process.env.DECKTOOLS_CONFIG_DIR ?? path.join(shared, 'decktools'); - const fromParse = options.fromParse ?? path.join(os.homedir(), '.deckparse'); - const fromTools = options.fromTools ?? path.join(os.homedir(), '.deckops'); - const parse = await readObject(path.join(fromParse, 'config.json')); - const legacyTools = await readObject(path.join(fromTools, 'config.json')); - const credentials: Record = {}; - // Preserve shared target values first; legacy tools take precedence over legacy parse. - for (const source of [legacyTools, parse]) { - for (const key of ['apiKey', 'token', 'spaceId', 'apiBase']) { - const value = source[key]; - if (credentials[key] !== undefined || typeof value !== 'string' || !value.trim()) continue; - if (key === 'apiBase') { - try { if (!['http:', 'https:'].includes(new URL(value).protocol)) continue; } catch { continue; } - } - credentials[key] = value.trim(); - } - } - const preferences: Record = {}; - if (typeof legacyTools.webhook === 'string') preferences.webhook = legacyTools.webhook; - if (typeof legacyTools.retentionHours === 'number' && Number.isFinite(legacyTools.retentionHours) && legacyTools.retentionHours > 0) preferences.retentionHours = legacyTools.retentionHours; - - const candidates: [string, Record][] = [ - [path.join(shared, 'credentials'), credentials], - [path.join(ops, 'config.json'), { ...sanitizeProductConfig(parse) }], - [path.join(tools, 'config.json'), preferences], - ]; - // Validate every target before writing any of them. Unknown target fields survive. - const changes = await Promise.all(candidates.map(async ([file, incoming]) => { - const current = await readObject(file); - const missing = Object.fromEntries(Object.entries(incoming).filter(([key]) => !(key in current))); - return { file, next: { ...current, ...missing }, fields: Object.keys(missing) }; - })); - for (const change of changes) { - if (!change.fields.length || options.dryRun) continue; - await fs.mkdir(path.dirname(change.file), { recursive: true, mode: DIR_MODE }); - const temporary = `${change.file}.migration-${randomUUID()}`; - try { - await fs.writeFile(temporary, `${JSON.stringify(change.next, null, 2)}\n`, { flag: 'wx', mode: SECRET_FILE_MODE }); - await fs.rename(temporary, change.file); - } finally { - await fs.rm(temporary, { force: true }); - } - } - // Return field names and paths, never secret values. UUID files are never touched. - return { dryRun: options.dryRun ?? false, changes: changes.filter((item) => item.fields.length).map(({ file, fields }) => ({ file, fields })) }; -} diff --git a/src/config/product.ts b/src/config/product.ts index db135b4..33e01a7 100644 --- a/src/config/product.ts +++ b/src/config/product.ts @@ -4,21 +4,13 @@ import { DeckOpsError } from '../errors/index.js'; import { configPath, DIR_MODE, SECRET_FILE_MODE } from './paths.js'; export interface ProductConfig { - engine?: 'local' | 'auto' | 'cloud'; - allowUpload?: boolean; - failOnDegraded?: boolean; timeout?: number; - preflight?: 'off' | 'validate' | 'strict'; } -/** Only understood, valid settings cross the one-time migration boundary. */ -export function sanitizeProductConfig(raw: Record): ProductConfig { +/** Only understood, valid settings are read; anything else in the file is ignored. */ +function sanitizeProductConfig(raw: Record): ProductConfig { const out: ProductConfig = {}; - if (raw.engine === 'local' || raw.engine === 'auto' || raw.engine === 'cloud') out.engine = raw.engine; - if (typeof raw.allowUpload === 'boolean') out.allowUpload = raw.allowUpload; - if (typeof raw.failOnDegraded === 'boolean') out.failOnDegraded = raw.failOnDegraded; if (typeof raw.timeout === 'number' && Number.isSafeInteger(raw.timeout) && raw.timeout > 0) out.timeout = raw.timeout; - if (raw.preflight === 'off' || raw.preflight === 'validate' || raw.preflight === 'strict') out.preflight = raw.preflight; return out; } @@ -36,13 +28,9 @@ export function readProductConfig(): ProductConfig { } export function writeProductOption(key: string, value: string): string { - const fields = { engine: 'engine', 'allow-upload': 'allowUpload', 'fail-on-degraded': 'failOnDegraded', timeout: 'timeout', preflight: 'preflight' } as const; - const field = fields[key as keyof typeof fields]; - if (!field) throw DeckOpsError.usage(`Unknown product option: ${key}`); - const parsed: unknown = field === 'timeout' ? Number(value) - : field === 'allowUpload' || field === 'failOnDegraded' ? (value === 'true' ? true : value === 'false' ? false : undefined) : value; - const valid = sanitizeProductConfig({ [field]: parsed }); - if (!(field in valid)) throw DeckOpsError.usage(`Invalid value for ${key}.`); + if (key !== 'timeout') throw DeckOpsError.usage(`Unknown product option: ${key}`); + const valid = sanitizeProductConfig({ timeout: Number(value) }); + if (!('timeout' in valid)) throw DeckOpsError.usage(`Invalid value for ${key}.`); const file = configPath(); // Validate an existing file first instead of replacing malformed user configuration. readProductConfig(); diff --git a/src/shared/validation.ts b/src/shared/validation.ts index 7394439..2e87225 100644 --- a/src/shared/validation.ts +++ b/src/shared/validation.ts @@ -30,9 +30,6 @@ const PARSE_FLAG_FORMATS: Record = { profile: ['pdf'], password: ['pdf'], includeImages: ['pdf'], - pageFurniture: ['pdf'], - overlaidText: ['pdf'], - trackedChanges: ['docx'], stayImageAreaRate: ['keynote'], mode: ['link'], }; diff --git a/src/types.ts b/src/types.ts index 2585a2f..0de7e0d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,9 +35,6 @@ export interface ParseFlags { profile?: 'fast' | 'balanced' | 'quality'; password?: string; includeImages?: boolean; - pageFurniture?: 'off' | 'drop' | 'extract'; - overlaidText?: 'auto' | 'keep' | 'drop'; - trackedChanges?: 'final' | 'original' | 'all'; stayImageAreaRate?: number; mode?: 'source' | 'runtime'; } diff --git a/tests/unit/migration.test.ts b/tests/unit/migration.test.ts deleted file mode 100644 index e82f1ea..0000000 --- a/tests/unit/migration.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { migrateConfig } from '../../src/config/migrate.js'; -import { configPath, credentialsPath } from '../../src/config/paths.js'; -import { resolveCredentials } from '../../src/config/credentials.js'; -import { commonFlagsOf } from '../../src/cli/commands/run-ops.js'; -import { readProductConfig, writeProductOption } from '../../src/config/product.js'; -import { resolveAuthUuid, resetAuthUuidCacheForTests } from '../../src/cloud/auth-uuid.js'; - -const dirs: string[] = []; -const tmp = async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-migration-')); dirs.push(dir); return dir; }; -const write = async (file: string, data: unknown) => { await fs.mkdir(path.dirname(file), { recursive: true }); await fs.writeFile(file, JSON.stringify(data)); }; -afterEach(async () => { vi.unstubAllEnvs(); resetAuthUuidCacheForTests(); await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); - -describe('migration contracts', () => { - it('validates product options and preserves unrelated settings without writing credentials', async () => { - const root = await tmp(); - vi.stubEnv('DECKFLOW_CONFIG_DIR', path.join(root, 'shared')); - vi.stubEnv('DECKOPS_CONFIG_DIR', path.join(root, 'product')); - await write(configPath(), { custom: 'keep' }); - writeProductOption('engine', 'local'); - writeProductOption('allow-upload', 'false'); - expect(readProductConfig()).toEqual({ engine: 'local', allowUpload: false }); - expect(() => writeProductOption('timeout', '-1')).toThrow('Invalid value'); - expect(() => writeProductOption('allow-upload', 'yes')).toThrow('Invalid value'); - expect(JSON.parse(await fs.readFile(configPath(), 'utf8')).custom).toBe('keep'); - await expect(fs.stat(credentialsPath())).rejects.toMatchObject({ code: 'ENOENT' }); - }); - - it('imports only valid missing fields, keeps sources and UUID, and is idempotent', async () => { - const root = await tmp(); const sharedDir = path.join(root, 'shared'); - const fromParse = path.join(root, 'parse'); const fromTools = path.join(root, 'tools'); - await write(path.join(fromParse, 'config.json'), { engine: 'auto', timeout: -1, failOnDegraded: true, token: 'parse-token', unknown: 'skip' }); - await write(path.join(fromTools, 'config.json'), { token: 'tools-token', spaceId: 's1', webhook: 'https://example.test', retentionHours: 3 }); - await write(path.join(sharedDir, 'credentials'), { token: 'existing', custom: 42 }); - await write(path.join(sharedDir, 'deckops/config.json'), { engine: 'local', custom: 'keep' }); - await fs.writeFile(path.join(sharedDir, 'auth-uuid'), 'f47ac10b-58cc-4372-a567-0e02b2c3d479'); - const source = await fs.readFile(path.join(fromParse, 'config.json'), 'utf8'); - const result = await migrateConfig({ fromParse, fromTools, sharedDir }); - expect(JSON.stringify(result)).not.toContain('tools-token'); - expect(JSON.parse(await fs.readFile(path.join(sharedDir, 'credentials'), 'utf8'))).toEqual({ token: 'existing', custom: 42, spaceId: 's1' }); - expect(JSON.parse(await fs.readFile(path.join(sharedDir, 'deckops/config.json'), 'utf8'))).toEqual({ engine: 'local', custom: 'keep', failOnDegraded: true }); - expect(JSON.parse(await fs.readFile(path.join(sharedDir, 'decktools/config.json'), 'utf8'))).toEqual({ webhook: 'https://example.test', retentionHours: 3 }); - expect(await fs.readFile(path.join(fromParse, 'config.json'), 'utf8')).toBe(source); - expect(await fs.readFile(path.join(sharedDir, 'auth-uuid'), 'utf8')).toBe('f47ac10b-58cc-4372-a567-0e02b2c3d479'); - expect((await fs.stat(path.join(sharedDir, 'credentials'))).mode & 0o777).toBe(0o600); - expect((await migrateConfig({ fromParse, fromTools, sharedDir })).changes).toEqual([]); - }); - - it('dry-runs without writes and fails before writing when a target is malformed', async () => { - const root = await tmp(); const sharedDir = path.join(root, 'shared'); const fromParse = path.join(root, 'parse'); const fromTools = path.join(root, 'tools'); - await write(path.join(fromParse, 'config.json'), { engine: 'local', token: 'secret' }); - expect((await migrateConfig({ sharedDir, fromParse, fromTools, dryRun: true })).changes).toHaveLength(2); - await expect(fs.stat(sharedDir)).rejects.toMatchObject({ code: 'ENOENT' }); - await write(path.join(sharedDir, 'deckops/config.json'), []); - await expect(migrateConfig({ sharedDir, fromParse, fromTools })).rejects.toThrow('JSON object'); - await expect(fs.stat(path.join(sharedDir, 'credentials'))).rejects.toMatchObject({ code: 'ENOENT' }); - }); - - it('isolates product paths, ignores old env names, and keeps shared UUID', async () => { - const root = await tmp(); const shared = path.join(root, 'shared'); const product = path.join(root, 'product'); - vi.stubEnv('DECKFLOW_CONFIG_DIR', shared); vi.stubEnv('DECKOPS_CONFIG_DIR', product); - vi.stubEnv('DECKPARSE_TOKEN', 'ignored'); vi.stubEnv('DECKTOOLS_TOKEN', 'ignored'); - vi.stubEnv('DECKOPS_TOKEN', ''); vi.stubEnv('DECKFLOW_TOKEN', ''); - vi.stubEnv('DECKOPS_AUTH_UUID', ''); - await write(path.join(shared, 'credentials'), { token: 'stored' }); - await write(path.join(product, 'config.json'), { engine: 'auto', allowUpload: false }); - await fs.writeFile(path.join(shared, 'auth-uuid'), 'f47ac10b-58cc-4372-a567-0e02b2c3d479'); - expect(configPath()).toBe(path.join(product, 'config.json')); - expect(credentialsPath()).toBe(path.join(shared, 'credentials')); - expect((await resolveCredentials()).token).toBe('stored'); - vi.stubEnv('DECKFLOW_TOKEN', 'shared-env'); vi.stubEnv('DECKOPS_TOKEN', 'product-env'); - expect((await resolveCredentials()).token).toBe('product-env'); - expect((await resolveCredentials({ token: 'flag' })).token).toBe('flag'); - vi.stubEnv('DECKOPS_ENGINE', 'local'); - expect(commonFlagsOf({}).engine).toBe('local'); - expect(commonFlagsOf({ engine: 'auto' }).engine).toBe('auto'); - expect(await resolveAuthUuid()).toBe('f47ac10b-58cc-4372-a567-0e02b2c3d479'); - }); -}); diff --git a/tests/unit/shared.test.ts b/tests/unit/shared.test.ts index de7a9a1..8762e11 100644 --- a/tests/unit/shared.test.ts +++ b/tests/unit/shared.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { validateConvertFlags, validateParseFlags } from '../../src/core/validation.js'; +import { validateConvertFlags, validateParseFlags } from '../../src/node/validation.js'; import { DeckOpsError } from '../../src/errors/index.js'; import { PRE_UPLOAD_THRESHOLD } from '../../src/shared/constants.js'; import { translateError } from '../../src/shared/errors.js'; @@ -10,31 +10,7 @@ import { validateConvertFlagsForFormat, validateParseFlagsForFormat, } from '../../src/shared/validation.js'; -import type { ResolvedInput } from '../../src/core/input.js'; -import { preflightModeForConvert, preflightModeOf } from '../../src/cli/commands/run-ops.js'; - -describe('shared preflight default', () => { - it('defaults CLI operations to validate while preserving explicit off and strict', () => { - expect(preflightModeOf({})).toBe('validate'); - expect(preflightModeOf({ preflight: 'off' })).toBe('off'); - expect(preflightModeOf({ preflight: 'strict' })).toBe('strict'); - }); - - it('does not apply the implicit source default to an existing artifact', () => { - const artifact: ResolvedInput = { - kind: 'artifact', - dir: 'example', - manifest: { - manifestVersion: 1, - source: { name: 'slides.pptx' }, - parse: { type: 'pptx.parse', taskId: 'parse-task', irKey: 'ir-key', irSchemaVersion: 'pptx.v1', params: {}, createdAt: '2026-01-01' }, - views: {}, assets: {}, producer: { deckparse: 'test', sdk: 'test' }, - }, - }; - expect(preflightModeForConvert(artifact, {})).toBeUndefined(); - expect(preflightModeForConvert(artifact, { preflight: 'validate' })).toBe('validate'); - }); -}); +import type { ResolvedInput } from '../../src/node/input.js'; describe('shared input routing', () => { it('routes the supported formats without requiring a filesystem input', () => { @@ -102,12 +78,12 @@ describe('shared format validation', () => { kind: 'artifact', dir: 'example', manifest: { - manifestVersion: 1, - source: { name: 'slides.pptx' }, - parse: { type: 'pptx.parse', taskId: 'parse-task', irKey: 'ir-key', irSchemaVersion: 'pptx.v1', params: {}, createdAt: '2026-01-01' }, + manifestVersion: 3, + source: { name: 'slides.pptx', sha256: 'abc', bytes: 1 }, + parse: { type: 'pptx.parse', taskId: 'parse-task', irKey: 'ir-key', irSchemaVersion: 'result.v3', params: {}, createdAt: '2026-01-01', expiresAt: '2026-01-08' }, views: {}, assets: {}, - producer: { deckparse: 'test', sdk: 'test' }, + producer: { deckops: 'test' }, }, }; expect(() => validateParseFlags(artifact, {})).not.toThrow(); From ebff7b8401cecb32f25d93d1c267fa39d600dbb9 Mon Sep 17 00:00:00 2001 From: Garfield Date: Mon, 19 Oct 2026 15:51:44 +0800 Subject: [PATCH 4/9] refactor: remove the local engine, client-side IR and DeckProbe preflight Delete the local PDF/DOCX/PPTX/HTML parsers and worker, the engine router and policy, quality assessment, the client IR normalization and Markdown renderer, the deckir.v1 schema, DeckProbe preflight and the legacy core/artifact operations with their tests. The Node entry now exports the 3.0 SDK from src/node/sdk.ts: createClient, parse, convert, read, openArtifact and ParsedDocument over artifact v3, rejecting unknown or removed options instead of ignoring them. Drop @deckflow/deckprobe, @deckflow/pdf-lite-parse, parse5, saxes and fflate; the fixture generator only writes the synthetic PDF the fake cloud and browser smoke page use. Unit tests for error translation and rejected credentials run through the client facade. Co-Authored-By: Claude Fable 5.1 --- package.json | 9 +- pnpm-lock.yaml | 135 ------- schemas/deckir.v1.schema.json | 90 ----- scripts/copy-deckprobe-wasm.mjs | 8 - scripts/generate-fixtures.mjs | 32 +- src/artifact/layout.ts | 43 --- src/artifact/manifest.ts | 114 ------ src/artifact/store-asset.ts | 29 -- src/cloud/client.ts | 99 ----- src/core/convert-op.ts | 218 ----------- src/core/input.ts | 62 ---- src/core/inspector.ts | 32 -- src/core/parse-op.ts | 261 ------------- src/core/read-op.ts | 102 ----- src/core/validation.ts | 54 --- src/engine/cloud.ts | 54 --- src/engine/local.ts | 65 ---- src/engine/policy.ts | 80 ---- src/engine/router.ts | 142 ------- src/engine/types.ts | 29 -- src/index.ts | 145 +------- src/ir/alt-text.ts | 55 --- src/ir/assets.ts | 51 --- src/ir/chart-data.ts | 99 ----- src/ir/cloud-adapter.ts | 591 ----------------------------- src/ir/ids.ts | 10 - src/ir/pptx-lists.ts | 61 --- src/ir/pptx-placeholders.ts | 63 ---- src/ir/result3-adapter.ts | 179 --------- src/ir/schema.ts | 115 ------ src/ir/slide-order.ts | 107 ------ src/ir/validate.ts | 85 ----- src/local/common.ts | 73 ---- src/local/docx/parser.ts | 299 --------------- src/local/html/parser.ts | 168 --------- src/local/limits.ts | 42 --- src/local/opc/package.ts | 186 ---------- src/local/pdf/adapter.ts | 46 --- src/local/pptx/chart.ts | 81 ---- src/local/pptx/parser.ts | 594 ------------------------------ src/local/runner.ts | 40 -- src/local/worker-protocol.ts | 14 - src/local/worker.ts | 22 -- src/local/xml.ts | 87 ----- src/node/sdk.ts | 122 ++++++ src/quality/assessment.ts | 137 ------- src/shared/assessment-types.ts | 20 - src/shared/policy-types.ts | 10 - src/shared/preflight.ts | 226 ------------ src/shared/text-quality.ts | 35 -- src/types-legacy.ts | 113 ------ src/version.ts | 2 - src/views/markdown.ts | 285 -------------- tests/browser/fake-cloud.mjs | 2 +- tests/browser/smoke.js | 19 +- tests/cloud/sdk.test.ts | 7 +- tests/integration/local.test.ts | 130 ------- tests/integration/read.test.ts | 96 +++-- tests/integration/review.test.ts | 40 -- tests/unit/alt-text.test.ts | 36 -- tests/unit/chart-data.test.ts | 23 -- tests/unit/cloud-adapter.test.ts | 394 -------------------- tests/unit/errors.test.ts | 2 +- tests/unit/local-parsers.test.ts | 83 ----- tests/unit/pdf-adapter.test.ts | 33 -- tests/unit/policy.test.ts | 102 ----- tests/unit/pptx-parser.test.ts | 324 ---------------- tests/unit/preflight.test.ts | 117 ------ tests/unit/quality-review.test.ts | 170 --------- tests/unit/slide-order.test.ts | 47 --- tsup.config.ts | 2 +- 71 files changed, 216 insertions(+), 7232 deletions(-) delete mode 100644 schemas/deckir.v1.schema.json delete mode 100644 scripts/copy-deckprobe-wasm.mjs delete mode 100644 src/artifact/layout.ts delete mode 100644 src/artifact/manifest.ts delete mode 100644 src/artifact/store-asset.ts delete mode 100644 src/cloud/client.ts delete mode 100644 src/core/convert-op.ts delete mode 100644 src/core/input.ts delete mode 100644 src/core/inspector.ts delete mode 100644 src/core/parse-op.ts delete mode 100644 src/core/read-op.ts delete mode 100644 src/core/validation.ts delete mode 100644 src/engine/cloud.ts delete mode 100644 src/engine/local.ts delete mode 100644 src/engine/policy.ts delete mode 100644 src/engine/router.ts delete mode 100644 src/engine/types.ts delete mode 100644 src/ir/alt-text.ts delete mode 100644 src/ir/assets.ts delete mode 100644 src/ir/chart-data.ts delete mode 100644 src/ir/cloud-adapter.ts delete mode 100644 src/ir/ids.ts delete mode 100644 src/ir/pptx-lists.ts delete mode 100644 src/ir/pptx-placeholders.ts delete mode 100644 src/ir/result3-adapter.ts delete mode 100644 src/ir/schema.ts delete mode 100644 src/ir/slide-order.ts delete mode 100644 src/ir/validate.ts delete mode 100644 src/local/common.ts delete mode 100644 src/local/docx/parser.ts delete mode 100644 src/local/html/parser.ts delete mode 100644 src/local/limits.ts delete mode 100644 src/local/opc/package.ts delete mode 100644 src/local/pdf/adapter.ts delete mode 100644 src/local/pptx/chart.ts delete mode 100644 src/local/pptx/parser.ts delete mode 100644 src/local/runner.ts delete mode 100644 src/local/worker-protocol.ts delete mode 100644 src/local/worker.ts delete mode 100644 src/local/xml.ts create mode 100644 src/node/sdk.ts delete mode 100644 src/quality/assessment.ts delete mode 100644 src/shared/assessment-types.ts delete mode 100644 src/shared/policy-types.ts delete mode 100644 src/shared/preflight.ts delete mode 100644 src/shared/text-quality.ts delete mode 100644 src/types-legacy.ts delete mode 100644 src/views/markdown.ts delete mode 100644 tests/integration/local.test.ts delete mode 100644 tests/integration/review.test.ts delete mode 100644 tests/unit/alt-text.test.ts delete mode 100644 tests/unit/chart-data.test.ts delete mode 100644 tests/unit/cloud-adapter.test.ts delete mode 100644 tests/unit/local-parsers.test.ts delete mode 100644 tests/unit/pdf-adapter.test.ts delete mode 100644 tests/unit/policy.test.ts delete mode 100644 tests/unit/pptx-parser.test.ts delete mode 100644 tests/unit/preflight.test.ts delete mode 100644 tests/unit/quality-review.test.ts delete mode 100644 tests/unit/slide-order.test.ts diff --git a/package.json b/package.json index be1c0f0..9cf558b 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "import": "./dist/browser/index.js", "default": "./dist/browser/index.js" }, - "./schemas/*": "./schemas/*.json", "./package.json": "./package.json" }, "bin": { @@ -23,7 +22,6 @@ }, "files": [ "dist", - "schemas", "skills", "README.md", "CHANGELOG.md", @@ -75,17 +73,12 @@ "url": "https://github.com/deckflow/deckops/issues" }, "dependencies": { - "@deckflow/deckprobe": "2.6.0", - "@deckflow/pdf-lite-parse": "0.2.1", "axios": "1.19.0", "chalk": "^5.3.0", "commander": "^11.1.0", "eventsource-parser": "1.1.2", - "fflate": "^0.8.2", "open": "^10.1.0", - "p-limit": "^5.0.0", - "parse5": "^7.3.0", - "saxes": "^6.0.0" + "p-limit": "^5.0.0" }, "devDependencies": { "@types/node": "^20.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81e4224..87cd24b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,12 +8,6 @@ importers: .: dependencies: - '@deckflow/deckprobe': - specifier: 2.6.0 - version: 2.6.0 - '@deckflow/pdf-lite-parse': - specifier: 0.2.1 - version: 0.2.1 axios: specifier: 1.19.0 version: 1.19.0 @@ -26,21 +20,12 @@ importers: eventsource-parser: specifier: 1.1.2 version: 1.1.2 - fflate: - specifier: ^0.8.2 - version: 0.8.3 open: specifier: ^10.1.0 version: 10.2.0 p-limit: specifier: ^5.0.0 version: 5.0.0 - parse5: - specifier: ^7.3.0 - version: 7.3.0 - saxes: - specifier: ^6.0.0 - version: 6.0.0 devDependencies: '@types/node': specifier: ^20.10.0 @@ -63,62 +48,6 @@ importers: packages: - '@deckflow/deckprobe-darwin-arm64@2.6.0': - resolution: {integrity: sha512-03u/yd77mUObeGYuAcrwB9PXDh+6f/gA44JtKvtwh/gQmvF8D1mpKqaPv7nNXHDMIcsBmZzN9GJSYyRx1LR8Hw==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-darwin-arm64/-/deckprobe-darwin-arm64-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [arm64] - os: [darwin] - - '@deckflow/deckprobe-darwin-x64@2.6.0': - resolution: {integrity: sha512-nuv96oCc8R8vjLzKqxNZvCTYcDJjk5H9GtZFzLWuuytnpCh7/BHestbLIFzHk6fCutZijy5/8vHrMY0zl7lLUA==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-darwin-x64/-/deckprobe-darwin-x64-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [x64] - os: [darwin] - - '@deckflow/deckprobe-linux-arm64-gnu@2.6.0': - resolution: {integrity: sha512-PWZhmSyjDej8DoH+EU63TqNH8K//7uShqsym/9OoXKMBa9mnz1DZx4jRBFo1cdYJZaC745ENKuNSpV44mJ1z5g==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-linux-arm64-gnu/-/deckprobe-linux-arm64-gnu-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@deckflow/deckprobe-linux-arm64-musl@2.6.0': - resolution: {integrity: sha512-nDAqhgqgKHE9eF1drD/IHLzO8CWaRe2TQeEpU/Ptu5Th3FWmtFpZAXZ0s4icBBwstNNNIRmR4VajlMimFN3hlA==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-linux-arm64-musl/-/deckprobe-linux-arm64-musl-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@deckflow/deckprobe-linux-x64-gnu@2.6.0': - resolution: {integrity: sha512-RsVNHrGF6NN7sdCnjsK9cU3As6TVxx5oftpXHSyzaSLxhu9UxdqnUOAvjhmXW46SxPGLgdKsfYNpdafn3bWI/Q==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-linux-x64-gnu/-/deckprobe-linux-x64-gnu-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@deckflow/deckprobe-linux-x64-musl@2.6.0': - resolution: {integrity: sha512-Qgpb3mFVJO5S7G1Zdy/oAGLJXjbd5yXbr7kq+RaO+GaxEDzeWa/TKWLqoCJwBDjnnpsTTsWCQpAQSFxhidjVdg==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-linux-x64-musl/-/deckprobe-linux-x64-musl-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@deckflow/deckprobe-win32-x64-msvc@2.6.0': - resolution: {integrity: sha512-3EeNootPYwADO7zASupxgBbQMLPCniKEKuPt4Pyj11ZwC4WVPOWCuAHZW6EJEMXu4McXCU4+4bTNnzU7TI+v4A==, tarball: https://registry.npmjs.org/@deckflow/deckprobe-win32-x64-msvc/-/deckprobe-win32-x64-msvc-2.6.0.tgz} - engines: {node: '>=20'} - cpu: [x64] - os: [win32] - - '@deckflow/deckprobe@2.6.0': - resolution: {integrity: sha512-rkgv1kYlPyn6dAUf67oDU0zK6p51Wq3bpjF3dMKe+t+RJWBUwRuDyiBKTlPojN3rfjx5r1gt7ZY4S+iHOg+nkw==, tarball: https://registry.npmjs.org/@deckflow/deckprobe/-/deckprobe-2.6.0.tgz} - engines: {node: '>=20'} - hasBin: true - - '@deckflow/pdf-lite-parse@0.2.1': - resolution: {integrity: sha512-rrZGZ/EyYAtg/5mGrr0Y9FowjujcQNJJu6gScpUsaKNvNw1p/8s1ZnTJfoJPoZIFDaTE2526f6zr2hO90iGoOw==, tarball: https://registry.npmjs.org/@deckflow/pdf-lite-parse/-/pdf-lite-parse-0.2.1.tgz} - engines: {node: '>=22.18'} - hasBin: true - '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==, tarball: https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz} engines: {node: '>=12'} @@ -719,10 +648,6 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==, tarball: https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz} engines: {node: '>= 0.4'} - entities@6.0.1: - resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==, tarball: https://registry.npmmirror.com/entities/-/entities-6.0.1.tgz} - engines: {node: '>=0.12'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==, tarball: https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz} engines: {node: '>= 0.4'} @@ -775,9 +700,6 @@ packages: picomatch: optional: true - fflate@0.8.3: - resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==, tarball: https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz} - fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==, tarball: https://registry.npmmirror.com/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz} @@ -907,9 +829,6 @@ packages: resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==, tarball: https://registry.npmmirror.com/p-limit/-/p-limit-5.0.0.tgz} engines: {node: '>=18'} - parse5@7.3.0: - resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==, tarball: https://registry.npmmirror.com/parse5/-/parse5-7.3.0.tgz} - pathe@1.1.2: resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==, tarball: https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz} @@ -977,10 +896,6 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==, tarball: https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz} engines: {node: '>=18'} - saxes@6.0.0: - resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==, tarball: https://registry.npmmirror.com/saxes/-/saxes-6.0.0.tgz} - engines: {node: '>=v12.22.7'} - siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, tarball: https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz} @@ -1139,48 +1054,12 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==, tarball: https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz} engines: {node: '>=18'} - xmlchars@2.2.0: - resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==, tarball: https://registry.npmmirror.com/xmlchars/-/xmlchars-2.2.0.tgz} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==, tarball: https://registry.npmmirror.com/yocto-queue/-/yocto-queue-1.2.2.tgz} engines: {node: '>=12.20'} snapshots: - '@deckflow/deckprobe-darwin-arm64@2.6.0': - optional: true - - '@deckflow/deckprobe-darwin-x64@2.6.0': - optional: true - - '@deckflow/deckprobe-linux-arm64-gnu@2.6.0': - optional: true - - '@deckflow/deckprobe-linux-arm64-musl@2.6.0': - optional: true - - '@deckflow/deckprobe-linux-x64-gnu@2.6.0': - optional: true - - '@deckflow/deckprobe-linux-x64-musl@2.6.0': - optional: true - - '@deckflow/deckprobe-win32-x64-msvc@2.6.0': - optional: true - - '@deckflow/deckprobe@2.6.0': - optionalDependencies: - '@deckflow/deckprobe-darwin-arm64': 2.6.0 - '@deckflow/deckprobe-darwin-x64': 2.6.0 - '@deckflow/deckprobe-linux-arm64-gnu': 2.6.0 - '@deckflow/deckprobe-linux-arm64-musl': 2.6.0 - '@deckflow/deckprobe-linux-x64-gnu': 2.6.0 - '@deckflow/deckprobe-linux-x64-musl': 2.6.0 - '@deckflow/deckprobe-win32-x64-msvc': 2.6.0 - - '@deckflow/pdf-lite-parse@0.2.1': {} - '@esbuild/aix-ppc64@0.21.5': optional: true @@ -1563,8 +1442,6 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - entities@6.0.1: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -1651,8 +1528,6 @@ snapshots: optionalDependencies: picomatch: 4.0.5 - fflate@0.8.3: {} - fix-dts-default-cjs-exports@1.0.1: dependencies: magic-string: 0.30.21 @@ -1775,10 +1650,6 @@ snapshots: dependencies: yocto-queue: 1.2.2 - parse5@7.3.0: - dependencies: - entities: 6.0.1 - pathe@1.1.2: {} pathe@2.0.3: {} @@ -1849,10 +1720,6 @@ snapshots: run-applescript@7.1.0: {} - saxes@6.0.0: - dependencies: - xmlchars: 2.2.0 - siginfo@2.0.0: {} source-map-js@1.2.1: {} @@ -2005,6 +1872,4 @@ snapshots: dependencies: is-wsl: 3.1.1 - xmlchars@2.2.0: {} - yocto-queue@1.2.2: {} diff --git a/schemas/deckir.v1.schema.json b/schemas/deckir.v1.schema.json deleted file mode 100644 index 2faac79..0000000 --- a/schemas/deckir.v1.schema.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://deckflow.dev/schemas/deckir.v1.schema.json", - "title": "DeckIR v1", - "type": "object", - "required": ["schemaVersion", "format", "source", "producer", "document", "quality"], - "properties": { - "schemaVersion": { "const": "deckir.v1" }, - "format": { "enum": ["pdf", "pptx", "docx", "html", "keynote"] }, - "source": { - "type": "object", - "required": ["sha256", "name", "bytes"], - "properties": { - "sha256": { "type": "string", "pattern": "^[a-f0-9]{64}$" }, - "name": { "type": "string", "minLength": 1 }, - "bytes": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }, - "producer": { - "type": "object", - "required": ["engine", "name", "version"], - "properties": { - "engine": { "enum": ["local", "cloud"] }, - "name": { "type": "string", "minLength": 1 }, - "version": { "type": "string" } - }, - "additionalProperties": false - }, - "document": { - "type": "object", - "required": ["metadata", "pages", "nodes", "assets"], - "properties": { - "metadata": { "type": "object" }, - "pages": { "type": "array", "items": { "$ref": "#/$defs/page" } }, - "nodes": { "type": "array", "items": { "$ref": "#/$defs/node" } }, - "assets": { "type": "array", "items": { "$ref": "#/$defs/asset" } } - }, - "additionalProperties": false - }, - "quality": { "$ref": "#/$defs/quality" } - }, - "additionalProperties": false, - "$defs": { - "sourceRef": { "type": "object" }, - "page": { - "type": "object", - "required": ["id", "index", "nodeIds", "sourceRef"], - "properties": { - "id": { "type": "string" }, "index": { "type": "integer", "minimum": 0 }, - "width": { "type": "number" }, "height": { "type": "number" }, - "nodeIds": { "type": "array", "items": { "type": "string" } }, - "sourceRef": { "$ref": "#/$defs/sourceRef" } - } - }, - "node": { - "type": "object", - "required": ["id", "type", "parentId", "children", "order", "sourceRef"], - "properties": { - "id": { "type": "string" }, "type": { "type": "string" }, - "parentId": { "type": ["string", "null"] }, - "children": { "type": "array", "items": { "type": "string" } }, - "order": { "type": "integer", "minimum": 0 }, "text": { "type": "string" }, - "page": { "type": "integer", "minimum": 0 }, - "bbox": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 }, - "sourceRef": { "$ref": "#/$defs/sourceRef" }, "confidence": { "type": "number" }, - "issues": { "type": "array", "items": { "type": "string" } }, - "opaque": { "type": "object" } - } - }, - "asset": { - "type": "object", - "required": ["id", "path", "hash", "bytes"], - "properties": { - "id": { "type": "string" }, "path": { "type": "string" }, - "hash": { "type": "string" }, "bytes": { "type": "integer", "minimum": 0 }, - "mediaType": { "type": "string" }, "sourceRef": { "$ref": "#/$defs/sourceRef" } - } - }, - "quality": { - "type": "object", - "required": ["status", "checks", "coverage"], - "properties": { - "status": { "enum": ["pass", "degraded", "unsupported"] }, - "checks": { "type": "array", "items": { "type": "object", "required": ["code", "severity", "message"] } }, - "coverage": { "type": "object" }, "recommendation": { "const": "cloud" } - } - } - } -} diff --git a/scripts/copy-deckprobe-wasm.mjs b/scripts/copy-deckprobe-wasm.mjs deleted file mode 100644 index a63b09d..0000000 --- a/scripts/copy-deckprobe-wasm.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; - -const source = path.resolve('node_modules/@deckflow/deckprobe/wasm/deckprobe_wasm_bg.wasm'); -const destination = path.resolve('dist/browser/deckprobe_wasm_bg.wasm'); - -await fs.mkdir(path.dirname(destination), { recursive: true }); -await fs.copyFile(source, destination); diff --git a/scripts/generate-fixtures.mjs b/scripts/generate-fixtures.mjs index f1c5a18..22e51bd 100644 --- a/scripts/generate-fixtures.mjs +++ b/scripts/generate-fixtures.mjs @@ -1,17 +1,9 @@ -/** Deterministic synthetic documents for CI. Never reads or replaces user samples. */ +/** A deterministic synthetic PDF for CI and the browser smoke page. Never reads or replaces user samples. */ import fs from 'node:fs'; import path from 'node:path'; -import { zipSync, strToU8 } from 'fflate'; const directory = path.resolve(import.meta.dirname, '../tests/generated'); fs.mkdirSync(directory, { recursive: true }); -const xml = (body) => `${body}`; -const relNS = 'http://schemas.openxmlformats.org/package/2006/relationships'; -const officeNS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'; -const typeNS = 'http://schemas.openxmlformats.org/package/2006/content-types'; -const archive = (name, entries) => fs.writeFileSync(path.join(directory, name), zipSync(Object.fromEntries( - Object.entries(entries).map(([key, value]) => [key, [typeof value === 'string' ? strToU8(value) : value, { mtime: new Date('2020-01-01T00:00:00Z') }]]) -))); const stream = 'BT /F1 18 Tf 40 140 Td (DeckOps deterministic migration document fixture) Tj ET\n'; const objects = [ @@ -27,25 +19,3 @@ objects.forEach((body, index) => { offsets.push(Buffer.byteLength(pdf)); pdf += const xref = Buffer.byteLength(pdf); pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`; fs.writeFileSync(path.join(directory, 'test.pdf'), pdf); - -archive('test.docx', { - '[Content_Types].xml': xml(``), - '_rels/.rels': xml(``), - 'word/document.xml': xml('DeckOps deterministic migration document fixture'), - 'docProps/app.xml': xml('1'), -}); -archive('test.pptx', { - '[Content_Types].xml': xml(``), - '_rels/.rels': xml(``), - 'ppt/presentation.xml': xml(``), - 'ppt/_rels/presentation.xml.rels': xml(``), - 'ppt/slides/slide1.xml': xml('DeckOps deterministic migration presentation fixture'), -}); -// Minimal IWA identity/structure fixture: uncompressed frame, one document -// ArchiveInfo object and one slide component. It is not a local Keynote parser. -const iwa = new Uint8Array([1, 9, 0, 0, 8, 8, 1, 18, 4, 8, 1, 24, 0]); -archive('test.key', { - 'Index/Document.iwa': iwa, - 'Index/Slide.iwa': iwa, - 'Metadata/Properties.plist': xml('fileFormatVersion1.0isMultiPage'), -}); diff --git a/src/artifact/layout.ts b/src/artifact/layout.ts deleted file mode 100644 index 6ac075f..0000000 --- a/src/artifact/layout.ts +++ /dev/null @@ -1,43 +0,0 @@ -import path from 'node:path'; - -/** - * Artifact directory layout (docs/rfc.md §3.2, pdf.md §7.3). Public contract: - * - * / - * ├── ir.json parse's only semantic output, server response verbatim - * ├── probe.json optional local DeckProbe schema-v2 report - * ├── assets/ images, named by their persistent identity - * ├── manifest.json written last — registered ⇒ exists - * └── views// written only by convert; parse never touches it - */ -export const IR_FILE = 'ir.json'; -export const PROBE_FILE = 'probe.json'; -export const MANIFEST_FILE = 'manifest.json'; -export const ASSETS_DIR = 'assets'; -export const VIEWS_DIR = 'views'; - -export function irPath(dir: string): string { - return path.join(dir, IR_FILE); -} - -export function probePath(dir: string): string { - return path.join(dir, PROBE_FILE); -} - -export function manifestPath(dir: string): string { - return path.join(dir, MANIFEST_FILE); -} - -export function assetsDir(dir: string): string { - return path.join(dir, ASSETS_DIR); -} - -export function viewDir(dir: string, to: string): string { - return path.join(dir, VIEWS_DIR, to); -} - -/** Default artifact directory for a source document: sibling dir named after it. */ -export function defaultArtifactDir(sourcePath: string): string { - const base = path.basename(sourcePath, path.extname(sourcePath)); - return path.join(path.dirname(sourcePath), base); -} diff --git a/src/artifact/manifest.ts b/src/artifact/manifest.ts deleted file mode 100644 index 4a58dcc..0000000 --- a/src/artifact/manifest.ts +++ /dev/null @@ -1,114 +0,0 @@ -import fs from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import path from 'node:path'; -import { createHash } from 'node:crypto'; -import { DeckOpsError } from '../errors/index.js'; -import type { EngineMode, Manifest, ManifestV1, ManifestV1View, ManifestV2, ManifestView } from '../types-legacy.js'; -import { irPath, manifestPath } from './layout.js'; -import { validateDeckIR } from '../ir/validate.js'; - -export const IR_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; - -export async function readManifest(dir: string): Promise { - const file = manifestPath(dir); - let parsed: unknown; - try { parsed = JSON.parse(await fs.readFile(file, 'utf-8')); } - catch (cause) { - if (!existsSync(file)) throw DeckOpsError.input(`${dir} is not an artifact: no ${path.basename(file)}.`, { hint: 'Run `deckops parse ` to create one.', cause }); - throw DeckOpsError.input(`${file} is not valid JSON.`, { hint: 'The artifact is damaged. Re-run parse with --force.', cause }); - } - if (!isRecord(parsed)) throw DeckOpsError.input(`${file} is not a deckops manifest.`); - if (parsed.manifestVersion === 1) { - const manifest = parsed as unknown as ManifestV1; - if (!manifest.parse?.irKey || !manifest.parse.type) throw DeckOpsError.input(`${file} is not a valid manifest v1.`); - return manifest; - } - if (parsed.manifestVersion === 2) { - const manifest = parsed as unknown as ManifestV2; - if (manifest.parse?.schemaVersion !== 'deckir.v1' || !manifest.source?.sha256 || !manifest.quality) throw DeckOpsError.input(`${file} is not a valid manifest v2.`); - try { - const ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(dir), 'utf-8'))); - if (ir.source.sha256 !== manifest.source.sha256 || ir.source.bytes !== manifest.source.bytes || ir.source.name !== manifest.source.name) throw DeckOpsError.input(`${dir} has inconsistent source identities in manifest.json and ir.json.`); - if (ir.format !== manifest.parse.format || ir.producer.engine !== manifest.parse.engine || - ir.producer.name !== manifest.parse.parser.name || ir.producer.version !== manifest.parse.parser.version) { - throw DeckOpsError.input(`${dir} has inconsistent parser metadata in manifest.json and ir.json.`); - } - if (JSON.stringify(ir.quality) !== JSON.stringify(manifest.quality)) throw DeckOpsError.input(`${dir} has inconsistent quality reports in manifest.json and ir.json.`); - const irAssets = new Map(ir.document.assets.map((asset) => [asset.path, asset])); - for (const [relative, asset] of Object.entries(manifest.assets)) { - const target = containedPath(dir, relative); - if (!target) throw DeckOpsError.input(`${dir} registers an unsafe asset path.`); - const stat = await fs.lstat(target); - if (!stat.isFile() || stat.size !== asset.bytes) throw DeckOpsError.input(`${dir} has a missing or truncated registered asset: ${relative}.`); - const hash = createHash('sha256').update(await fs.readFile(target)).digest('hex'); - if (hash !== asset.hash || irAssets.has(relative) && irAssets.get(relative)?.hash !== hash) throw DeckOpsError.input(`${dir} has a corrupted registered asset: ${relative}.`); - } - if ([...irAssets.keys()].some((relative) => !(relative in manifest.assets))) throw DeckOpsError.input(`${dir} has inconsistent asset indexes in manifest.json and ir.json.`); - for (const view of Object.values(manifest.views)) { - if (view?.files.some((relative) => !containedPath(dir, relative))) throw DeckOpsError.input(`${dir} registers an unsafe view path.`); - } - } - catch (cause) { if (cause instanceof DeckOpsError) throw cause; throw DeckOpsError.input(`${dir} has a missing or invalid ir.json.`, { cause }); } - return manifest; - } - throw DeckOpsError.input(`${file} is from an incompatible deckops version.`); -} - -export async function writeManifest(dir: string, manifest: Manifest): Promise { - const file = manifestPath(dir); const tmp = `${file}.tmp`; - await fs.mkdir(dir, { recursive: true }); - await fs.writeFile(tmp, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); - await fs.rename(tmp, file); -} - -export function normalizeParseParams(params: Record): Record { - const clean: Record = { parseProfile: 'balanced', includeImages: true, ...params }; - if ('password' in clean) { clean.passwordProvided = typeof clean.password === 'string'; delete clean.password; } - return sortKeys(clean); -} - -export function sameParams(a: Record, b: Record): boolean { - return JSON.stringify(normalizeParseParams(a)) === JSON.stringify(normalizeParseParams(b)); -} - -export function parseHit(dir: string, manifest: Manifest, sha256: string | undefined, params: Record, engine?: EngineMode, parser?: { name: string; major: number; minor?: number }): boolean { - if (!sha256 || manifest.source.sha256 !== sha256 || !existsSync(irPath(dir))) return false; - if (!sameParams(manifest.parse.params, params)) return false; - if (manifest.manifestVersion === 1) return engine === undefined || engine === 'cloud'; - if (parser && (manifest.parse.parser.name !== parser.name || majorOf(manifest.parse.parser.version) !== parser.major)) return false; - if (parser?.minor !== undefined && Number(manifest.parse.parser.version.split('.')[1]) !== parser.minor) return false; - if (engine === 'local' && manifest.parse.engine !== 'local') return false; - if (engine === 'cloud' && manifest.parse.engine !== 'cloud') return false; - return true; -} - -export function viewHit(dir: string, view: ManifestView | ManifestV1View | undefined, params: Record): boolean { - if (!view || JSON.stringify(sortKeys(view.params)) !== JSON.stringify(sortKeys(params))) return false; - return view.files.length > 0 && view.files.every((file) => { const target = containedPath(dir, file); return Boolean(target && existsSync(target)); }); -} - -export function locallyExpired(manifest: Manifest, now: number = Date.now()): boolean { - if (manifest.manifestVersion === 2 && !manifest.parse.remote) return false; - const expiresAt = manifest.manifestVersion === 2 ? manifest.parse.remote?.expiresAt : undefined; - if (expiresAt) { const expires = Date.parse(expiresAt); return Number.isFinite(expires) && now > expires; } - const createdAt = Date.parse(manifest.parse.createdAt); - return Number.isFinite(createdAt) && now - createdAt > IR_RETENTION_MS; -} - -function sortKeys(obj: Record): Record { - return Object.fromEntries(Object.entries(obj).filter(([, value]) => value !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => [key, canonical(value)])); -} - -function canonical(value: unknown): unknown { - if (Array.isArray(value)) return value.map(canonical); - if (isRecord(value)) return sortKeys(value); - return value; -} - -function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } -function majorOf(version: string): number { const value = Number.parseInt(version.match(/\d+/)?.[0] ?? '', 10); return Number.isFinite(value) ? value : -1; } -function containedPath(dir: string, relative: string): string | undefined { - if (!relative || path.isAbsolute(relative)) return undefined; - const root = path.resolve(dir); const target = path.resolve(root, relative); - return target.startsWith(`${root}${path.sep}`) ? target : undefined; -} diff --git a/src/artifact/store-asset.ts b/src/artifact/store-asset.ts deleted file mode 100644 index d6712df..0000000 --- a/src/artifact/store-asset.ts +++ /dev/null @@ -1,29 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import { createHash, randomUUID } from 'node:crypto'; - -export async function storeAsset(target: string, data: Uint8Array, shareFrom: string[]): Promise { - await fs.mkdir(path.dirname(target), { recursive: true }); - const hash = createHash('sha256').update(data).digest('hex'); - const matches = async (file: string) => { - const stat = await fs.lstat(file).catch(() => undefined); - return stat?.isFile() && stat.size === data.byteLength && createHash('sha256').update(await fs.readFile(file)).digest('hex') === hash ? stat : undefined; - }; - const existing = await matches(target); - for (const source of shareFrom) { - if (path.resolve(source) === path.resolve(target)) continue; - const stat = await matches(source); if (!stat) continue; - if (existing?.dev === stat.dev && existing?.ino === stat.ino) return; - const temp = `${target}.${randomUUID()}.tmp`; - try { - await fs.link(source, temp); - await fs.rename(temp, target); - return; - } catch { /* Cross-device or unavailable hard links: keep a normal artifact file. */ } - finally { await fs.rm(temp, { force: true }); } - } - if (existing) return; - const temp = `${target}.${randomUUID()}.tmp`; - try { await fs.writeFile(temp, data); await fs.rename(temp, target); } - finally { await fs.rm(temp, { force: true }); } -} diff --git a/src/cloud/client.ts b/src/cloud/client.ts deleted file mode 100644 index fb35b0f..0000000 --- a/src/cloud/client.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { statSync } from 'node:fs'; -import path from 'node:path'; -import { createNodeTransport } from './node.js'; -import type { TransportClient } from './transport.js'; -import type { - ConvertOptions as SdkConvertOptions, ConvertRef, ConvertResult, - ParseOptions as SdkParseOptions, ParseResult, ParseSource, ResumeOptions, -} from './parse-facade.js'; -import type { ParseTaskType } from '../types-legacy.js'; -import type { ResolvedCredentials } from '../config/index.js'; -import { PRE_UPLOAD_THRESHOLD } from '../shared/constants.js'; -import { translateError } from '../shared/errors.js'; - -export { PRE_UPLOAD_THRESHOLD } from '../shared/constants.js'; -export { translateError as translate } from '../shared/errors.js'; - -/** - * Product boundary: inject resolved credentials and translate cloud errors. - * Transport, upload, task orchestration and DTOs are owned by this repository. - */ - -export interface CloudClient { - parse(source: ParseSource, options?: SdkParseOptions): Promise>; - convert(ref: ConvertRef, options?: SdkConvertOptions): Promise; - resume(taskId: string, type: ParseTaskType | 'html.getByURL', options?: ResumeOptions): Promise>; -} - -export function createCloudClient(credentials: ResolvedCredentials): CloudClient { - const deck: TransportClient = createNodeTransport({ - root: credentials.apiBase, - retryMutations: false, - // 保存的凭据被拒(过期、被吊销)时直接报 auth_error,不悄悄换成访客:访客的任务落在另一个 - // 空间,结果取不回来;实测命令白等 600 秒,从头到尾没提一句登录。 - allowGuestFallback: false, - ...(credentials.token ? { token: credentials.token } : {}), - ...(credentials.apiKey ? { apiKey: credentials.apiKey } : {}), - ...(credentials.spaceId ? { spaceId: credentials.spaceId } : {}), - }); - - return { - parse: async (source, options) => { - try { - return await deck.parse(await preUploadLarge(deck, source, options?.signal), options); - } catch (error) { - throw translateError(error); - } - }, - convert: async (ref, options) => { - try { - return await deck.convert(ref, options); - } catch (error) { - throw translateError(error); - } - }, - resume: async (taskId, type, options) => { - try { - return await deck.resume(taskId, type, options); - } catch (error) { - throw translateError(error, 'node', taskId); - } - }, - }; -} - -async function preUploadLarge(deck: TransportClient, source: ParseSource, signal?: AbortSignal): Promise { - signal?.throwIfAborted(); - const large = largeUpload(source); - if (!large) { - return source; - } - const uploaded = await deck.files.upload(large.input, { name: large.name, ...(signal ? { signal } : {}) }); - return { fileId: uploaded.id, name: large.name }; -} - -function largeUpload(source: ParseSource): { input: string | Uint8Array; name: string } | undefined { - if (typeof source === 'string') { - try { - if (statSync(source).size >= PRE_UPLOAD_THRESHOLD) { - return { input: source, name: path.basename(source) }; - } - } catch { - // Missing files fail later with the transport's own message. - } - return undefined; - } - if (typeof source === 'object' && source !== null && 'file' in source) { - const { file } = source; - if (typeof file === 'string') { - return largeUpload(file); - } - if (typeof file === 'object' && file !== null && 'input' in file) { - const nested = file as { input: unknown; name?: (string) | undefined }; - if (nested.input instanceof Uint8Array && nested.input.byteLength >= PRE_UPLOAD_THRESHOLD) { - return { input: nested.input, name: nested.name ?? source.name ?? 'upload.bin' }; - } - } - } - return undefined; -} diff --git a/src/core/convert-op.ts b/src/core/convert-op.ts deleted file mode 100644 index ea1b7b5..0000000 --- a/src/core/convert-op.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { createHash } from 'node:crypto'; -import { createReadStream } from 'node:fs'; -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import type { ConvertResult } from '../cloud/parse-facade.js'; -import type { IrFormat } from '../cloud/parse/types.js'; -import { localizeImages, rewriteLinks } from '../node/localize.js'; -import { assetsDir, irPath, viewDir, probePath } from '../artifact/layout.js'; -import { locallyExpired, viewHit, writeManifest } from '../artifact/manifest.js'; -import type { CloudClient } from '../cloud/client.js'; -import { assessCandidate, type Assessment } from '../quality/assessment.js'; -import { routeParse, finalQualityGate } from '../engine/router.js'; -import { DeckOpsError } from '../errors/index.js'; -import { candidateAssetOutputPath } from '../ir/assets.js'; -import type { DeckIR, ParseCandidate } from '../ir/schema.js'; -import { validateDeckIR } from '../ir/validate.js'; -import type { SourceIdentity } from '../local/common.js'; -import { DEFAULT_LOCAL_LIMITS } from '../local/limits.js'; -import type { PreflightMode } from '../shared/preflight.js'; -import { convertParams } from '../shared/params.js'; -import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ManifestV2, OutputFile, ParseFlags } from '../types-legacy.js'; -import { MARKDOWN_RENDERER_VERSION, renderMarkdown } from '../views/markdown.js'; -import type { NodeDocumentInspector } from './inspector.js'; -import type { ResolvedInput } from './input.js'; -import { runSourcePreflight } from './parse-op.js'; -import { withinOperationTimeout } from '../node/timeout.js'; - -export interface ConvertOpOptions { - assessment?: Assessment; - input: ResolvedInput; inputLabel: string; out?: string; flags: ConvertFlags; parseFlags?: ParseFlags; common: CommonFlags; - onContent?: (markdown: string) => void; - client?: CloudClient; cloud?: () => Promise; preflight?: PreflightMode; inspector?: NodeDocumentInspector; -} - -export async function runConvert(options: ConvertOpOptions): Promise { - if ((options.flags.to ?? 'markdown') !== 'markdown') throw DeckOpsError.unsupported(`--to ${String(options.flags.to)} is not supported yet.`); - let content: string | undefined; - const work = { ...options, onContent: (value: string) => { content = value; } }; - let result: ConvertEnvelope; - if (options.input.kind === 'artifact') { - if (options.preflight && options.preflight !== 'off') throw DeckOpsError.usage('Preflight applies to source documents, not an existing artifact.'); - result = await convertArtifact(work, options.input.dir, options.input.manifest); - } else result = await convertOneShot(work); - return { ...result, ...(content !== undefined ? { content } : {}) }; -} - -async function convertArtifact(options: ConvertOpOptions, dir: string, manifest: Manifest): Promise { - const startedAt = Date.now(); - if (manifest.manifestVersion === 2) { - const ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(dir), 'utf8'))); - const probe = manifest.inspection ? await fs.readFile(probePath(dir), 'utf8').then(body => JSON.parse(body) as import('../shared/preflight.js').DeckProbeReport).catch(() => undefined) : undefined; - options = { ...options, assessment: assessCandidate({ ir, quality: ir.quality, assets: [] }, probe) }; - finalQualityGate({ quality: ir.quality, assessment: options.assessment! }, options.common.failOnDegraded); - } - if (manifest.manifestVersion === 2 && options.common.failOnDegraded && manifest.quality.status === 'degraded') { - throw DeckOpsError.input('Artifact quality is degraded.', manifest.quality.checks[0]?.message ? { hint: manifest.quality.checks[0].message } : {}); - } - const useCloud = manifest.manifestVersion === 1 || options.common.engine === 'cloud'; - if (manifest.manifestVersion === 1 && options.common.engine === 'local') { - throw DeckOpsError.unsupported('Manifest v1 contains cloud-native IR, not deckir.v1.', { hint: 'Convert it with --engine cloud or parse the source again to create artifact v2.' }); - } - const viewParams = { ...viewParamsFor(options.flags), rendererEngine: useCloud ? 'cloud' : 'local' }; - const view = manifest.views.markdown; - const rendererMatches = rendererCompatible(manifest, useCloud); - if (!options.out && !options.common.force && rendererMatches && viewHit(dir, view, viewParams)) { - return envelope(options, { startedAt, engine: 'artifact-cache', format: formatOf(manifest), taskId: null, reusedParse: true, - outputs: await Promise.all(view!.files.map(async (file) => ({ file: path.join(dir, file), bytes: (await fs.stat(path.join(dir, file))).size }))), warnings: [], ...(manifest.manifestVersion === 2 ? { quality: manifest.quality } : {}) }); - } - if (useCloud) return convertArtifactCloud(options, dir, manifest, viewParams, startedAt); - return convertArtifactLocal(options, dir, manifest as ManifestV2, viewParams, startedAt); -} - -async function convertArtifactLocal(options: ConvertOpOptions, dir: string, manifest: ManifestV2, viewParams: Record, startedAt: number): Promise { - if (options.flags.strict) throw DeckOpsError.unsupported('Markdown --strict is a cloud-renderer option.', { hint: 'Remove --strict or use --engine cloud on an artifact with a remote reference.' }); - const ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(dir), 'utf-8'))); - if (options.common.failOnDegraded && ir.quality.status === 'degraded') throw DeckOpsError.input('Artifact quality is degraded.', ir.quality.checks[0]?.message ? { hint: ir.quality.checks[0].message } : {}); - const rendered = renderMarkdown(ir, { anchors: options.flags.anchors, splitPages: options.flags.splitPages, assetPrefix: '../../assets/' }); - const outputs = await writeArtifactMarkdown(dir, rendered.markdown, rendered.pages); - manifest.views.markdown = { engine: 'local', rendererVersion: MARKDOWN_RENDERER_VERSION, params: viewParams, - files: outputs.map((output) => path.relative(dir, output.file)), createdAt: new Date().toISOString() }; - await writeManifest(dir, manifest); - if (options.out) outputs.push(...await writePortableLocal(options.out, ir, dir, options.flags, options.onContent)); - return envelope(options, { startedAt, engine: 'local', format: ir.format as IrFormat, taskId: null, reusedParse: true, - outputs, warnings: rendered.warnings, quality: ir.quality }); -} - -async function convertArtifactCloud(options: ConvertOpOptions, dir: string, manifest: Manifest, viewParams: Record, startedAt: number): Promise { - const remote = manifest.manifestVersion === 1 ? { irKey: manifest.parse.irKey } : manifest.parse.remote; - if (!remote) throw DeckOpsError.usage('This local artifact has no cloud IR reference.', { hint: 'Re-run parse with --engine cloud; convert never uploads or re-parses an artifact.' }); - if (locallyExpired(manifest)) throw new DeckOpsError('ir_expired', `The cloud IR behind ${dir} has expired.`, { hint: `Re-run parse for ${manifest.source.name} with --engine cloud --force.` }); - const client = await requireCloud(options); - const result = await callConvert(client, { irKey: remote.irKey }, options.flags, options.common); - const materialized = await materializeCloud(dir, result, options.flags); - if (manifest.manifestVersion === 2) manifest.views.markdown = { engine: 'cloud', rendererVersion: result.schemaVersion, - taskId: result.taskId, params: viewParams, files: materialized.files, createdAt: new Date().toISOString() }; - else manifest.views.markdown = { taskId: result.taskId, params: viewParams, files: materialized.files, createdAt: new Date().toISOString() }; - for (const [file, asset] of Object.entries(materialized.savedAssets)) { - const relative = `assets/${file}`; - const hash = manifest.manifestVersion === 2 ? createHash('sha256').update(await fs.readFile(path.join(dir, relative))).digest('hex') : asset.hash; - manifest.assets[relative] = { key: asset.key, hash, bytes: asset.bytes }; - } - await writeManifest(dir, manifest); - if (options.out) { const portable = await writePortableCloud(options.out, result, options.flags, options.onContent); materialized.outputs.push(...portable.outputs); materialized.warnings.push(...portable.warnings); } - return envelope(options, { startedAt, engine: 'cloud', format: result.format, taskId: result.taskId, reusedParse: true, - outputs: materialized.outputs, warnings: materialized.warnings, ...(manifest.manifestVersion === 2 ? { quality: manifest.quality } : {}) }); -} - -async function convertOneShot(options: ConvertOpOptions): Promise { - const startedAt = Date.now(); const input = options.input; - if (input.kind === 'artifact') throw new Error('unreachable'); - const inspected = await runSourcePreflight({ input, flags: options.parseFlags ?? {}, ...(options.preflight ? { mode: options.preflight } : {}), ...(options.inspector ? { inspector: options.inspector } : {}) }); - const source = await sourceIdentity(input, options.common.engine === 'cloud' ? undefined : (options.common.limits?.sourceBytes ?? DEFAULT_LOCAL_LIMITS.sourceBytes)); - const cloud = cloudFactory(options); - const candidate = await withinOperationTimeout(operationTimeoutMs(options.common), undefined, (signal) => routeParse({ input: { input, inputLabel: options.inputLabel, source }, - ...(inspected.report ? { probe: inspected.report } : {}), - parse: { flags: options.parseFlags ?? {}, common: options.common }, ...(cloud ? { cloud } : {}), - signal })); - if ((options.common.engine === 'cloud' || options.flags.strict) && candidate.remote) { - const result = await callConvert(await requireCloud(options), { irKey: candidate.remote.irKey }, options.flags, options.common); - const target = options.out ?? defaultPortableName(input, options.inputLabel); - const portable = await writePortableCloud(target, result, options.flags, options.onContent); - return { ok: true, op: 'convert', input: options.inputLabel, to: 'markdown', engine: 'cloud', format: result.format, - taskId: result.taskId, parseTaskId: candidate.remote.taskId, ...(inspected.summary ? { inspection: inspected.summary } : {}), - reusedParse: false, ...(candidate.assessment ? { assessment: candidate.assessment } : {}), ...(candidate.decision ? { decision: candidate.decision } : {}), quality: candidate.quality, outputs: portable.outputs, warnings: [...inspected.warnings, ...(candidate.warnings ?? []), ...portable.warnings], durationMs: Date.now() - startedAt }; - } - if (options.flags.strict) throw DeckOpsError.unsupported('Markdown --strict is a cloud-renderer option.', { hint: 'Remove --strict or select --engine cloud.' }); - const target = options.out ?? defaultPortableName(input, options.inputLabel); - const tempDir = target === '-' ? await stdoutAssetDir() : await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-assets-')); - try { - await writeCandidateAssets(tempDir, candidate); - const outputs = await writePortableLocal(target, candidate.ir, tempDir, options.flags, options.onContent); - return { ok: true, op: 'convert', input: options.inputLabel, to: 'markdown', engine: 'local', format: candidate.ir.format as IrFormat, - taskId: null, ...(candidate.remote ? { parseTaskId: candidate.remote.taskId } : {}), ...(inspected.summary ? { inspection: inspected.summary } : {}), - reusedParse: false, ...(candidate.assessment ? { assessment: candidate.assessment } : {}), ...(candidate.decision ? { decision: candidate.decision } : {}), quality: candidate.quality, outputs, warnings: [...inspected.warnings, ...(candidate.warnings ?? []), ...renderMarkdown(candidate.ir).warnings], durationMs: Date.now() - startedAt }; - } finally { if (target !== '-') await fs.rm(tempDir, { recursive: true, force: true }); } -} - -async function writeArtifactMarkdown(dir: string, markdown: string, pages?: string[]): Promise { - const target = viewDir(dir, 'markdown'); await fs.rm(target, { recursive: true, force: true }); await fs.mkdir(target, { recursive: true }); const outputs: OutputFile[] = []; - const write = async (name: string, body: string): Promise => { const file = path.join(target, name); await fs.writeFile(file, body, 'utf-8'); outputs.push({ file, bytes: Buffer.byteLength(body) }); }; - await write('index.md', markdown); if (pages) for (const [index, page] of pages.entries()) await write(`${String(index + 1).padStart(3, '0')}.md`, page); - return outputs; -} - -async function writePortableLocal(target: string, ir: DeckIR, artifactDir: string, flags: ConvertFlags, onContent?: (markdown: string) => void): Promise { - if (target === '-') { onContent?.(renderMarkdown(ir, { anchors: flags.anchors, assetPrefix: path.join(path.resolve(artifactDir), 'assets') + '/' }).markdown); return []; } - const base = target.endsWith('.md') ? target.slice(0, -3) : target; const mdPath = `${base}.md`; const assetsName = `${path.basename(base)}.assets`; - const rendered = renderMarkdown(ir, { anchors: flags.anchors, assetPrefix: `${assetsName}/` }); - await fs.mkdir(path.dirname(path.resolve(mdPath)), { recursive: true }); await fs.writeFile(mdPath, rendered.markdown, 'utf-8'); - const outputs: OutputFile[] = [{ file: mdPath, bytes: Buffer.byteLength(rendered.markdown) }]; - for (const asset of ir.document.assets) { - const from = path.join(artifactDir, asset.path); const to = path.join(path.dirname(mdPath), assetsName, path.basename(asset.path)); - try { await fs.mkdir(path.dirname(to), { recursive: true }); await fs.copyFile(from, to); outputs.push({ file: to, bytes: asset.bytes }); } catch (cause) { throw DeckOpsError.input('Could not export a registered Markdown asset.', { cause }); } - } - return outputs; -} - -async function stdoutAssetDir(): Promise { - const root = path.resolve(process.env.DECKOPS_CACHE_DIR ?? path.join(os.homedir(), '.cache', 'deckops'), 'exports'); - await fs.mkdir(root, { recursive: true, mode: 0o700 }); return fs.mkdtemp(path.join(root, 'assets-')); -} - -async function writeCandidateAssets(dir: string, candidate: ParseCandidate): Promise { - for (const asset of candidate.assets) { const target = path.join(dir, candidateAssetOutputPath(asset)); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, asset.data); } -} - -async function callConvert(client: CloudClient, ref: { irKey: string }, flags: ConvertFlags, common: CommonFlags): Promise { - const result = await client.convert(ref, { to: 'markdown', ...convertParams(flags), ...(common.spaceId ? { spaceId: common.spaceId } : {}), ...(common.timeout ? { wait: { timeout: common.timeout } } : {}) }); - if (result.markdownError) throw DeckOpsError.backend(`Markdown rendering failed: ${result.markdownError}`, { taskId: result.taskId }); - return result; -} - -async function materializeCloud(dir: string, result: ConvertResult, flags: ConvertFlags) { - const localized = await localizeImages({ images: result.images, destDir: assetsDir(dir), linkPrefix: '../../assets/', ...(flags.keepRemoteImages !== undefined ? { keepRemote: flags.keepRemoteImages } : {}) }); - const outputs: OutputFile[] = Object.values(localized.saved).map((asset) => ({ file: path.join(dir, 'assets', asset.file), bytes: asset.bytes })); - const target = viewDir(dir, 'markdown'); await fs.rm(target, { recursive: true, force: true }); await fs.mkdir(target, { recursive: true }); const files: string[] = []; - const write = async (name: string, text: string) => { const body = rewriteLinks(text, localized.rewrites); const file = path.join(target, name); await fs.writeFile(file, body, 'utf-8'); files.push(path.join('views', 'markdown', name)); outputs.push({ file, bytes: Buffer.byteLength(body) }); }; - await write('index.md', result.markdown); if (flags.splitPages && result.markdownPages) for (const [index, page] of result.markdownPages.entries()) await write(`${String(index + 1).padStart(3, '0')}.md`, page); - return { outputs, warnings: localized.warnings, files, savedAssets: localized.saved }; -} - -async function writePortableCloud(target: string, result: ConvertResult, flags: ConvertFlags, onContent?: (markdown: string) => void): Promise<{ outputs: OutputFile[]; warnings: string[] }> { - if (target === '-') { - const dir = await stdoutAssetDir(); - const localized = await localizeImages({ images: result.images, destDir: dir, linkPrefix: dir + '/', ...(flags.keepRemoteImages !== undefined ? { keepRemote: flags.keepRemoteImages } : {}) }); - onContent?.(rewriteLinks(result.markdown, localized.rewrites)); - return { outputs: Object.values(localized.saved).map(asset => ({ file: path.join(dir, asset.file), bytes: asset.bytes })), warnings: localized.warnings }; - } - const base = target.endsWith('.md') ? target.slice(0, -3) : target; const mdPath = `${base}.md`; const assetsName = `${path.basename(base)}.assets`; - const localized = await localizeImages({ images: result.images, destDir: path.join(path.dirname(mdPath), assetsName), linkPrefix: `${assetsName}/`, ...(flags.keepRemoteImages !== undefined ? { keepRemote: flags.keepRemoteImages } : {}) }); - await fs.mkdir(path.dirname(path.resolve(mdPath)), { recursive: true }); const body = rewriteLinks(result.markdown, localized.rewrites); await fs.writeFile(mdPath, body, 'utf-8'); - return { outputs: [{ file: mdPath, bytes: Buffer.byteLength(body) }, ...Object.values(localized.saved).map((asset) => ({ file: path.join(path.dirname(mdPath), assetsName, asset.file), bytes: asset.bytes }))], warnings: localized.warnings }; -} - -function viewParamsFor(flags: ConvertFlags): Record { return { - ...(flags.anchors !== undefined ? { anchors: flags.anchors } : {}), - ...(flags.splitPages !== undefined ? { splitPages: flags.splitPages } : {}), - ...(flags.strict !== undefined ? { strict: flags.strict } : {}), - ...(flags.keepRemoteImages !== undefined ? { keepRemoteImages: flags.keepRemoteImages } : {}), -}; } -function rendererCompatible(manifest: Manifest, useCloud: boolean): boolean { - if (useCloud || manifest.manifestVersion === 1) return true; - const view = manifest.views.markdown; - return view?.engine === 'local' && view.rendererVersion === MARKDOWN_RENDERER_VERSION; -} -function formatOf(manifest: Manifest): IrFormat { if (manifest.manifestVersion === 2) return manifest.parse.format as IrFormat; return manifest.parse.type === 'pdf.pdfParse' ? 'pdf' : manifest.parse.type === 'pptx.parse' ? 'pptx' : manifest.parse.type === 'docx.parseTextAndImage' ? 'docx' : manifest.parse.type === 'keynote.parseTextAndImage' ? 'keynote' : 'html'; } -function defaultPortableName(input: Exclude, label: string): string { if (input.kind === 'document') { const ext = path.extname(input.file); return input.file.slice(0, -ext.length); } if (input.kind === 'stdin') return 'stdin'; try { return new URL(input.url).hostname.replace(/[^\w.-]+/g, '-') || 'page'; } catch { return label; } } -async function sourceIdentity(input: Exclude, maxBytes?: number): Promise { - if (input.kind === 'link') return { sha256: createHash('sha256').update(input.url).digest('hex'), name: input.url, bytes: 0 }; - if (input.kind === 'document') { const stat = await fs.stat(input.file); if (maxBytes !== undefined && stat.size > maxBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); const hash = createHash('sha256'); for await (const chunk of createReadStream(input.file)) hash.update(chunk as Buffer); return { sha256: hash.digest('hex'), name: input.name, bytes: stat.size }; } - if (maxBytes !== undefined && input.data.byteLength > maxBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); - return { sha256: createHash('sha256').update(input.data).digest('hex'), name: input.name, bytes: input.data.byteLength }; -} -function cloudFactory(options: ConvertOpOptions): (() => Promise) | undefined { return options.cloud ?? (options.client ? async () => options.client! : undefined); } -async function requireCloud(options: ConvertOpOptions): Promise { const factory = cloudFactory(options); if (!factory) throw DeckOpsError.usage('Cloud conversion was requested but no cloud client is configured.'); return factory(); } -function operationTimeoutMs(common: CommonFlags): number { const value = common.timeout !== undefined ? common.timeout * 1000 : common.engine === 'cloud' ? 120_000 : (common.limits?.timeoutMs ?? 120_000); if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw DeckOpsError.usage('Timeout must be a positive duration within the Node.js timer range.'); return value; } -function envelope(options: ConvertOpOptions, extra: { startedAt: number; engine: ConvertEnvelope['engine']; format: IrFormat; taskId: string | null; reusedParse: boolean; outputs: OutputFile[]; warnings: string[]; quality?: import('../ir/schema.js').QualityReport }): ConvertEnvelope { return { ok: true, op: 'convert', input: options.inputLabel, to: 'markdown', engine: extra.engine, format: extra.format, taskId: extra.taskId, reusedParse: extra.reusedParse, ...(extra.quality ? { quality: extra.quality } : {}), ...(options.assessment ? { assessment: options.assessment } : {}), outputs: extra.outputs, warnings: extra.warnings, durationMs: Date.now() - extra.startedAt }; } diff --git a/src/core/input.ts b/src/core/input.ts deleted file mode 100644 index 455b726..0000000 --- a/src/core/input.ts +++ /dev/null @@ -1,62 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { DeckOpsError } from '../errors/index.js'; -import type { Manifest, ParseTaskType } from '../types-legacy.js'; -import { readManifest } from '../artifact/manifest.js'; -import { routeExtension } from '../shared/input.js'; - -export { EXTENSION_ROUTES, SUPPORTED_EXTENSIONS, extensionOf, routeExtension } from '../shared/input.js'; - -/** - * Input classification (docs/rfc.md §3.1). Order matters: `-` → stdin, - * http(s) → link, directory with a manifest → artifact, file → document. - * A directory without a valid manifest is an error, not a guess. - */ - -export type ResolvedInput = - | { kind: 'document'; file: string; name: string; taskType: ParseTaskType } - | { kind: 'link'; url: string } - | { kind: 'stdin'; data: Buffer; name: string; taskType: ParseTaskType } - | { kind: 'artifact'; dir: string; manifest: Manifest }; - -export interface ResolveInputOptions { - /** Extension (e.g. "pdf") for stdin input, where there is no file name. */ - from?: string; - /** Injected for tests. */ - readStdin?: () => Buffer; -} - -export async function resolveInput(input: string, options: ResolveInputOptions = {}): Promise { - if (input === '-') { - if (!options.from) { - throw DeckOpsError.usage('Reading from stdin needs --from to pick a parser (e.g. --from pdf).'); - } - const ext = options.from.startsWith('.') ? options.from : `.${options.from}`; - const name = `stdin${ext.toLowerCase()}`; - const taskType = routeExtension(name); - const data = options.readStdin ? options.readStdin() : fs.readFileSync(0); - if (data.length === 0) { - throw DeckOpsError.input('stdin was empty.'); - } - return { kind: 'stdin', data, name, taskType }; - } - - if (/^https?:\/\//i.test(input)) { - return { kind: 'link', url: input }; - } - - let stat: fs.Stats; - try { - stat = fs.statSync(input); - } catch { - throw DeckOpsError.input(`No such file or directory: ${input}`); - } - - if (stat.isDirectory()) { - const manifest = await readManifest(input); - return { kind: 'artifact', dir: input, manifest }; - } - - const name = path.basename(input); - return { kind: 'document', file: input, name, taskType: routeExtension(name) }; -} diff --git a/src/core/inspector.ts b/src/core/inspector.ts deleted file mode 100644 index b9e37ed..0000000 --- a/src/core/inspector.ts +++ /dev/null @@ -1,32 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { probeFile, type ProbeCallOptions, type ProbeResult } from '@deckflow/deckprobe'; -import type { ResolvedInput } from './input.js'; - -export interface NodeDocumentInspector { - inspect( - input: Extract, - options: ProbeCallOptions, - signal?: AbortSignal - ): Promise; -} - -/** Default Node adapter. The dependency initializes its WASM engine lazily. */ -export function createNodeDocumentInspector(): NodeDocumentInspector { - return { - inspect: async (input, options, signal) => { - const runtime = { deadlineMs: 2500, maxInputBytes: 16 * 1024 * 1024, ...(signal ? { signal } : {}) }; - if (input.kind === 'document') { - return probeFile(input.file, options, runtime); - } - if (input.data.byteLength > runtime.maxInputBytes) return { schema_version: 2, tool_version: '2.6.0', status: 'error', error: { code: 'BUDGET_EXCEEDED', message: 'stdin exceeds preflight input limit', exit_code: 4 } }; - const temp = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-probe-')); - try { - const file = path.join(temp, path.basename(input.name)); - await fs.writeFile(file, input.data); - return await probeFile(file, { ...options, sourceKind: 'stdin' }, runtime); - } finally { await fs.rm(temp, { recursive: true, force: true }); } - }, - }; -} diff --git a/src/core/parse-op.ts b/src/core/parse-op.ts deleted file mode 100644 index a37dc1e..0000000 --- a/src/core/parse-op.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createHash } from 'node:crypto'; -import { mkdirSync, writeFileSync, renameSync } from 'node:fs'; -import { createReadStream } from 'node:fs'; -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { validateDeckIR } from '../ir/validate.js'; -import type { ParseCandidate } from '../ir/schema.js'; -import { defaultArtifactDir, irPath, PROBE_FILE, probePath } from '../artifact/layout.js'; -import { normalizeParseParams, parseHit, readManifest, writeManifest } from '../artifact/manifest.js'; -import type { CloudClient } from '../cloud/client.js'; -import { storeAsset } from '../artifact/store-asset.js'; -import { routeParse } from '../engine/router.js'; -import { DeckOpsError } from '../errors/index.js'; -import { candidateAssetOutputPath } from '../ir/assets.js'; -import { cloudProducerVersion } from '../ir/cloud-adapter.js'; -import type { SourceIdentity } from '../local/common.js'; -import { DEFAULT_LOCAL_LIMITS } from '../local/limits.js'; -import { - assessPreflight, DEFAULT_PREFLIGHT_MODE, PREFLIGHT_REQUEST_VERSION, preflightProbeOptions, - type DeckProbeReport, type PreflightMode, type PreflightOutcome, -} from '../shared/preflight.js'; -import { parseParams } from '../shared/params.js'; -import { formatForTaskType } from '../shared/validation.js'; -import type { CommonFlags, Manifest, ManifestInspection, ManifestV2, OutputFile, ParseEnvelope, ParseFlags } from '../types-legacy.js'; -import { PDF_PARSER_VERSION, VERSION } from '../version.js'; -import { createNodeDocumentInspector, type NodeDocumentInspector } from './inspector.js'; -import { withinOperationTimeout } from '../node/timeout.js'; -import type { ResolvedInput } from './input.js'; - -export interface ParseOpOptions { - input: ResolvedInput; inputLabel: string; out?: string; flags: ParseFlags; common: CommonFlags; - /** Backward-compatible injection for tests and callers; only touched on a cloud route. */ - client?: CloudClient; - cloud?: () => Promise; - preflight?: PreflightMode; inspector?: NodeDocumentInspector; -} - -export async function runParse(options: ParseOpOptions): Promise { - const directory = options.out ?? (options.input.kind === 'artifact' ? options.input.dir : defaultDirFor(options.input)); - const lockPath = path.resolve(directory) + '.lock'; - await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 }); - let lock: Awaited>; - try { lock = await fs.open(lockPath, 'wx', 0o600); } - catch (cause) { throw DeckOpsError.input('Artifact is already in use or its lock cannot be created.', { hint: `Retry when the active operation finishes. A stale lock after a crash can be inspected at ${lockPath}.`, cause }); } - try { - await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })); - return await withinOperationTimeout(operationTimeoutMs(options.common), options.common.signal, (signal) => runParseLocked(options, signal), - 'If a cloud task was created, rerunning the same command resumes it.'); - } - finally { await lock.close(); await fs.rm(lockPath, { force: true }); } -} - -async function runParseLocked(options: ParseOpOptions, signal: AbortSignal): Promise { - signal.throwIfAborted(); - // Freeze local source bytes for probe, candidate comparison and possible upload. - if (options.input.kind === 'document') { - const original = options.input; - const stat = await fs.stat(original.file); - if (stat.size > (options.common.limits?.sourceBytes ?? DEFAULT_LOCAL_LIMITS.sourceBytes)) throw DeckOpsError.input('Source exceeds the input size limit.'); - const temporary = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-source-')); - try { - const snapshot = path.join(temporary, original.name); - await fs.copyFile(original.file, snapshot); signal.throwIfAborted(); - const after = await fs.stat(original.file); - if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) throw DeckOpsError.input('Source changed while creating its snapshot.'); - return await runFrozenParse({ ...options, out: options.out ?? defaultDirFor(original), input: { ...original, file: snapshot } }, signal); - } finally { await fs.rm(temporary, { recursive: true, force: true }); } - } - return runFrozenParse(options, signal); -} - -async function runFrozenParse(options: ParseOpOptions, signal: AbortSignal): Promise { - const { input, inputLabel, flags, common } = options; - const startedAt = Date.now(); - if (input.kind === 'artifact') throw DeckOpsError.usage(`${inputLabel} is already an artifact.`, { hint: 'Run deckops convert to derive a view.' }); - const requestedEngine = common.engine ?? 'local'; - const source = await sourceIdentity(input, common.limits?.sourceBytes ?? DEFAULT_LOCAL_LIMITS.sourceBytes); - signal.throwIfAborted(); - const params = normalizeParseParams({ ...parseParams(flags, input.kind === 'link'), - ...(flags.pageFurniture ? { pageFurniture: flags.pageFurniture } : {}), - ...(flags.overlaidText ? { overlaidText: flags.overlaidText } : {}), - ...(flags.trackedChanges ? { trackedChanges: flags.trackedChanges } : {}), - ...(common.limits ? { limits: common.limits } : {}) }); - const dir = options.out ?? defaultDirFor(input); - const candidateEngine = requestedEngine === 'cloud' ? 'cloud' : 'local'; - let cached: ParseCandidate | undefined; - let storedInspection: DeckProbeReport | undefined; - if (!common.force && input.kind !== 'link') { - const primary = await readManifest(dir).catch(() => undefined); - const hasPrimary = await fs.access(path.join(dir, 'manifest.json')).then(() => true, () => false); - const locations = primary?.manifestVersion === 2 && primary.parse.engine !== candidateEngine || !hasPrimary ? [path.join(dir, 'candidates', candidateEngine), dir] : [dir]; - for (const location of locations) { - const existing = await readManifest(location).catch(() => undefined); - if (existing?.manifestVersion === 2 && parseHit(location, existing, source.sha256, params, candidateEngine, expectedParser(input, candidateEngine))) { - const ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(location), 'utf8'))); - cached = { ir, quality: ir.quality, assets: await Promise.all(ir.document.assets.map(async asset => ({ path: asset.path, data: new Uint8Array(await fs.readFile(path.join(location, asset.path))), ...(asset.mediaType ? { mediaType: asset.mediaType } : {}) }))), ...(existing.parse.remote ? { remote: existing.parse.remote } : {}) }; - storedInspection = await readStoredInspection(location, existing); break; - } - } - } - let cachedCloud: ParseCandidate | undefined; - if (requestedEngine === 'auto' && !common.force && input.kind !== 'link') { - const location = path.join(dir, 'candidates', 'cloud'); - const manifest = await readManifest(location).catch(() => undefined); - if (manifest?.manifestVersion === 2 && parseHit(location, manifest, source.sha256, params, 'cloud', expectedParser(input, 'cloud'))) { - const ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(location), 'utf8'))); - cachedCloud = { ir, quality: ir.quality, assets: await Promise.all(ir.document.assets.map(async asset => ({ path: asset.path, data: new Uint8Array(await fs.readFile(path.join(location, asset.path))) }))), ...(manifest.parse.remote ? { remote: manifest.parse.remote } : {}) }; - } - } - const journalPath = path.join(dir, 'upgrade.json'); - const journal = await fs.readFile(journalPath, 'utf8').then(body => JSON.parse(body)).catch(() => undefined); - const previousSubmission = journal?.sourceHash === source.sha256 && JSON.stringify(journal.params) === JSON.stringify(params) && ['submitted', 'submission_unknown'].includes(journal.status) ? { status: journal.status as string, ...(typeof journal.taskId === 'string' ? { taskId: journal.taskId } : {}), ...(typeof journal.spaceId === 'string' ? { spaceId: journal.spaceId } : {}) } : undefined; - const inspected = await runPreflight({ input, flags, mode: options.preflight ?? DEFAULT_PREFLIGHT_MODE, signal, - ...(options.inspector ? { inspector: options.inspector } : {}), ...(storedInspection ? { stored: storedInspection } : {}) }); - const cloud = options.cloud ?? (options.client ? async () => options.client! : undefined); - const taskLog: Array<{ taskId: string }> = []; - const candidate = await routeParse({ input: { input, inputLabel, source }, parse: { flags, common }, - ...(cloud ? { cloud } : {}), signal, ...(cached ? { cached } : {}), - ...(inspected.outcome.report ? { probe: inspected.outcome.report } : {}), - ...(cachedCloud ? { cachedCloud } : {}), ...(previousSubmission ? { previousSubmission } : {}), - onSubmission: state => { - mkdirSync(dir, { recursive: true, mode: 0o700 }); - const temp = `${journalPath}.tmp`; - writeFileSync(temp, JSON.stringify({ sourceHash: source.sha256, params, ...state, updatedAt: new Date().toISOString() }) + '\n', { mode: 0o600 }); - renameSync(temp, journalPath); - }, - onCandidate: async value => { await persistCandidate(path.join(dir, 'candidates', value.ir.producer.engine), value, params, inspected.outcome, [path.join(dir, 'candidates', value.ir.producer.engine === 'local' ? 'cloud' : 'local'), dir]); }, - onTask: task => { taskLog.push({ taskId: task.id }); }, - }); - common.signal?.throwIfAborted(); - if (candidate.quality.status === 'unsupported') throw DeckOpsError.unsupported('The selected engine could not produce a trustworthy document structure.'); - const { outputs, manifest } = await persistCandidate(dir, candidate, params, inspected.outcome, [path.join(dir, 'candidates', candidate.ir.producer.engine)]); - const assessmentFile = path.join(dir, 'assessment.json'); - await fs.writeFile(assessmentFile, JSON.stringify(candidate.assessment, null, 2) + '\n'); - const runFile = path.join(dir, 'run.json'); - await fs.writeFile(runFile, JSON.stringify({ schemaVersion: 'deckops.run.v1', assessment: candidate.assessment, decision: candidate.decision, tasks: taskLog }, null, 2) + '\n'); - outputs.push({ file: assessmentFile, bytes: (await fs.stat(assessmentFile)).size }, { file: runFile, bytes: (await fs.stat(runFile)).size }); - return { ...envelope(inputLabel, dir, manifest, { reused: candidate === cached || candidate === cachedCloud, startedAt, outputs, - warnings: [...inspected.outcome.warnings, ...(candidate.warnings ?? []), ...candidate.quality.checks.filter(c => c.severity !== 'info').map(c => c.message)], - ...(inspected.outcome.summary ? { inspection: inspected.outcome.summary } : {}) }), - ...(candidate.assessment ? { assessment: candidate.assessment } : {}), ...(candidate.decision ? { decision: candidate.decision } : {}) }; -} - -async function persistCandidate(dir: string, candidate: ParseCandidate, params: Record, inspected: PreflightOutcome, shareDirs: string[] = []): Promise<{ outputs: OutputFile[]; manifest: ManifestV2 }> { - await fs.mkdir(dir, { recursive: true, mode: 0o700 }); - const outputs: OutputFile[] = []; - const written = new Set(); - for (const asset of candidate.assets) { - const relative = candidateAssetOutputPath(asset); - if (written.has(relative)) continue; - written.add(relative); - const target = path.join(dir, relative); await fs.mkdir(path.dirname(target), { recursive: true }); - await storeAsset(target, asset.data, shareDirs.map(dir => path.join(dir, relative))); outputs.push({ file: target, bytes: asset.data.byteLength }); - } - const irJson = JSON.stringify(candidate.ir, null, 2) + '\n'; - await fs.writeFile(irPath(dir), irJson, 'utf8'); outputs.unshift({ file: irPath(dir), bytes: Buffer.byteLength(irJson) }); - if (inspected.report) outputs.push(await writeProbeReport(dir, inspected.report)); - const manifest = buildManifest(candidate.ir, params, candidate.remote, inspected); - await writeManifest(dir, manifest); outputs.push({ file: path.join(dir, 'manifest.json'), bytes: (await fs.stat(path.join(dir, 'manifest.json'))).size }); - return { outputs, manifest }; -} - -export async function runSourcePreflight(options: { input: Exclude; flags: ParseFlags; mode?: PreflightMode; inspector?: NodeDocumentInspector }): Promise { - return (await runPreflight({ input: options.input, flags: options.flags, mode: options.mode ?? DEFAULT_PREFLIGHT_MODE, - ...(options.inspector ? { inspector: options.inspector } : {}) })).outcome; -} - -interface PreflightRun { outcome: PreflightOutcome; fresh: boolean } -async function runPreflight(options: { input: Exclude; flags: ParseFlags; mode: PreflightMode; inspector?: NodeDocumentInspector; signal?: AbortSignal; stored?: DeckProbeReport }): Promise { - if (options.mode === 'off') return { outcome: { warnings: [] }, fresh: false }; - if (options.input.kind === 'link') return { outcome: { warnings: ['DeckProbe preflight is local-only and was skipped for the URL input.'] }, fresh: false }; - const format = formatForTaskType(options.input.taskType); - if (!format || format === 'link') return { outcome: { warnings: [] }, fresh: false }; - const assess = (report: import('../shared/preflight.js').DeckProbeResult): PreflightOutcome => assessPreflight(report, { - mode: options.mode as Exclude, format, passwordProvided: Boolean(options.flags.password), - }); - if (options.stored) return { outcome: assess(options.stored), fresh: false }; - const inspector = options.inspector ?? createNodeDocumentInspector(); - try { - const result = await inspector.inspect(options.input, preflightProbeOptions(format), options.signal); - return { outcome: assess(result), fresh: result.status !== 'error' }; - } catch (error) { - if (error instanceof DeckOpsError) throw error; - const message = error instanceof Error ? error.message : String(error); - if (options.mode === 'strict') throw DeckOpsError.input(`DeckProbe preflight failed to run: ${message}`, { hint: 'Retry with preflight validate/off.', cause: error }); - return { outcome: { warnings: [`DeckProbe preflight failed to run: ${message}. Parsing will continue.`] }, fresh: false }; - } -} - -async function sourceIdentity(input: Exclude, maxBytes?: number): Promise { - if (input.kind === 'link') return { sha256: createHash('sha256').update(input.url).digest('hex'), name: input.url, bytes: 0 }; - if (input.kind === 'document') { - const stat = await fs.stat(input.file); - if (maxBytes !== undefined && stat.size > maxBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); - const hash = createHash('sha256'); - for await (const chunk of createReadStream(input.file)) hash.update(chunk as Buffer); - return { sha256: hash.digest('hex'), name: input.name, bytes: stat.size }; - } - if (maxBytes !== undefined && input.data.byteLength > maxBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); - return { sha256: createHash('sha256').update(input.data).digest('hex'), name: input.name, bytes: input.data.byteLength }; -} - -function buildManifest(ir: import('../ir/schema.js').DeckIR, params: Record, remote: import('../ir/schema.js').ParseCandidate['remote'], inspected: PreflightOutcome): ManifestV2 { - return { - manifestVersion: 2, source: ir.source, - parse: { engine: ir.producer.engine, format: ir.format, schemaVersion: 'deckir.v1', parser: { name: ir.producer.name, version: ir.producer.version }, - params, createdAt: new Date().toISOString(), remote: remote ?? null }, - ...(inspected.report && inspected.summary ? { inspection: manifestInspection(inspected.report, inspected.summary) } : {}), - quality: ir.quality, views: {}, assets: Object.fromEntries(ir.document.assets.map((asset) => [asset.path, { key: asset.hash, hash: asset.hash, bytes: asset.bytes, ...(asset.mediaType ? { mediaType: asset.mediaType } : {}) }])), - producer: { deckparse: VERSION }, - }; -} - -function defaultDirFor(input: Exclude): string { - if (input.kind === 'document') return defaultArtifactDir(input.file); - if (input.kind === 'stdin') return 'stdin'; - const url = new URL(input.url); return `${url.hostname}${url.pathname}`.replace(/[^\w.-]+/g, '-').replace(/-+$/, '') || 'page'; -} - -function expectedParser(input: Exclude, engine: NonNullable): { name: string; major: number; minor?: number } | undefined { - if (engine === 'cloud') return { name: 'deckflow-cloud', major: Number(cloudProducerVersion(input.kind === 'link' ? undefined : formatForTaskType(input.taskType))) }; - if (engine === 'auto' || input.kind === 'link') return undefined; - if (input.taskType === 'pdf.pdfParse') { - const [major, minor] = PDF_PARSER_VERSION.split('.').map(Number); - // Pre-1.0 minor releases can change parsing semantics (e.g. embedded image defaults). - return { name: 'pdf-lite-parse', major: major!, ...(major === 0 ? { minor: minor! } : {}) }; - } - if (input.taskType === 'pptx.parse') return { name: 'deckparse-pptx', major: 5 }; - if (input.taskType === 'docx.parseTextAndImage') return { name: 'deckparse-docx', major: 3 }; - return undefined; -} - -function operationTimeoutMs(common: CommonFlags): number { - const value = common.timeout !== undefined ? common.timeout * 1000 : common.engine === 'cloud' ? 120_000 : (common.limits?.timeoutMs ?? 120_000); - if (!Number.isSafeInteger(value) || value <= 0 || value > 2_147_483_647) throw DeckOpsError.usage('Timeout must be a positive duration within the Node.js timer range.'); - return value; -} - -async function readStoredInspection(dir: string, manifest: Manifest): Promise { - const inspection = manifest.inspection; - if (!inspection || inspection.file !== PROBE_FILE || inspection.requestVersion !== PREFLIGHT_REQUEST_VERSION) return undefined; - try { const parsed = JSON.parse(await fs.readFile(probePath(dir), 'utf-8')) as DeckProbeReport; return parsed.schema_version === 2 && (parsed.status === 'ok' || parsed.status === 'partial') ? parsed : undefined; } catch { return undefined; } -} - -async function writeProbeReport(dir: string, report: DeckProbeReport): Promise { - const body = `${JSON.stringify(report, null, 2)}\n`; await fs.writeFile(probePath(dir), body, 'utf-8'); return { file: probePath(dir), bytes: Buffer.byteLength(body) }; -} - -function manifestInspection(report: DeckProbeReport, summary: import('../shared/preflight.js').PreflightSummary): ManifestInspection { - return { file: PROBE_FILE, schemaVersion: 2, toolVersion: report.tool_version, requestVersion: PREFLIGHT_REQUEST_VERSION, - status: report.status, summary, createdAt: new Date().toISOString() }; -} - -function envelope(input: string, dir: string, manifest: ManifestV2, extra: { reused: boolean; startedAt: number; outputs: OutputFile[]; warnings: string[]; inspection?: import('../shared/preflight.js').PreflightSummary }): ParseEnvelope { - const type = manifest.parse.format === 'pdf' ? 'pdf.pdfParse' : manifest.parse.format === 'pptx' ? 'pptx.parse' : manifest.parse.format === 'docx' ? 'docx.parseTextAndImage' : manifest.parse.format === 'keynote' ? 'keynote.parseTextAndImage' : 'html.getByURL'; - return { ok: true, op: 'parse', input, engine: extra.reused ? 'artifact-cache' : manifest.parse.engine, type, format: manifest.parse.format, - quality: manifest.quality, taskId: extra.reused ? null : (manifest.parse.remote?.taskId ?? null), reusedParse: extra.reused, - ...(manifest.parse.remote ? { irKey: manifest.parse.remote.irKey } : {}), irSchemaVersion: 'deckir.v1', artifact: dir, - ...(extra.inspection ? { inspection: extra.inspection } : {}), outputs: extra.outputs, warnings: extra.warnings, durationMs: Date.now() - extra.startedAt }; -} diff --git a/src/core/read-op.ts b/src/core/read-op.ts deleted file mode 100644 index e0423f1..0000000 --- a/src/core/read-op.ts +++ /dev/null @@ -1,102 +0,0 @@ -import fs from 'node:fs/promises'; -import os from 'node:os'; -import path from 'node:path'; -import { createHash, randomUUID } from 'node:crypto'; -import { runParse, type ParseOpOptions } from './parse-op.js'; -import { irPath, probePath } from '../artifact/layout.js'; -import { validateDeckIR } from '../ir/validate.js'; -import { renderMarkdown } from '../views/markdown.js'; -import type { DeckProbeReport } from '../shared/preflight.js'; -import { assessCandidate, type Assessment } from '../quality/assessment.js'; -import { finalQualityGate } from '../engine/router.js'; -import type { RouteDecision } from '../engine/policy.js'; -import type { DeckIR } from '../ir/schema.js'; -import type { OutputFile } from '../types-legacy.js'; -import { DeckOpsError } from '../errors/index.js'; - -export interface ReadReport { - schemaVersion: 'deckops.run.v1'; execution: 'completed'; completeness: 'partial' | 'unknown'; - selected: { parseEngine: 'local' | 'cloud'; renderer: 'local'; cacheHit: boolean }; - assessment: Assessment; decision?: RouteDecision; artifactBase: string; warnings: string[]; -} -export interface ReadResult { - schemaVersion: 'deckops.read.v1'; ok: true; format: F; - content: (F extends 'ir' ? DeckIR : string) | null; - outputs: OutputFile[]; - assets: Array<{ path: string; lifecycle: 'cache' | 'output'; bytes: number }>; - report: ReadReport; -} -export interface ReadOpOptions extends Omit { - out?: string; format?: 'markdown' | 'ir'; reportFile?: string; anchors?: boolean; splitPages?: boolean; - cacheDir?: string; -} -export async function runRead(options: ReadOpOptions): Promise { - const format = options.format ?? 'markdown'; - if (!['markdown', 'ir'].includes(format)) throw DeckOpsError.usage('format must be markdown or ir.'); - if (options.splitPages) throw DeckOpsError.usage('Use deckops convert on an artifact for --split-pages.'); - const output = options.out && options.out !== '-' ? path.resolve(options.out) : undefined; - if (format === 'ir' && output) throw DeckOpsError.usage('Use deckops parse -o DIR to export portable IR with assets.'); - if (options.reportFile === '-') throw DeckOpsError.usage('--report must be a file; stdout is reserved for content.'); - const reportFile = options.reportFile ? path.resolve(options.reportFile) : undefined; - const sourceFile = options.input.kind === 'document' ? path.resolve(options.input.file) : undefined; - const paths = [output, reportFile, sourceFile].filter((x): x is string => !!x); - for (let i = 0; i < paths.length; i++) for (let j = i + 1; j < paths.length; j++) { - const canonical = async (p: string) => fs.realpath(p).catch(() => fs.realpath(path.dirname(p)).then(parent => path.join(parent, path.basename(p))).catch(() => p)); - if (await canonical(paths[i]!) === await canonical(paths[j]!)) throw DeckOpsError.usage('Source, output and report paths must be distinct.'); - } - options.common.signal?.throwIfAborted(); - let artifactBase: string, ir: DeckIR, assessment: Assessment, decision: RouteDecision | undefined, warnings: string[] = [], cacheHit = false; - if (options.input.kind === 'artifact') { - if (options.input.manifest.manifestVersion !== 2) throw DeckOpsError.unsupported('Use explicit convert for legacy cloud-native artifacts.'); - artifactBase = path.resolve(options.input.dir); - ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(artifactBase), 'utf8'))); - const storedProbe = options.input.manifest.inspection ? await fs.readFile(probePath(artifactBase), 'utf8').then(body => JSON.parse(body) as DeckProbeReport).catch(() => undefined) : undefined; - assessment = assessCandidate({ ir, quality: ir.quality, assets: [] }, storedProbe); cacheHit = true; - } else { - const root = options.cacheDir ?? process.env.DECKOPS_CACHE_DIR ?? path.join(os.homedir(), '.cache', 'deckops'); - // URL fetches and password-bearing parses are never persistently reused by identity alone. - const key = createHash('sha256').update(JSON.stringify([options.input.kind === 'document' ? path.resolve(options.inputLabel) : options.inputLabel, { ...options.flags, password: undefined }, options.common.limits ?? {}])).digest('hex'); - const unique = options.input.kind !== 'document' || options.flags.password ? `-${randomUUID()}` : ''; - artifactBase = path.resolve(root, key + unique); - const parsed = await runParse({ ...options, out: artifactBase, common: { ...options.common, failOnDegraded: false } }); - ir = validateDeckIR(JSON.parse(await fs.readFile(irPath(artifactBase), 'utf8'))); - assessment = parsed.assessment ?? assessCandidate({ ir, quality: ir.quality, assets: [] }); decision = parsed.decision; if (decision) decision.policy.acceptance = options.common.failOnDegraded ? 'no-degraded' : 'best-effort'; warnings = parsed.warnings; cacheHit = parsed.reusedParse; - } - const assets = await Promise.all(ir.document.assets.map(async asset => { - const file = path.join(artifactBase, asset.path); - const stat = await fs.stat(file).catch(() => undefined); - if (!stat?.isFile() || stat.size !== asset.bytes) throw DeckOpsError.input('An exported image is unavailable; no content was published.'); - return { path: file, bytes: asset.bytes, lifecycle: 'cache' as const }; - })); - const base = output?.endsWith('.md') ? output.slice(0, -3) : output; - // Immutable asset directory: a failed overwrite must not damage the previous Markdown's links. - const assetsName = base ? `${path.basename(base)}.assets-${randomUUID().slice(0, 8)}` : undefined; - const prefix = output ? `${assetsName}/` : `${path.join(artifactBase, 'assets')}/`; - const rendered = renderMarkdown(ir, { anchors: options.anchors, assetPrefix: prefix }); - const hasSourceText = ir.document.nodes.some(n => (n.text ?? '').trim()); - if (format === 'markdown' && hasSourceText && !rendered.markdown.trim()) assessment.issues.push({ code: 'markdown_content_missing', severity: 'error', message: 'Markdown is empty although the IR contains text.', impact: 'delivery', evidenceKind: 'source_comparison' }); - if (assessment.issues.some(i => i.severity !== 'info' && i.impact !== 'informational')) assessment.status = 'needs_attention'; - finalQualityGate({ quality: ir.quality, assessment }, options.common.failOnDegraded); - options.common.signal?.throwIfAborted(); - const report: ReadReport = { schemaVersion: 'deckops.run.v1', execution: 'completed', completeness: assessment.issues.some(i => ['missing_slide', 'page_parse_failed', 'source_page_count_mismatch', 'source_object_loss'].includes(i.code)) ? 'partial' : 'unknown', - selected: { parseEngine: ir.producer.engine, renderer: 'local', cacheHit }, assessment, ...(decision ? { decision } : {}), artifactBase, - warnings: [...new Set([...warnings, ...(format === 'markdown' ? rendered.warnings : [])])] }; - const outputs: OutputFile[] = []; - if (output) { - await fs.mkdir(path.dirname(output), { recursive: true }); - const assetDir = path.join(path.dirname(output), assetsName!); - const temp = `${output}.${randomUUID()}.tmp`; - try { - if (assets.length) await fs.mkdir(assetDir, { recursive: false }); - for (const asset of assets) { const file = path.join(assetDir, path.basename(asset.path)); await fs.copyFile(asset.path, file); outputs.push({ file, bytes: asset.bytes }); } - await fs.writeFile(temp, rendered.markdown, 'utf8'); - options.common.signal?.throwIfAborted(); - await fs.rename(temp, output); outputs.unshift({ file: output, bytes: Buffer.byteLength(rendered.markdown) }); - } catch (cause) { - await fs.rm(temp, { force: true }); await fs.rm(assetDir, { recursive: true, force: true }); - throw DeckOpsError.input('Could not deliver Markdown and its assets.', { cause }); - } - } - if (reportFile) { await fs.mkdir(path.dirname(reportFile), { recursive: true }); const temp = `${reportFile}.${randomUUID()}.tmp`; try { await fs.writeFile(temp, JSON.stringify(report, null, 2) + '\n'); await fs.rename(temp, reportFile); } finally { await fs.rm(temp, { force: true }); } } - return { schemaVersion: 'deckops.read.v1', ok: true, format, content: output ? null : format === 'ir' ? ir : rendered.markdown, outputs, assets: output ? assets.map(asset => ({ ...asset, path: path.join(path.dirname(output), assetsName!, path.basename(asset.path)), lifecycle: 'output' as const })) : assets, report }; -} diff --git a/src/core/validation.ts b/src/core/validation.ts deleted file mode 100644 index 1425eb7..0000000 --- a/src/core/validation.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { DeckOpsError } from '../errors/index.js'; -import type { ConvertFlags, ParseFlags } from '../types-legacy.js'; -import type { ResolvedInput } from './input.js'; -import { - formatForTaskType, - validateConvertFlagsForFormat, - validateParseFlagsForFormat, - type FormatKey, -} from '../shared/validation.js'; - -/** - * Flag × format validation (docs/rfc.md §5.2). A flag that cannot apply is a - * usage error before any task is sent — the legacy backends silently ignored - * unknown params, so the client is the only place this can be caught. - */ - -function formatOf(input: ResolvedInput): FormatKey | undefined { - if (input.kind === 'link') { - return 'link'; - } - if (input.kind === 'artifact') { - if (input.manifest.manifestVersion === 2) return input.manifest.parse.format === 'html' ? 'link' : input.manifest.parse.format === 'keynote' ? 'keynote' : input.manifest.parse.format; - return formatForTaskType(input.manifest.parse.type) ?? 'link'; - } - return formatForTaskType(input.taskType); -} - -export function validateParseFlags(input: ResolvedInput, flags: ParseFlags): void { - if (flags.profile && !['fast', 'balanced', 'quality'].includes(flags.profile)) throw DeckOpsError.usage('--profile must be fast, balanced, or quality.'); - if (flags.pageFurniture && !['off', 'drop', 'extract'].includes(flags.pageFurniture)) throw DeckOpsError.usage('--page-furniture must be off, drop, or extract.'); - if (flags.overlaidText && !['auto', 'keep', 'drop'].includes(flags.overlaidText)) throw DeckOpsError.usage('--overlaid-text must be auto, keep, or drop.'); - if (flags.trackedChanges && !['final', 'original', 'all'].includes(flags.trackedChanges)) throw DeckOpsError.usage('--tracked-changes must be final, original, or all.'); - if (flags.mode && !['source', 'runtime'].includes(flags.mode)) throw DeckOpsError.usage('--mode must be source or runtime.'); - const format = formatOf(input); - if (input.kind === 'artifact') { - const set = Object.entries(flags).filter(([, value]) => value !== undefined); - if (set.length > 0) { - throw DeckOpsError.usage( - `Parse flags (${set.map(([name]) => `--${kebab(name)}`).join(', ')}) cannot apply to an artifact — it is already parsed.` - ); - } - return; - } - validateParseFlagsForFormat(format, flags); -} - -export function validateConvertFlags(input: ResolvedInput, flags: ConvertFlags): void { - const format = formatOf(input); - validateConvertFlagsForFormat(format, flags); -} - -function kebab(name: string): string { - return name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); -} diff --git a/src/engine/cloud.ts b/src/engine/cloud.ts deleted file mode 100644 index 57d3d25..0000000 --- a/src/engine/cloud.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { cloudSupport } from './policy.js'; -import { DeckOpsError } from '../errors/index.js'; -import type { ParseSource } from '../cloud/parse-facade.js'; -import { cloudResultToCandidate } from '../ir/cloud-adapter.js'; -import type { CloudClient } from '../cloud/client.js'; -import type { EngineParseInput, EngineParseOptions, ParseEngine, SupportDecision } from './types.js'; - -export class CloudEngine implements ParseEngine { - readonly id = 'cloud' as const; - constructor(private readonly client: CloudClient) {} - supports(input: EngineParseInput, options: EngineParseOptions): SupportDecision { return cloudSupport(input, options.flags); } - async parse(input: EngineParseInput, options: EngineParseOptions, signal: AbortSignal) { - signal.throwIfAborted(); - if (!this.supports(input, options).supported) throw DeckOpsError.unsupported('Cloud parser cannot preserve the requested parameters.'); - const parsed = await this.client.parse(sourceFor(input.input), { - signal, - ...(options.onSubmit ? { onSubmit: options.onSubmit } : {}), - ...(options.onTask ? { onTask: options.onTask } : {}), - ...cloudParams(options.flags, input.input.kind === 'link'), - ...(options.common.spaceId ? { spaceId: options.common.spaceId } : {}), - ...(options.common.timeout ? { wait: { timeout: options.common.timeout } } : {}), - }); - if (!input.source) throw new Error('Cloud parsing needs a resolved source identity.'); - return cloudResultToCandidate(parsed, input.source, signal); - } - - /** 接着等上一次提交的任务并取回结果:不上传、不建新任务。 */ - async resume(input: EngineParseInput, submission: { taskId: string; spaceId?: string | undefined }, options: EngineParseOptions, signal: AbortSignal) { - signal.throwIfAborted(); - const spaceId = submission.spaceId ?? options.common.spaceId; - const parsed = await this.client.resume(submission.taskId, input.input.kind === 'link' ? 'html.getByURL' : input.input.taskType, { - signal, - ...(spaceId ? { spaceId } : {}), - ...(options.common.timeout ? { wait: { timeout: options.common.timeout } } : {}), - }); - if (!input.source) throw new Error('Cloud parsing needs a resolved source identity.'); - return cloudResultToCandidate(parsed, input.source, signal); - } -} -function sourceFor(input: EngineParseInput['input']): ParseSource { - if (input.kind === 'document') return input.file; - if (input.kind === 'stdin') return { file: { input: input.data, name: input.name }, name: input.name }; - return { url: input.url }; -} - -function cloudParams(flags: EngineParseOptions['flags'], isLink: boolean): Record { - const params: Record = {}; - if (flags.password !== undefined) params.password = flags.password; - if (flags.profile !== undefined) params.parseProfile = flags.profile; - if (flags.includeImages !== undefined) params.includeImages = flags.includeImages; - if (flags.stayImageAreaRate !== undefined) params.stayImageAreaRate = flags.stayImageAreaRate; - if (isLink && flags.mode !== undefined) params.mode = flags.mode; - return params; -} diff --git a/src/engine/local.ts b/src/engine/local.ts deleted file mode 100644 index d96a489..0000000 --- a/src/engine/local.ts +++ /dev/null @@ -1,65 +0,0 @@ -import fs from 'node:fs/promises'; -import { DeckOpsError } from '../errors/index.js'; -import { sha256 } from '../ir/ids.js'; -import type { ParseCandidate } from '../ir/schema.js'; -import { type SourceIdentity } from '../local/common.js'; -import { fetchHtml } from '../local/html/parser.js'; -import { resolveLimits } from '../local/limits.js'; -import { runLocalWorker } from '../local/runner.js'; -import type { EngineParseInput, EngineParseOptions, ParseEngine, SupportDecision } from './types.js'; - -export class LocalEngine implements ParseEngine { - readonly id = 'local' as const; - - supports(input: EngineParseInput, options: EngineParseOptions): SupportDecision { - if (input.input.kind === 'link') { - return options.flags.mode === 'runtime' - ? { supported: false, reason: 'runtime_required', hint: 'Use --engine cloud for runtime URL parsing.' } - : { supported: true }; - } - if (input.input.taskType === 'keynote.parseTextAndImage') { - return { supported: false, reason: 'unsupported_local', hint: 'Keynote IWA parsing is cloud-only. Use --engine cloud.' }; - } - if (input.input.taskType === 'pdf.pdfParse' && options.flags.profile && options.flags.profile !== 'balanced') { - return { supported: false, reason: 'profile_cloud_only', hint: 'The local pdf-lite-parse API uses its balanced profile. Use --profile balanced or --engine cloud.' }; - } - return { supported: true }; - } - - async parse(input: EngineParseInput, options: EngineParseOptions, signal: AbortSignal): Promise { - const support = this.supports(input, options); - if (!support.supported) throw DeckOpsError.unsupported(support.reason ?? 'Input is not supported locally.', support.hint ? { hint: support.hint } : {}); - try { - const limits = resolveLimits(options.common.limits); - if (input.input.kind === 'link') { - const fetched = await fetchHtml(input.input.url, limits, signal); - const identity: SourceIdentity = { sha256: sha256(fetched.bytes), name: fetched.url, bytes: fetched.bytes.byteLength }; - return await runLocalWorker({ kind: 'html', html: fetched.html, source: identity, baseUrl: fetched.url, limits }, signal); - } - const identity = input.source ?? await sourceIdentity(input.input); - if (identity.bytes > limits.sourceBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); - if (input.input.taskType === 'pdf.pdfParse') { - const source = input.input.kind === 'document' ? input.input.file : input.input.data; - return await runLocalWorker({ kind: 'pdf', input: source, source: identity, limits, options: { - ...(options.flags.password !== undefined ? { password: options.flags.password } : {}), - ...(options.flags.pageFurniture !== undefined ? { pageFurniture: options.flags.pageFurniture } : {}), - ...(options.flags.overlaidText !== undefined ? { overlaidText: options.flags.overlaidText } : {}), - ...(options.flags.includeImages !== undefined ? { includeImages: options.flags.includeImages } : {}), - } }, signal); - } - const bytes = input.input.kind === 'document' ? new Uint8Array(await fs.readFile(input.input.file)) : input.input.data; - if (input.input.taskType === 'docx.parseTextAndImage') return await runLocalWorker({ kind: 'docx', data: bytes, source: identity, limits, options: { ...(options.flags.trackedChanges ? { trackedChanges: options.flags.trackedChanges } : {}) } }, signal); - if (input.input.taskType === 'pptx.parse') return await runLocalWorker({ kind: 'pptx', data: bytes, source: identity, limits }, signal); - throw DeckOpsError.unsupported('This format has no local parser.'); - } catch (error) { - if (error instanceof DeckOpsError) throw error; - const message = error instanceof Error ? error.message : String(error); - throw DeckOpsError.input(`Local parsing failed: ${message}`, { hint: 'Inspect the quality/format, or explicitly retry with --engine cloud.', cause: error }); - } - } -} - -async function sourceIdentity(input: Exclude): Promise { - const bytes = input.kind === 'document' ? new Uint8Array(await fs.readFile(input.file)) : input.data; - return { sha256: sha256(bytes), name: input.name, bytes: bytes.byteLength }; -} diff --git a/src/engine/policy.ts b/src/engine/policy.ts deleted file mode 100644 index 807d8d1..0000000 --- a/src/engine/policy.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { DeckOpsError } from '../errors/index.js'; -import type { CommonFlags, ParseFlags } from '../types-legacy.js'; -import type { Assessment } from '../quality/assessment.js'; -import type { EngineParseInput } from './types.js'; -import type { DeckProbeReport } from '../shared/preflight.js'; - -export const CAPABILITIES = { - schemaVersion: 'deckops.capabilities.v1', - local: { formats: ['pdf', 'pptx', 'docx', 'html'], features: ['text', 'tables', 'images'], ocr: false, upload: 'none' }, - cloud: { provider: 'deckflow', formats: ['pdf', 'pptx', 'docx', 'keynote', 'html'], uploadScope: 'entire_document', paid: true, - costBound: 'unavailable', source: 'bundled cloud parse API contract', - features: [ - { id: 'document_parse', status: 'supported', autoEligible: true }, - { id: 'keynote_parse', status: 'supported', autoEligible: true }, - { id: 'runtime_html', status: 'supported', autoEligible: true }, - { id: 'ocr', status: 'unverified', autoEligible: false }, - { id: 'table_structure', status: 'unverified', autoEligible: false }, - { id: 'chart_content', status: 'unverified', autoEligible: false }, - { id: 'smartart_content', status: 'unverified', autoEligible: false }, - { id: 'visual_layout', status: 'unverified', autoEligible: false }, - { id: 'embedded_content', status: 'unverified', autoEligible: false }, - ], - unsupportedParameters: ['trackedChanges', 'pageFurniture', 'overlaidText'], - }, -} as const; -export type { ExecutionPolicy, RouteDecision } from '../shared/policy-types.js'; -import type { ExecutionPolicy, RouteDecision } from '../shared/policy-types.js'; -export function compilePolicy(common: CommonFlags): ExecutionPolicy { - const engine = common.engine ?? 'local'; - if (!['local', 'auto', 'cloud'].includes(engine)) throw DeckOpsError.usage('Invalid engine mode.'); - for (const value of [common.cloudLimits?.maxSourceBytes, common.cloudLimits?.maxSourcePages]) { - if (value !== undefined && (!Number.isSafeInteger(value) || value <= 0)) throw DeckOpsError.usage('Cloud source limits must be positive integers.'); - } - return { version: 1, engine, upload: engine === 'cloud' && common.allowUpload !== false || engine === 'auto' && common.allowUpload === true ? 'allow' : 'deny', acceptance: common.failOnDegraded ? 'no-degraded' : 'best-effort', maxParseSubmissions: 1, source: common.policySource ?? { engine: common.engine ? 'api' : 'builtin', allowUpload: common.allowUpload !== undefined ? 'api' : 'builtin' } }; -} -export function cloudSupport(input: EngineParseInput, flags: ParseFlags): { supported: boolean; reason?: string } { - if (flags.trackedChanges !== undefined || flags.pageFurniture !== undefined || flags.overlaidText !== undefined) return { supported: false, reason: 'parameter_incompatible' }; - return { supported: input.input.kind === 'link' || ['pdf.pdfParse', 'pptx.parse', 'docx.parseTextAndImage', 'keynote.parseTextAndImage'].includes(input.input.taskType) }; -} -export function evaluatePolicy(input: EngineParseInput, flags: ParseFlags, common: CommonFlags, assessment?: Assessment, unsupported = false, probe?: DeckProbeReport): RouteDecision { - const policy = compilePolicy(common); - const needed = unsupported ? ['document_parse'] : [...new Set(assessment?.issues.filter(i => i.severity !== 'info' && i.impact !== 'informational' && i.impact !== 'delivery').flatMap(i => i.neededCapability ? [i.neededCapability] : []) ?? [])]; - const alternatives: RouteDecision['alternatives'] = needed.map(capability => ({ engine: 'cloud', capability, verification: CAPABILITIES.cloud.features.find(f => f.id === capability)?.status ?? 'unverified', uploadScope: 'entire_document', requires: ['upload_authorization', 'credentials'] })); - const hasIssues = assessment?.issues.some(i => i.severity !== 'info' && i.impact !== 'informational'); - let reason = hasIssues ? 'no_verified_remedy' : 'no_relevant_issue'; let action: RouteDecision['action'] = 'keep_local'; - if (policy.engine === 'cloud') { action = 'use_cloud'; reason = 'explicit_cloud'; } - else if (needed.length) { - if (policy.engine === 'local') reason = 'local_only'; - else if (policy.upload === 'deny') reason = 'upload_denied'; - else if (!cloudSupport(input, flags).supported) reason = 'parameter_incompatible'; - else if (!needed.every(id => CAPABILITIES.cloud.features.some(f => f.id === id && f.autoEligible))) reason = 'capability_unverified'; - else { action = 'upgrade'; reason = unsupported ? 'unsupported_local' : 'quality_gap'; } - } - if (action !== 'keep_local') { - if (policy.upload === 'deny') { action = 'keep_local'; reason = 'upload_denied'; } - else if (!cloudSupport(input, flags).supported) { action = 'keep_local'; reason = 'parameter_incompatible'; } - else if (common.cloudLimits?.maxCost !== undefined) { action = 'keep_local'; reason = 'budget_unverifiable'; } - else if (common.cloudLimits?.maxSourceBytes !== undefined && (input.input.kind === 'link' || !input.source || input.source.bytes > common.cloudLimits.maxSourceBytes)) { action = 'keep_local'; reason = input.input.kind === 'link' ? 'limit_unverifiable' : 'source_byte_limit'; } - else if (common.cloudLimits?.maxSourcePages !== undefined) { - const key = input.input.kind !== 'link' && input.input.taskType === 'pdf.pdfParse' ? 'pdf.page_count' : 'powerpoint.slide_count'; - const evidence = probe?.results[key]; - if (evidence?.status !== 'resolved' || evidence.confidence !== 'exact' || typeof evidence.value !== 'number') { action = 'keep_local'; reason = 'limit_unverifiable'; } - else if (evidence.value > common.cloudLimits.maxSourcePages) { action = 'keep_local'; reason = 'source_page_limit'; } - } - } - // A recommendation is visible even when policy keeps this run local. Do not drop - // parameters or hard limits from a suggested command, or serialize a password. - const recommend = assessment?.recommendation || alternatives.length; - const commandSafe = cloudSupport(input, flags).supported && !flags.password && !common.cloudLimits && !common.limits; - const argv = ['deckops', '--engine', 'cloud']; - if (flags.profile !== undefined) argv.push('--profile', flags.profile); - if (flags.includeImages === false) argv.push('--no-images'); - if (flags.mode !== undefined) argv.push('--mode', flags.mode); - if (flags.stayImageAreaRate !== undefined) argv.push('--stay-image-area-rate', String(flags.stayImageAreaRate)); - argv.push('--', input.inputLabel); - return { action, reason, policy, alternatives, attempts: [], ...(recommend && action === 'keep_local' ? { - next: { argv: commandSafe ? argv : ['deckops', 'capabilities', '--json'], - message: commandSafe ? 'Optional paid cloud run; execute only with upload authorization. Listed unverified remedies are not guaranteed.' : 'Inspect cloud capabilities; preserve source parameters and limits when choosing a compatible cloud run.' }, - } : {}) }; -} diff --git a/src/engine/router.ts b/src/engine/router.ts deleted file mode 100644 index 68ee716..0000000 --- a/src/engine/router.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { DeckOpsError } from '../errors/index.js'; -import type { ParseCandidate } from '../ir/schema.js'; -import { CloudEngine } from './cloud.js'; -import { LocalEngine } from './local.js'; -import type { EngineParseInput, EngineParseOptions } from './types.js'; -import type { CloudClient } from '../cloud/client.js'; -import { assessCandidate, crossCheckEmbeddedObjects, improvesCandidate, type Assessment } from '../quality/assessment.js'; -import { evaluatePolicy, type RouteDecision } from './policy.js'; -import type { DeckProbeReport } from '../shared/preflight.js'; - -export async function routeParse(options: { - input: EngineParseInput; parse: EngineParseOptions; cloud?: () => Promise; signal: AbortSignal; - probe?: DeckProbeReport; cached?: ParseCandidate; cachedCloud?: ParseCandidate; - previousSubmission?: { status: string; taskId?: string; spaceId?: string }; - onSubmission?: (state: { status: string; taskId?: string; spaceId?: string }) => void; - onCandidate?: (candidate: ParseCandidate) => Promise; - onTask?: (task: { id: string }) => void; -}): Promise { - options.signal.throwIfAborted(); - const mode = options.parse.common.engine ?? 'local'; - const local = new LocalEngine(); - const support = local.supports(options.input, options.parse); - let candidate: ParseCandidate | undefined; - if (mode !== 'cloud' && support.supported) { - candidate = options.cached?.ir.producer.engine === 'local' ? options.cached : await local.parse(options.input, options.parse, options.signal); - options.signal.throwIfAborted(); - await options.onCandidate?.(candidate); - } - // 源与产物的交叉核对要在评估之前做:新增的 check 才能一并进 assessment 的 issues。 - if (candidate) crossCheckEmbeddedObjects(candidate, options.probe); - let assessment = candidate ? assessCandidate(candidate, options.probe) : undefined; - const decision = evaluatePolicy(options.input, options.parse.flags, options.parse.common, assessment, !support.supported, options.probe); - // 上一次提交没有结果。拿到过任务 id 就接着等那个任务、取回结果,不重新上传也不重新计费——原先 - // 只能挡住,唯一的出路是 --force 再交一次钱(实测下载结果时断网一次,就得重解析一遍)。没拿到 - // id 无从查起,默认不再提交,免得同一份文件算两次钱;`--force` 明说要重做,放行并留一条警告。 - const unresolved = decision.action !== 'keep_local' && options.previousSubmission && !options.cachedCloud && options.cached?.ir.producer.engine !== 'cloud' - ? options.previousSubmission : undefined; - const force = Boolean(options.parse.common.force); - const resumable = unresolved?.taskId && !force ? { taskId: unresolved.taskId, ...(unresolved.spaceId ? { spaceId: unresolved.spaceId } : {}) } : undefined; - if (unresolved && !force && !resumable) { - decision.action = 'keep_local'; decision.reason = 'previous_submission_pending'; - decision.attempts.push({ engine: 'cloud', status: unresolved.status }); - if (mode === 'cloud' || !candidate) { - throw DeckOpsError.backend('A previous cloud submission may have reached the cloud, but no task id came back.', { - hint: 'Run again with --force to resubmit; if the earlier task was created, it may be billed separately.', - }); - } - } - if (decision.action !== 'keep_local') { - const attempt: RouteDecision['attempts'][number] = { engine: 'cloud', status: 'starting' }; - decision.attempts.push(attempt); - try { - options.signal.throwIfAborted(); - const reusableCloud = options.cachedCloud ?? (options.cached?.ir.producer.engine === 'cloud' ? options.cached : undefined); - if (!options.cloud && !reusableCloud) throw DeckOpsError.usage('Cloud parsing was requested but no cloud client is configured.'); - const client = reusableCloud ? undefined : await options.cloud!(); - options.signal.throwIfAborted(); - if (reusableCloud) attempt.status = 'cache_hit'; - else if (resumable) { attempt.status = 'resuming'; attempt.taskId = resumable.taskId; } - // 建任务的请求真正发出时才记成 submission_unknown。原先连上云之前就记,取空间时网络 - // 不通也会留下一条「提交未决」,之后每次解析都被它挡住,而云端根本没有这个任务。 - const cloud = reusableCloud ?? (resumable - ? await new CloudEngine(client!).resume(options.input, resumable, options.parse, options.signal) - : await new CloudEngine(client!).parse(options.input, { ...options.parse, - onSubmit: () => { attempt.status = 'submission_unknown'; options.onSubmission?.({ status: 'submission_unknown' }); }, - onTask: task => { - attempt.taskId = task.id; attempt.status = 'submitted'; - options.onSubmission?.({ status: 'submitted', taskId: task.id, ...(task.spaceId ? { spaceId: task.spaceId } : {}) }); - options.onTask?.(task); - } }, options.signal)); - options.signal.throwIfAborted(); - crossCheckEmbeddedObjects(cloud, options.probe); - const after = assessCandidate(cloud, options.probe, assessment?.summary?.sourcePages); - await options.onCandidate?.(cloud); - attempt.status = reusableCloud ? 'cache_hit' : 'completed'; - if (!reusableCloud) options.onSubmission?.({ status: 'completed', ...(attempt.taskId ? { taskId: attempt.taskId } : {}) }); - if (!candidate || mode === 'cloud' || improvesCandidate(candidate, cloud, assessment!, after)) { candidate = cloud; assessment = after; decision.reason = mode === 'cloud' ? 'explicit_cloud' : 'quality_improved'; } - else { decision.action = 'keep_local'; decision.reason = 'comparison_inconclusive'; } - if (resumable && !reusableCloud) { - candidate.warnings = [...(candidate.warnings ?? []), `Resumed cloud task ${resumable.taskId} submitted by an earlier run; nothing was uploaded or submitted again.`]; - } else if (unresolved && !reusableCloud) { - candidate.warnings = [...(candidate.warnings ?? []), `Resubmitted with --force over an unresolved cloud submission${unresolved.taskId ? ` (task ${unresolved.taskId})` : ''}; if that task was created, it may be billed separately.`]; - } - } catch (error) { - // 接着等的任务在云端失败了、或已查不到:记成 failed,下一次正常提交;断网、超时则保持原样,下次再接着等。 - const abandoned = resumable && !options.parse.common.signal?.aborted && isTerminalTaskError(error); - if (abandoned) { - attempt.status = 'failed'; - options.onSubmission?.({ status: 'failed', taskId: resumable.taskId }); - } - // 建任务的请求发出去了,服务端却回了 4xx(凭据被拒、额度用完、参数不对):任务没有建,不是 - // 「提交未决」。原先照记 submission_unknown,重新登录后下一次解析反被它挡住,只能 --force。 - if (attempt.status === 'submission_unknown' && rejectedByServer(error)) { - attempt.status = 'failed'; - options.onSubmission?.({ status: 'failed' }); - } - // Explicit cancellation stops publication; an upgrade timeout may retain a ready local candidate. - if (options.parse.common.signal?.aborted) throw error; - if (!candidate || mode === 'cloud') { - if (abandoned) { - throw DeckOpsError.backend(`The cloud task ${resumable.taskId} from an earlier run cannot be resumed: ${error instanceof Error ? error.message : String(error)}`, { - taskId: resumable.taskId, hint: 'Run the same command again to submit a new task.', cause: error, - }); - } - throw error; - } - if (attempt.status !== 'submission_unknown') attempt.status = 'failed'; - decision.action = 'keep_local'; decision.reason = 'upgrade_failed'; - candidate.warnings = [...(candidate.warnings ?? []), `Cloud upgrade failed; kept local result (${error instanceof DeckOpsError ? error.code : 'backend_error'}).`]; - } - } - if (!candidate || !assessment) throw DeckOpsError.unsupported(support.reason ?? decision.reason, { hint: `Cloud route blocked: ${decision.reason}. Inspect deckops capabilities --json.` }); - candidate.assessment = assessment; candidate.decision = decision; - finalQualityGate(candidate, options.parse.common.failOnDegraded); - return candidate; -} -/** 服务端回了 4xx:请求被明确拒绝,没有建出任务。5xx 不算,服务端可能在出错前已经建了任务。 */ -function rejectedByServer(error: unknown): boolean { - for (let current: unknown = error; current instanceof Error; current = (current as { cause?: unknown }).cause) { - const status = (current as { statusCode?: unknown }).statusCode; - if (typeof status === 'number' && status >= 400 && status < 500) return true; - } - return false; -} - -/** - * 任务本身的终局:云端报任务失败,或任务、结果已查不到(包括状态接口一直回空——这个身份看不到它)。 - * 网络错误与超时不算,下次还能接着等。 - */ -function isTerminalTaskError(error: unknown): boolean { - for (let current: unknown = error; current instanceof Error; current = (current as { cause?: unknown }).cause) { - if (/^Task failed:|returned no task status/.test(current.message)) return true; - const status = (current as { statusCode?: unknown }).statusCode; - if (status === 403 || status === 404 || status === 410) return true; - if (current instanceof DeckOpsError && (current.code === 'ir_not_found' || current.code === 'ir_expired')) return true; - } - return false; -} - -export function finalQualityGate(candidate: { quality: ParseCandidate['quality']; assessment?: Assessment }, strict?: boolean): void { - if (strict && (candidate.quality.status !== 'pass' || candidate.assessment?.status === 'needs_attention')) throw DeckOpsError.input('Final result does not meet the requested quality gate.', { hint: candidate.assessment?.issues[0]?.message ?? candidate.quality.checks[0]?.message ?? 'See the quality report.' }); -} diff --git a/src/engine/types.ts b/src/engine/types.ts deleted file mode 100644 index 35a881b..0000000 --- a/src/engine/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ResolvedInput } from '../core/input.js'; -import type { ParseCandidate } from '../ir/schema.js'; -import type { CommonFlags, ParseFlags } from '../types-legacy.js'; -import type { SourceIdentity } from '../local/common.js'; - -export interface SupportDecision { - supported: boolean; - reason?: string; - hint?: string; -} -export interface EngineParseInput { - input: Exclude; - inputLabel: string; - source?: SourceIdentity; -} - -export interface EngineParseOptions { - /** 云端建任务的请求即将发出;在此之前失败不会留下云端任务。 */ - onSubmit?: () => void; - onTask?: (task: { id: string; spaceId?: string | undefined }) => void; - flags: ParseFlags; - common: CommonFlags; -} - -export interface ParseEngine { - readonly id: 'local' | 'cloud'; - supports(input: EngineParseInput, options: EngineParseOptions): SupportDecision; - parse(input: EngineParseInput, options: EngineParseOptions, signal: AbortSignal): Promise; -} diff --git a/src/index.ts b/src/index.ts index 728a035..a0706c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,140 +1,13 @@ -import fs from 'node:fs/promises'; -import { readManifest } from './artifact/manifest.js'; -import { irPath, probePath } from './artifact/layout.js'; -import type { CloudClient } from './cloud/client.js'; -import type { CredentialOverrides } from './config/index.js'; -import { runConvert } from './core/convert-op.js'; -import { resolveInput } from './core/input.js'; -import type { NodeDocumentInspector } from './core/inspector.js'; -import { runRead, type ReadResult } from './core/read-op.js'; -import { runParse } from './core/parse-op.js'; -import { validateConvertFlags, validateParseFlags } from './core/validation.js'; -import { DeckOpsError } from './errors/index.js'; -import { validateDeckIR } from './ir/validate.js'; -import type { DeckProbeReport, PreflightMode, PreflightSummary } from './shared/preflight.js'; -import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ParseEnvelope, ParseFlags } from './types-legacy.js'; +/** Node entry: the SDK with filesystem artifacts and shared credentials. */ +export { + createClient, parse, convert, read, openArtifact, ParsedDocument, + type ArtifactConvertOptions, type ClientOptions, type ConvertInputOptions, type DeckOpsClient, type ParseInputOptions, type ReadInputOptions, +} from './node/sdk.js'; +export type { ReadReport, ReadResult } from './node/read-op.js'; export { DeckOpsError, ERROR_CODES, EXIT_CODES, type ErrorCode } from './errors/index.js'; export { resolveCredentials, writeSharedCredentials, type ResolvedCredentials } from './config/index.js'; -export { DECK_IR_SCHEMA_VERSION, type DeckIR, type DeckIrNode, type QualityReport } from './ir/schema.js'; -export { validateDeckIR } from './ir/validate.js'; -export { renderMarkdown, MARKDOWN_RENDERER_VERSION } from './views/markdown.js'; export type { - CommonFlags, ConvertEnvelope, ConvertFlags, EngineMode, Envelope, LocalLimits, Manifest, ManifestAsset, - ManifestInspection, ManifestV1, ManifestV2, ManifestView, OutputFile, ParseEnvelope, ParseFlags, ParseTaskType, - PreflightMode, PreflightSummary, -} from './types-legacy.js'; -export type { DeckProbeReport } from './shared/preflight.js'; -export { createNodeDocumentInspector, type NodeDocumentInspector } from './core/inspector.js'; - -export type { ReadResult, ReadReport } from './core/read-op.js'; -export { CAPABILITIES, compilePolicy, evaluatePolicy } from './engine/policy.js'; -export type { ExecutionPolicy, RouteDecision } from './engine/policy.js'; -export type { Assessment, QualityIssue } from './quality/assessment.js'; -export interface ReadInputOptions extends ParseInputOptions { format?: 'markdown' | 'ir'; reportFile?: string; cacheDir?: string; anchors?: boolean; splitPages?: boolean } - -export interface ClientOptions extends CredentialOverrides { inspector?: NodeDocumentInspector } -export interface ParseInputOptions extends ParseFlags, CommonFlags { out?: string; from?: string; preflight?: PreflightMode } -export interface ConvertInputOptions extends ConvertFlags, CommonFlags, ParseFlags { out?: string; from?: string; preflight?: PreflightMode } - -export class ParsedDocument { - constructor(readonly dir: string, readonly manifest: Manifest, private readonly cloud: () => Promise) {} - - async ir(): Promise { - const value = JSON.parse(await fs.readFile(irPath(this.dir), 'utf-8')); - return this.manifest.manifestVersion === 2 ? validateDeckIR(value) : value; - } - - get irKey(): string | undefined { - return this.manifest.manifestVersion === 1 ? this.manifest.parse.irKey : this.manifest.parse.remote?.irKey; - } - - get inspection(): PreflightSummary | undefined { return this.manifest.inspection?.summary; } - - async inspectionReport(): Promise { - if (!this.manifest.inspection) return undefined; - try { return JSON.parse(await fs.readFile(probePath(this.dir), 'utf-8')) as DeckProbeReport; } - catch (cause) { throw DeckOpsError.input(`${this.dir} is missing its registered probe report.`, { hint: 'Re-run parse with preflight validate/strict.', cause }); } - } - - async convert(options: ConvertInputOptions = {}): Promise { - const { out, from: _from, preflight, spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource, - profile, password, includeImages, pageFurniture, overlaidText, trackedChanges, stayImageAreaRate, mode, ...flags } = options; - const input = { kind: 'artifact' as const, dir: this.dir, manifest: this.manifest }; - const parseFlags = compact({ profile, password, includeImages, pageFurniture, overlaidText, trackedChanges, stayImageAreaRate, mode }); - validateParseFlags(input, parseFlags); validateConvertFlags(input, flags); validateCommon({ timeout, engine, allowUpload }); - return runConvert({ input, inputLabel: this.dir, - ...(out !== undefined ? { out } : {}), flags, - common: compact({ spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource }), - ...(preflight !== undefined ? { preflight } : {}), cloud: this.cloud }); - } -} - -export interface DeckOpsClient { - read(input: string, options: ReadInputOptions & { format: 'ir' }): Promise>; - read(input: string, options?: ReadInputOptions & { format?: 'markdown' }): Promise>; - parse(input: string, options?: ParseInputOptions): Promise; - openArtifact(dir: string): Promise; - convert(input: string, options?: ConvertInputOptions): Promise; - parseEnvelope(input: string, options?: ParseInputOptions): Promise; -} - -export function createClient(options: ClientOptions = {}): DeckOpsClient { - let cached: CloudClient | undefined; - const cloud = async (): Promise => { - if (!cached) { - const [{ createCloudClient }, { resolveCredentials }] = await Promise.all([import('./cloud/client.js'), import('./config/index.js')]); - cached = createCloudClient(await resolveCredentials(options)); - } - return cached; - }; - const parseEnvelope = async (input: string, parseOptions: ParseInputOptions = {}): Promise => { - const { out, from, preflight, spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource, ...flags } = parseOptions; - const resolved = await resolveInput(input, from !== undefined ? { from } : {}); - validateParseFlags(resolved, flags); validateCommon({ timeout, engine, allowUpload }); - return runParse({ input: resolved, inputLabel: input, ...(out !== undefined ? { out } : {}), flags, - common: compact({ spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource }), - ...(preflight !== undefined ? { preflight } : {}), ...(options.inspector ? { inspector: options.inspector } : {}), cloud }); - }; - const read = async (input: string, readOptions: ReadInputOptions = {}): Promise => { - const { out, from, preflight, format, reportFile, cacheDir, anchors, splitPages, spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource, ...flags } = readOptions; - const resolved = await resolveInput(input, from !== undefined ? { from } : {}); - validateParseFlags(resolved, flags); validateCommon({ timeout, engine, allowUpload }); - return runRead({ input: resolved, inputLabel: input, flags, - common: compact({ spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource }), - ...compact>({ out, preflight, format, reportFile, cacheDir, anchors, splitPages }), ...(options.inspector ? { inspector: options.inspector } : {}), cloud }); - }; - return { - read: read as DeckOpsClient['read'], - parseEnvelope, - parse: async (input, parseOptions = {}) => { const envelope = await parseEnvelope(input, parseOptions); return new ParsedDocument(envelope.artifact, await readManifest(envelope.artifact), cloud); }, - openArtifact: async (dir) => new ParsedDocument(dir, await readManifest(dir), cloud), - convert: async (input, convertOptions = {}) => { - const { out, from, preflight, spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource, - profile, password, includeImages, pageFurniture, overlaidText, trackedChanges, stayImageAreaRate, mode, ...flags } = convertOptions; - const resolved = await resolveInput(input, from !== undefined ? { from } : {}); - const parseFlags = compact({ profile, password, includeImages, pageFurniture, overlaidText, trackedChanges, stayImageAreaRate, mode }); - validateParseFlags(resolved, parseFlags); validateConvertFlags(resolved, flags); validateCommon({ timeout, engine, allowUpload }); - return runConvert({ input: resolved, inputLabel: input, ...(out !== undefined ? { out } : {}), flags, parseFlags, - common: compact({ spaceId, timeout, force, engine, allowUpload, failOnDegraded, limits, cloudLimits, signal, policySource }), - ...(preflight !== undefined ? { preflight } : {}), ...(options.inspector ? { inspector: options.inspector } : {}), cloud }); - }, - }; -} - -function compact(value: Record): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T; } -function validateCommon(value: { timeout: CommonFlags['timeout']; engine: CommonFlags['engine']; allowUpload: CommonFlags['allowUpload'] }): void { - if (value.engine !== undefined && !['local', 'cloud', 'auto'].includes(value.engine)) throw DeckOpsError.usage('engine must be local, cloud, or auto.'); - if (value.allowUpload && (value.engine === 'local' || value.engine === undefined)) throw DeckOpsError.usage('Explicit local mode conflicts with allowUpload.'); - if (value.allowUpload === false && value.engine === 'cloud') throw DeckOpsError.usage('Cloud mode conflicts with upload denial.'); - if (value.timeout !== undefined && (!Number.isFinite(value.timeout) || value.timeout <= 0)) throw DeckOpsError.usage('timeout must be a positive number of seconds.'); -} - -const defaultClient = createClient(); -export async function parse(input: string, options?: ParseInputOptions): Promise { return defaultClient.parse(input, options); } -export async function openArtifact(dir: string): Promise { return defaultClient.openArtifact(dir); } -export async function convert(input: string, options?: ConvertInputOptions): Promise { return defaultClient.convert(input, options); } - -export function read(input: string, options: ReadInputOptions & { format: 'ir' }): Promise>; -export function read(input: string, options?: ReadInputOptions & { format?: 'markdown' }): Promise>; -export function read(input: string, options?: ReadInputOptions): Promise { return defaultClient.read(input, options as ReadInputOptions & { format: 'ir' }); } + CommonFlags, ConvertEnvelope, ConvertFlags, ConvertImage, Envelope, ErrorEnvelope, IrFormat, Manifest, ManifestAsset, ManifestV3, + ManifestView, OutputFile, ParseEnvelope, ParseFlags, ParseTaskType, ParseType, +} from './types.js'; diff --git a/src/ir/alt-text.ts b/src/ir/alt-text.ts deleted file mode 100644 index 7fce2d2..0000000 --- a/src/ir/alt-text.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 图片的替代文字:留下作者写的描述,去掉工具顺手塞进去的东西。 - * - * 实测两份讲义的 106 条替代文字:一份的 55 条大半是剪贴画缓存名(`imgyjavg[1]`);另一份的 - * 51 条里有图片网址、QQ 临时文件路径、「Image result for …」「See the source image」这类搜索 - * 引擎的样板话,还有 UTF-8 被当成 Latin-1 读出来的乱码(`中信éå¢` 其实是「中信集团」)。 - * 原样放进 `![…]`,读者看到的是噪声。 - * - * 能救的救回来:乱码按 Latin-1 → UTF-8 还原,搜索样板话剥掉只留检索词。救不回来的返回 - * undefined,调用方只保留原文于 IR,不再当作描述。 - */ -export function imageAltText(raw: string | undefined): string | undefined { - let text = repairMojibake(raw?.trim() ?? '').replace(/\s+/g, ' ').trim(); - text = text.replace(AUTO_DESCRIPTION_SUFFIX, '').trim(); - for (const pattern of SEARCH_BOILERPLATE) text = text.replace(pattern, '').trim(); - if (!text || JUNK.some((pattern) => pattern.test(text))) return undefined; - return text; -} - -/** 必应、百度、Office 的图片搜索与截图工具写进来的样板话;剥掉它们,剩下的检索词才是描述。 */ -const SEARCH_BOILERPLATE: readonly RegExp[] = [ - /^(?:image result for|related image|see the source image)\b:?\s*/i, - /\s*(?:的图像结果|的图片搜索结果|相关图片)$/, -]; - -/** Office 自动生成的描述带的尾巴(「A picture containing … Description automatically generated」)。 */ -const AUTO_DESCRIPTION_SUFFIX = /\s*(?:description automatically generated|自动生成的说明)\.?$/i; - -const IMAGE_FILE = /\.(?:png|jpe?g|gif|bmp|tiff?|webp|emf|wmf|svg|heic)(?![a-z])/i; - -/** 不是描述的替代文字。 */ -const JUNK: readonly RegExp[] = [ - /^[a-z][a-z0-9+.-]*:\/\//i, // 网址 - /^www\./i, - /^[a-z]:\\/i, // Windows 路径 - /^(?:\/|~\/)\S/, // Unix 路径 - IMAGE_FILE, // 文件名:「timg.jpeg」「屏幕快照 2017-02-15 下午12.58.49.png」 - /^[\w-]{3,16}\[\d+\]$/, // Office 剪贴画缓存名「imgyjavg[1]」 - /^(?:屏幕剪辑|屏幕截图|screen ?clipping|screenshot)$/i, - /^(?:picture|image|img|图片|图像|照片)\s*\d*$/i, // 与形状默认名同样的占位字样 -]; - -/** - * UTF-8 字节被当成 Latin-1 解码后的乱码:每个字符都落在 U+0000–U+00FF,且字节序列本身是合法 - * UTF-8。真正的 Latin-1 文字(「café」)按 UTF-8 严格解码会失败,保持原样。 - */ -function repairMojibake(text: string): string { - if (!/[\u0080-ÿ]/.test(text) || /[^\u0000-ÿ]/.test(text)) return text; - try { - const repaired = new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(text, (character) => character.charCodeAt(0))); - return /[^\u0000-\u007f]/.test(repaired) ? repaired : text; - } catch { - return text; - } -} diff --git a/src/ir/assets.ts b/src/ir/assets.ts deleted file mode 100644 index b6d7eaf..0000000 --- a/src/ir/assets.ts +++ /dev/null @@ -1,51 +0,0 @@ -import path from 'node:path'; -import { sha256 } from './ids.js'; -import type { CandidateAsset, DeckIrAsset, DeckIrNode } from './schema.js'; - -export function finalizeAssets(assets: CandidateAsset[], nodes: DeckIrNode[]): DeckIrAsset[] { - const rewrites = new Map(); - const unique = new Map(); - for (const asset of assets) { - const hash = sha256(asset.data); - const ext = safeExtension(asset.path, asset.mediaType); - const outputPath = `assets/${hash}${ext}`; - rewrites.set(normalize(asset.path), outputPath); - unique.set(outputPath, { - id: `asset_${sha256(outputPath).slice(0, 20)}`, - path: outputPath, - hash, - bytes: asset.data.byteLength, - ...(asset.mediaType ? { mediaType: asset.mediaType } : {}), - ...(asset.sourceRef ? { sourceRef: asset.sourceRef } : {}), - }); - } - for (const node of nodes) rewriteNodeAsset(node, rewrites); - return [...unique.values()].sort((a, b) => a.path.localeCompare(b.path)); -} -export function candidateAssetOutputPath(asset: CandidateAsset): string { - const hash = sha256(asset.data); - return `assets/${hash}${safeExtension(asset.path, asset.mediaType)}`; -} - -function rewriteNodeAsset(node: DeckIrNode, rewrites: Map): void { - const extensions = node.extensions; - if (!extensions) return; - for (const key of ['assetPath', 'previewPath'] as const) { - const value = extensions[key]; - if (typeof value === 'string') extensions[key] = rewrites.get(normalize(value)) ?? value; - } -} - -function normalize(value: string): string { - return value.replace(/\\/g, '/').replace(/^\.\//, ''); -} - -function safeExtension(name: string, mediaType?: string): string { - const fromName = path.posix.extname(normalize(name)).toLowerCase(); - if (/^\.[a-z0-9]{1,8}$/.test(fromName)) return fromName; - const known: Record = { - 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif', 'image/svg+xml': '.svg', - 'image/webp': '.webp', 'image/tiff': '.tiff', 'image/bmp': '.bmp', - }; - return mediaType ? (known[mediaType] ?? '.bin') : '.bin'; -} diff --git a/src/ir/chart-data.ts b/src/ir/chart-data.ts deleted file mode 100644 index 6850f78..0000000 --- a/src/ir/chart-data.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { DeckIrNode } from './schema.js'; - -/** - * 图表部件里缓存的数据,两个引擎共用的形状与表格化规则。 - * - * 图表原先只留一个空壳(`chart_partial`):画图用的类别与数值其实就缓存在图表部件里,不必打开 - * 内嵌的工作簿。本地解析器从 `ppt/charts/chartN.xml` 读,云端从 presentation 的 `chart` 字段读, - * 读出来都按这里的规则变成一张表。 - */ - -export interface ChartData { - title?: string | undefined; - /** 图表类型:`bar`、`line`、`pie`、`doughnut`…… */ - kind: string; - /** 类别(横轴标签);散点图是 x 值 */ - categories: string[]; - series: ChartSeries[]; -} - -export interface ChartSeries { - name?: string | undefined; - /** 与类别一一对应;缺的点为 null */ - values: Array; - /** 数值格式,如 `0%`、`General` */ - formatCode?: string | undefined; -} - -/** - * 图表 → 表格:首行是表头(空的类别列 + 各系列名),其后每个类别一行。没有类别就按序号标行。 - * 末尾的空行去掉:缓存常按 `ptCount` 预留点位,实测一个环形图四个点位只有两个有值。 - */ -export function chartRows(chart: ChartData): string[][] { - const count = Math.max(chart.categories.length, ...chart.series.map((series) => series.values.length)); - const rows: string[][] = []; - for (let index = 0; index < count; index += 1) { - const values = chart.series.map((series) => formatChartValue(series.values[index] ?? null, series.formatCode)); - rows.push([chart.categories[index] || String(index + 1), ...values]); - } - while (rows.length && !chart.categories[rows.length - 1] && rows.at(-1)!.slice(1).every((cell) => cell === '')) rows.pop(); - const header = ['', ...chart.series.map((series, index) => series.name || `Series ${index + 1}`)]; - return rows.length ? [header, ...rows] : []; -} - -/** 百分比格式(`0%`、`0.0%`,引号里的 `%` 不算)换成百分数;其余去掉浮点尾巴。 */ -export function formatChartValue(value: number | null, formatCode?: string): string { - if (value === null || !Number.isFinite(value)) return ''; - if (formatCode && formatCode.replace(/"[^"]*"/g, '').replace(/\\./g, '').includes('%')) { - const decimals = formatCode.match(/\.(0+)%/)?.[1]?.length ?? 0; - return `${(value * 100).toFixed(decimals)}%`; - } - return String(Number(value.toPrecision(12))); -} - -/** 读外部数据时的校验:形状不对就当没有。 */ -export function asChartData(value: unknown): ChartData | undefined { - if (!value || typeof value !== 'object') return undefined; - const record = value as Record; - if (typeof record.kind !== 'string' || !Array.isArray(record.series)) return undefined; - const series = record.series.flatMap((item): ChartSeries[] => { - if (!item || typeof item !== 'object' || !Array.isArray((item as Record).values)) return []; - const raw = item as Record; - return [{ - ...(typeof raw.name === 'string' && raw.name ? { name: raw.name } : {}), - values: (raw.values as unknown[]).map((point) => (typeof point === 'number' && Number.isFinite(point) ? point : null)), - ...(typeof raw.formatCode === 'string' && raw.formatCode ? { formatCode: raw.formatCode } : {}), - }]; - }); - if (!series.length) return undefined; - const categories = Array.isArray(record.categories) ? record.categories.map((category) => (typeof category === 'string' ? category : '')) : []; - return { ...(typeof record.title === 'string' && record.title ? { title: record.title } : {}), kind: record.kind, categories, series }; -} - -/** - * 把图表数据展开成挂在图表节点下的 table → table_row → table_cell,与两个引擎的表格同一种结构, - * Markdown 渲染器照常出表。`id` 给每个派生节点一个稳定 id,`order` 依次取序号。 - */ -export function chartTableNodes(chartNode: DeckIrNode, chart: ChartData, id: (suffix: string) => string, order: () => number): DeckIrNode[] { - const rows = chartRows(chart); - if (!rows.length) return []; - const base = { ...(chartNode.page ? { page: chartNode.page } : {}) }; - const path = chartNode.sourceRef.path ?? 'chart'; - const table: DeckIrNode = { id: id('table'), type: 'table', parentId: chartNode.id, children: [], order: order(), ...base, - sourceRef: { ...chartNode.sourceRef, path: `${path}/table` }, extensions: { rows: rows.length, columns: rows[0]!.length } }; - chartNode.children.push(table.id); - const nodes: DeckIrNode[] = [table]; - rows.forEach((cells, rowIndex) => { - const row: DeckIrNode = { id: id(`row:${rowIndex}`), type: 'table_row', parentId: table.id, children: [], order: order(), ...base, - sourceRef: { ...chartNode.sourceRef, path: `${path}/table/row/${rowIndex}` } }; - table.children.push(row.id); - nodes.push(row); - cells.forEach((text, column) => { - const cell: DeckIrNode = { id: id(`row:${rowIndex}:cell:${column}`), type: 'table_cell', parentId: row.id, children: [], order: order(), text, ...base, - sourceRef: { ...chartNode.sourceRef, path: `${path}/table/row/${rowIndex}/cell/${column}` }, extensions: { row: rowIndex, column } }; - row.children.push(cell.id); - nodes.push(cell); - }); - }); - return nodes; -} diff --git a/src/ir/cloud-adapter.ts b/src/ir/cloud-adapter.ts deleted file mode 100644 index 74d7955..0000000 --- a/src/ir/cloud-adapter.ts +++ /dev/null @@ -1,591 +0,0 @@ -import pLimit from 'p-limit'; -import type { ParseResult } from '../cloud/parse-facade.js'; -import type { ResultArtifact } from '@deckflow/pdf-lite-parse'; -import { imageAltText } from './alt-text.js'; -import { asChartData, chartTableNodes } from './chart-data.js'; -import { stableId } from './ids.js'; -import { - bulletDeclaration, levelStyleKey, paragraphStructure, resolveList, type BulletDeclaration, type TextParagraph, -} from './pptx-lists.js'; -import { inheritedPlaceholders, masterTextStyleKey, type PlaceholderEntry, type PlaceholderKey } from './pptx-placeholders.js'; -import { result3ToDeckIr } from './result3-adapter.js'; -import type { CandidateAsset, DeckIrNode, DeckIrPage, DeckIrRun, DocumentFormat, ParseCandidate, QualityCheck } from './schema.js'; -import { orderSlideNodes } from './slide-order.js'; -import { makeIr, qualityOf, type SourceIdentity } from '../local/common.js'; -import type { ParseTaskType } from '../types-legacy.js'; - -export async function cloudResultToCandidate(parsed: ParseResult, source: SourceIdentity, signal?: AbortSignal): Promise { - const result3 = findResult3(parsed.ir); - const assets = await cloudAssets(parsed.ir, signal); - if (result3) { - const candidate = result3ToDeckIr({ document: result3, source, assets, - producer: { engine: 'cloud', name: 'deckflow-cloud', version: cloudProducerVersion('pdf') } }); - candidate.remote = { taskId: parsed.taskId, irKey: parsed.irKey }; - return candidate; - } - const format = formatForType(parsed.type as ParseTaskType); - const nodes: DeckIrNode[] = []; - const sink: CloudNodeSink = { nodes, byId: new Map(), checks: [], embedded: { preview: { nodeIds: [], pages: new Set() }, hidden: { nodeIds: [], pages: new Set() } } }; - const pages: DeckIrPage[] = []; - const raw = parsed.ir as Record; - const pageItems = format === 'pptx' || format === 'keynote' ? array(raw.slides) : []; - // pptx 的坐标是 EMU,本地解析器给的是 pt;同一份文件换引擎,框的单位不能跟着变。 - const pageTransform = format === 'pptx' ? EMU_TO_PT : IDENTITY; - const pageSize = (value: unknown): number | undefined => { - const size = number(value); - return size === undefined ? undefined : round(size * pageTransform.scaleX); - }; - if (pageItems.length) { - pageItems.forEach((item, index) => { - const before = nodes.length; - const styles = format === 'pptx' ? slideInheritance(raw, object(item)) : undefined; - walkCloud(item, sink, source.sha256, `pages/${index}`, index + 1, null, pageTransform, styles); - if (format === 'pptx') emitCloudNotes(object(item), sink, source.sha256, index); - pages.push({ id: stableId(source.sha256, `cloud:page:${index}`, 'p'), index: index + 1, - ...(pageSize(raw.width ?? object(raw.slideSize)?.cx) ? { width: pageSize(raw.width ?? object(raw.slideSize)?.cx) } : {}), - ...(pageSize(raw.height ?? object(raw.slideSize)?.cy) ? { height: pageSize(raw.height ?? object(raw.slideSize)?.cy) } : {}), - nodeIds: nodes.slice(before).map((node) => node.id), sourceRef: { page: index + 1, path: `slides/${index}` } }); - }); - if (format === 'pptx') { - for (const node of nodes) node.zIndex = node.order; - orderSlideNodes(nodes, pages); - } - } else if (Array.isArray(raw.content)) { - raw.content.forEach((item, index) => walkCloud(item, sink, source.sha256, `content/${index}`, undefined, null)); - } else { - walkCloud(raw, sink, source.sha256, 'root', undefined, null); - } - const checks: QualityCheck[] = [...sink.checks]; - const { preview, hidden } = sink.embedded; - if (preview.nodeIds.length) { - checks.push({ code: 'embedded_object_preview', severity: 'info', pages: [...preview.pages], nodeIds: preview.nodeIds, - message: `${preview.nodeIds.length} embedded objects are shown as their preview images; their own content (for example a worksheet) is not expanded.` }); - } - if (hidden.nodeIds.length) { - checks.push({ code: 'embedded_object_hidden', severity: 'info', pages: [...hidden.pages], nodeIds: hidden.nodeIds, - message: `${hidden.nodeIds.length} embedded objects draw nothing visible on the slide (zero-size shells or add-in metadata) and are left out of the Markdown.` }); - } - if (nodes.length === 0) checks.push({ code: 'cloud_schema_unmapped', severity: 'error', message: `Cloud schema ${parsed.irSchemaVersion} did not contain mappable document nodes.` }); - let quality = qualityOf(checks, { ...(pages.length ? { pages: { parsed: pages.length, total: pages.length } } : {}), - objects: { parsed: nodes.length, opaque: 0 }, textCharacters: nodes.reduce((sum, node) => sum + (node.text?.length ?? 0), 0) }); - const ir = makeIr({ format, source, producer: { engine: 'cloud', name: 'deckflow-cloud', version: cloudProducerVersion(format) }, - metadata: { cloudSchemaVersion: parsed.irSchemaVersion }, pages, nodes, assets, quality }); - const availableAssets = new Set(ir.document.assets.map((asset) => asset.path)); - for (const node of ir.document.nodes) { - // 图片形状指着包里的媒体,云端结果却没有给出这张图:原先悄无声息地少一张图(实测两张存成 - // `.tmp` 的 PNG 被后端按扩展名筛掉)。 - const mediaRef = node.extensions?.mediaRef; - if (typeof mediaRef === 'string') { - node.issues = [...(node.issues ?? []), 'missing_media']; - checks.push({ code: 'cloud_asset_missing', severity: 'warning', message: `The cloud result does not include the image ${mediaRef} that a picture refers to.`, ...(node.page ? { pages: [node.page] } : {}), nodeIds: [node.id] }); - } - const assetPath = node.extensions?.assetPath; - if (typeof assetPath !== 'string' || availableAssets.has(assetPath)) continue; - delete node.extensions!.assetPath; - node.issues = [...(node.issues ?? []), 'missing_media']; - checks.push({ code: 'cloud_asset_unavailable', severity: 'warning', message: 'A cloud asset could not be materialized into the durable artifact.', nodeIds: [node.id] }); - } - quality = qualityOf(checks, quality.coverage); ir.quality = quality; - return { ir, quality, assets, remote: { taskId: parsed.taskId, irKey: parsed.irKey }, warnings: quality.checks.map((check) => check.message) }; -} - -/** - * 云端适配器的产物版本,按格式分开:parse-op 按主版本判断缓存能不能复用,缓存失效就要重新提交 - * 云端任务(计费)。只有产物真变了的格式才升版本:pptx 3 加了列表结构,4 加了继承的占位符 - * 位置与图片替代文字,5 加了段落边界、讲者备注、嵌入对象预览与图表数据;其余格式仍是 2。 - */ -export function cloudProducerVersion(format: string | undefined): string { - return format === 'pptx' ? '5' : '2'; -} - -/** - * 收集遍历产出的节点。 - * - * 除了数组还带一份 id → 节点的索引:建父子关系原本是 `nodes.find()`,每建一个节点线性 - * 扫一遍全表,在几千节点的文档上是平方级开销(实测一份 99 页文档 2838 个节点)。 - */ -interface CloudNodeSink { - nodes: DeckIrNode[]; - byId: Map; - /** 遍历途中发现的问题,与本地解析器逐个形状报告的对应。 */ - checks: QualityCheck[]; - /** 嵌入对象的去向,最后各汇总成一条 info,与本地解析器一致。 */ - embedded: { preview: { nodeIds: string[]; pages: Set }; hidden: { nodeIds: string[]; pages: Set } }; -} - -/** 小于这个尺寸(pt)的嵌入对象在幻灯片上看不见(插件元数据),与本地解析器同一个阈值。 */ -const HIDDEN_OBJECT_MAX_PT = 1; - -function push(sink: CloudNodeSink, node: DeckIrNode): void { - sink.nodes.push(node); - sink.byId.set(node.id, node); -} - -/** 形状树坐标 → 页面坐标:先缩放再平移。组合的子形状写在组合自己的子画布里,逐层复合。 */ -interface Transform { scaleX: number; scaleY: number; translateX: number; translateY: number } - -const IDENTITY: Transform = { scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 }; -const EMU_PER_PT = 12700; -const EMU_TO_PT: Transform = { scaleX: 1 / EMU_PER_PT, scaleY: 1 / EMU_PER_PT, translateX: 0, translateY: 0 }; - -/** - * 组合的子画布(`chX`/`chY`/`chCX`/`chCY`)映射回组合外框(`x`/`y`/`cx`/`cy`)。原先直接用 - * 子形状自己的 xfrm,组合里的框与页面上的框落在两套坐标里(实测同一页出现 - * `[3268, 2846, …]` 与 `[952500, 1138238, …]`),按位置排序无从谈起。 - */ -function groupTransform(xfrm: Record | undefined, parent: Transform): Transform { - const x = number(xfrm?.x); const y = number(xfrm?.y); const cx = number(xfrm?.cx); const cy = number(xfrm?.cy); - const chX = number(xfrm?.chX) ?? 0; const chY = number(xfrm?.chY) ?? 0; - const chCX = number(xfrm?.chCX); const chCY = number(xfrm?.chCY); - if (x === undefined || y === undefined || cx === undefined || cy === undefined || !chCX || !chCY) return parent; - const scaleX = cx / chCX; const scaleY = cy / chCY; - return { - scaleX: parent.scaleX * scaleX, - scaleY: parent.scaleY * scaleY, - translateX: parent.translateX + parent.scaleX * (x - chX * scaleX), - translateY: parent.translateY + parent.scaleY * (y - chY * scaleY), - }; -} - -function transformedBbox(xfrm: Record | undefined, transform: Transform): [number, number, number, number] | undefined { - const x = number(xfrm?.x); const y = number(xfrm?.y); const cx = number(xfrm?.cx); const cy = number(xfrm?.cy); - if (x === undefined || y === undefined || cx === undefined || cy === undefined) return undefined; - return [ - round(transform.translateX + transform.scaleX * x), - round(transform.translateY + transform.scaleY * y), - round(transform.translateX + transform.scaleX * (x + cx)), - round(transform.translateY + transform.scaleY * (y + cy)), - ]; -} - -function round(value: number): number { return Math.round(value * 1000) / 1000; } - -function walkCloud(value: unknown, sink: CloudNodeSink, hash: string, locator: string, page?: number, parentId: string | null = null, transform: Transform = IDENTITY, styles?: SlideInheritance): void { - if (!value || typeof value !== 'object') return; - if (Array.isArray(value)) { value.forEach((item, index) => walkCloud(item, sink, hash, `${locator}/${index}`, page, parentId, transform, styles)); return; } - const record = value as Record; - if (object(record.ole)) { emitEmbeddedObject(record, sink, hash, locator, page, parentId, transform); return; } - if (record.graphicType === 'diagram') { emitSmartArt(record, sink, hash, locator, page, parentId, transform); return; } - const text = cloudText(record); - const kind = cloudKind(record, text); - let nextParent = parentId; - const skipKeys = new Set(WALK_SKIP_KEYS); - const xfrm = object(record.xfrm); - if (text || ['shape', 'picture', 'image', 'table', 'group', 'chart'].some((token) => kind.includes(token)) || FURNITURE_KINDS.has(kind)) { - const id = stableId(hash, `cloud:${locator}`); - const placeholder = object(record.ph); - const placeholderKey = placeholder ? cloudPlaceholderKey(placeholder) : undefined; - const inherited = placeholderKey && styles ? inheritedPlaceholders(placeholderKey, styles.layoutPlaceholders, styles.masterPlaceholders) : undefined; - // 占位符没写位置就照版式、再照母版上的那一个摆,与本地解析器一致;继承来的框是页面坐标。 - const ownBbox = transformedBbox(xfrm, transform); - const layoutBbox = ownBbox ? undefined : transformedBbox(object(inherited?.layout?.xfrm), EMU_TO_PT); - const masterBbox = ownBbox || layoutBbox ? undefined : transformedBbox(object(inherited?.master?.xfrm), EMU_TO_PT); - const bbox = ownBbox ?? layoutBbox ?? masterBbox; - const bboxInheritedFrom = layoutBbox ? 'layout' : masterBbox ? 'master' : undefined; - const runs = text ? cloudRuns(record, text) : undefined; - // 只认解析器明确给出的资产指针。`path` 曾经也在这条兜底链上,但在 pptx 里它是自选 - // 图形的几何路径(`"M 0 0 L 10 10 Z"`),任何 Freeform 都会因此被判成「指向不存在的 - // 资产」,凭空产出一条 cloud_asset_unavailable 并把 quality 顶成 degraded。 - const assetPath = string(record.assetPath ?? record.suggestedPath); - const externalUrl = string(record.accessURL ?? record.url); - // 段落范围按 runs 切,runs 与 text 对不上时一并不给。 - const paragraphs = kind === 'text' && runs && styles ? cloudListParagraphs(record, styles, placeholderKey, inherited) : undefined; - // 图片的替代文字与本地解析器同一套清洗;原文留在 descr。 - const descr = kind === 'picture' ? string(record.descr) : undefined; - const alt = imageAltText(descr); - const mediaRef = kind === 'picture' && !assetPath && !externalUrl ? string(object(record.picture)?.blip) : undefined; - const node: DeckIrNode = { id, type: kind, parentId, children: [], order: sink.nodes.length, ...(text ? { text } : alt ? { text: alt } : {}), - ...(runs ? { runs } : {}), ...(page ? { page } : {}), sourceRef: { path: locator, ...(page ? { page } : {}) }, - ...(bbox ? { bbox } : {}), - extensions: { cloud: { id: record.id, name: record.name, style: record.style }, ...(placeholder ? { placeholder } : {}), - ...(bboxInheritedFrom ? { bboxInheritedFrom } : {}), ...(assetPath ? { assetPath } : {}), ...(externalUrl ? { externalUrl } : {}), - ...(alt ? { alt } : {}), ...(descr ? { descr } : {}), ...(mediaRef ? { mediaRef } : {}), ...(paragraphs ? { paragraphs } : {}) } }; - push(sink, node); if (parentId) sink.byId.get(parentId)?.children.push(id); nextParent = id; - if (kind === 'chart') expandCloudChart(record, node, sink, hash); - // 表格必须按 table → table_row → table_cell 发,和 local/pptx、local/docx、result3 三个 - // 适配器一致:Markdown 渲染器只认这三种类型,让通用遍历把单元格摊成一串 shape 子节点, - // 整张表会在渲染时被静默丢掉——IR 里看得见,产物里一个字都没有。 - if (kind.includes('table') && emitCloudTable(record, node, sink, hash, locator, page)) { - skipKeys.add('table'); - } - } - const childTransform = kind === 'group' ? groupTransform(xfrm, transform) : transform; - for (const [key, child] of Object.entries(record)) { - if (skipKeys.has(key)) continue; - if (child && typeof child === 'object') walkCloud(child, sink, hash, `${locator}/${key}`, page, nextParent, key === 'children' ? childTransform : transform, styles); - } -} - -/** - * 遍历时不再下钻的键:要么已在节点上表达,要么由更专门的分支接管。 - * - * `prstGeom` 是预设几何描述符,和 `style` / `xfrm` 一样描述的是形状怎么画,不是内容。 - * 下钻进去会凭空造出节点:它自带 `type`(如 `flowChartMagneticDisk`),而建节点的判据是 - * 子串命中 —— "flowchart…" 里就含着 "chart"。实测一份文档因此多出 18 个空节点,还把 - * 「产物是否表示了图表」的判断带偏。 - */ -const WALK_SKIP_KEYS = ['style', 'xfrm', 'txBody', 'text', 't', 'name', 'id', 'type', 'prstGeom', 'notes', 'chart', 'ole'] as const; - -/** - * 图表:presentation 开启 `readCharts` 后带着缓存的类别与数值,展开成一张表挂在图表节点下, - * 标题作图表节点的文字——与本地解析器同一套规则。没有数据(旧后端)就照实报 chart_partial。 - */ -function expandCloudChart(record: Record, node: DeckIrNode, sink: CloudNodeSink, hash: string): void { - const chart = asChartData(record.chart); - if (!chart) { - sink.checks.push({ code: 'chart_partial', severity: 'warning', message: `${node.page ? `Slide ${node.page} contains` : 'The document contains'} a chart whose data the cloud result does not include.`, - ...(node.page ? { pages: [node.page] } : {}), nodeIds: [node.id] }); - return; - } - if (chart.title) node.text = chart.title; - node.extensions = { ...node.extensions, chart: { kind: chart.kind, ...(chart.title ? { title: chart.title } : {}) } }; - let next = sink.nodes.length; - for (const child of chartTableNodes(node, chart, (suffix) => stableId(hash, `${node.id}:${suffix}`), () => next++)) push(sink, child); -} - -/** - * 嵌入对象(OLE),与本地解析器同一套规则:看不见的留一个不渲染的节点;看得见且有预览图的 - * 作一张图片;没有预览图的照实报不支持。 - */ -function emitEmbeddedObject(record: Record, sink: CloudNodeSink, hash: string, locator: string, page: number | undefined, parentId: string | null, transform: Transform): void { - const ole = object(record.ole)!; - const embeddedObject = { ...(string(ole.progId) ? { progId: string(ole.progId) } : {}), ...(string(ole.name) ? { name: string(ole.name) } : {}) }; - const id = stableId(hash, `cloud:${locator}`); - const bbox = transformedBbox(object(record.xfrm), transform); - const assetPath = string(record.assetPath); - const base = { id, parentId, children: [], order: sink.nodes.length, ...(page ? { page } : {}), sourceRef: { path: locator, ...(page ? { page } : {}) }, ...(bbox ? { bbox } : {}) }; - const extensions = { cloud: { id: record.id, name: record.name }, embeddedObject }; - const tally = (bucket: CloudNodeSink['embedded']['preview']): void => { - bucket.nodeIds.push(id); if (page) bucket.pages.add(page); - }; - const tiny = bbox !== undefined && (bbox[2] - bbox[0] < HIDDEN_OBJECT_MAX_PT || bbox[3] - bbox[1] < HIDDEN_OBJECT_MAX_PT); - const empty = !assetPath && number(ole.imgW) === 0 && number(ole.imgH) === 0; - if (tiny || empty) { - push(sink, { ...base, type: 'embedded_object', opaque: { type: 'embedded_object', reason: 'The object draws nothing visible on the slide.', data: embeddedObject }, extensions }); - tally(sink.embedded.hidden); - } else if (assetPath) { - push(sink, { ...base, type: 'picture', extensions: { ...extensions, assetPath } }); - tally(sink.embedded.preview); - } else { - push(sink, { ...base, type: 'graphic_frame', opaque: { type: 'embedded_object', reason: 'The embedded object has no preview image in the cloud result.', data: embeddedObject }, extensions }); - sink.checks.push({ code: 'embedded_object_unsupported', severity: 'warning', message: `${page ? `Slide ${page} contains` : 'The document contains'} an embedded object without a preview image.`, - ...(page ? { pages: [page] } : {}), nodeIds: [id] }); - } - if (parentId) sink.byId.get(parentId)?.children.push(id); -} - -/** SmartArt:云端结果没有它的文字与结构,与本地解析器一样报 smartart_partial。 */ -function emitSmartArt(record: Record, sink: CloudNodeSink, hash: string, locator: string, page: number | undefined, parentId: string | null, transform: Transform): void { - const id = stableId(hash, `cloud:${locator}`); - const bbox = transformedBbox(object(record.xfrm), transform); - push(sink, { id, type: 'smartart', parentId, children: [], order: sink.nodes.length, ...(page ? { page } : {}), sourceRef: { path: locator, ...(page ? { page } : {}) }, - ...(bbox ? { bbox } : {}), opaque: { type: 'smartart', reason: 'The object is preserved, but its text and structure are not expanded.' }, - extensions: { cloud: { id: record.id, name: record.name } } }); - if (parentId) sink.byId.get(parentId)?.children.push(id); - sink.checks.push({ code: 'smartart_partial', severity: 'warning', message: `${page ? `Slide ${page} contains` : 'The document contains'} smartart whose full semantics are not expanded.`, - ...(page ? { pages: [page] } : {}), nodeIds: [id] }); -} - -/** - * 讲者备注:presentation 开启 `readNotes` 后幻灯片带着 `notes`(备注页上的形状,已去掉缩略图与 - * 页眉页脚)。每个有字的形状一个 speaker_note 节点,与本地解析器一致;不进幻灯片的版面排序。 - */ -function emitCloudNotes(slide: Record | undefined, sink: CloudNodeSink, hash: string, index: number): void { - const shapes: Array> = []; - const collect = (items: unknown): void => { - for (const item of array(items)) { - const shape = object(item); - if (!shape) continue; - shapes.push(shape); - collect(shape.children); - } - }; - collect(slide?.notes); - const page = index + 1; - shapes.forEach((shape, noteIndex) => { - const text = cloudText(shape); - if (!text.trim()) return; - const runs = cloudRuns(shape, text); - const locator = `slides/${index}/notes/${noteIndex}`; - const paragraphs = runs ? plainParagraphs(shape) : undefined; - push(sink, { id: stableId(hash, `cloud:${locator}`), type: 'speaker_note', parentId: null, children: [], order: sink.nodes.length, text, - ...(runs ? { runs } : {}), page, sourceRef: { path: locator, page }, ...(paragraphs ? { extensions: { paragraphs } } : {}) }); - }); -} - -/** 段落范围(不判项目符号):备注页不走幻灯片母版的样式链。 */ -function plainParagraphs(record: Record): TextParagraph[] | undefined { - let length = 0; - return paragraphStructure(cloudParagraphs(record).map((paragraph, index): TextParagraph => { - if (index > 0) length += 1; - const start = length; - length += paragraph.runs.reduce((sum, run) => sum + (run.t as string).length, 0); - return { start, end: length, level: 0 }; - })); -} - -/** - * 把云端表格结构展开成行列节点;认不出行列就返回 false,交回通用遍历。 - * - * 行与单元格的键名各格式不同(pptx 是 `table.trs[].cells[]`),所以按候选键取第一个命中的, - * 而不是写死某一种格式。 - */ -function emitCloudTable( - record: Record, - table: DeckIrNode, - sink: CloudNodeSink, - hash: string, - locator: string, - page?: number, -): boolean { - const container = object(record.table) ?? record; - const rows = array(container.trs ?? container.rows).map((row) => - array(object(row)?.cells ?? object(row)?.tcs).flatMap((cell) => { - const parsed = object(cell); - return parsed ? [parsed] : []; - })); - if (!rows.some((cells) => cells.length > 0)) return false; - table.extensions = { ...table.extensions, rows: rows.length, - columns: Math.max(...rows.map((cells) => cells.length)) }; - rows.forEach((cells, rowIndex) => { - const rowLocator = `${locator}/row/${rowIndex}`; - const rowId = stableId(hash, `cloud:${rowLocator}`); - const row: DeckIrNode = { id: rowId, type: 'table_row', parentId: table.id, children: [], order: sink.nodes.length, - ...(page ? { page } : {}), sourceRef: { path: rowLocator, ...(page ? { page } : {}) } }; - push(sink, row); table.children.push(rowId); - cells.forEach((cell, column) => { - const cellLocator = `${rowLocator}/cell/${column}`; - const cellId = stableId(hash, `cloud:${cellLocator}`); - const text = cloudText(cell); - const runs = text ? cloudRuns(cell, text) : undefined; - push(sink, { id: cellId, type: 'table_cell', parentId: rowId, children: [], order: sink.nodes.length, - ...(text ? { text } : {}), ...(runs ? { runs } : {}), ...(page ? { page } : {}), - sourceRef: { path: cellLocator, ...(page ? { page } : {}) }, - extensions: { row: rowIndex, column, - gridSpan: number(cell.colSpan ?? cell.gridSpan) ?? 1, rowSpan: number(cell.rowSpan) ?? 1, - ...(cell.hMerge === true ? { hMerge: true } : {}), ...(cell.vMerge === true ? { vMerge: true } : {}) } }); - row.children.push(cellId); - }); - }); - return true; -} - -/** - * 节点类型。 - * - * 解析器自报的 `type` 是它的形状分类(pptx 一律是 "Shape"),不是文档语义:整篇因此落成 - * 清一色的 shape,Markdown 一个标题都没有(实测 99 页文档本地 96 个 heading、云端 0 个)。 - * 占位符类型才是 OOXML 里「这是标题」的确定性证据,与 local/pptx/parser.ts 用的是同一条 - * 判据 —— 两个引擎的产物必须在这件事上对得上,否则同一份文件换引擎结构就变了。 - * - * 只在有文字时改判,空的标题占位符仍按原类型走:那些节点靠类型词命中才得以建出来, - * 改成 heading 会让它们从 IR 里整个消失。 - */ -function cloudKind(record: Record, text: string): string { - const placeholder = string(object(record.ph)?.type); - const declared = string(record.type)?.toLowerCase(); - if (text && (placeholder === 'title' || placeholder === 'ctrTitle')) return 'heading'; - if (placeholder && FURNITURE_PLACEHOLDERS[placeholder]) return FURNITURE_PLACEHOLDERS[placeholder]!; - if (text && declared === 'shape') return 'text'; - return declared ?? (record.txBody ? 'shape' : record.table ? 'table' : text ? 'text' : 'object'); -} - -/** - * 版式家具占位符 → 节点类型,与 local/pptx/parser.ts 同一张表。页脚、日期、页码每页一份, - * 实测一份 99 页讲义的页脚在 Markdown 里占了 92 行;文字留在 IR,Markdown 视图不渲染。 - */ -const FURNITURE_PLACEHOLDERS: Readonly> = { ftr: 'footer', dt: 'footer', hdr: 'header', sldNum: 'page_number' }; -const FURNITURE_KINDS: ReadonlySet = new Set(Object.values(FURNITURE_PLACEHOLDERS)); - -/** - * 文字的格式(粗体、斜体、下划线、删除线)。云端 IR 的 `txBody` 里每个文本运行带着样式, - * 原先只取纯文本,本地产物有的强调云端一处都没有(实测同一份讲义本地 234 个带格式的运行)。 - * - * 段落之间与 `text` 一样用换行连接、跳过空段落;拼出来与 `text` 不一字不差就不给 runs—— - * runs 是 text 的另一种切分,两者不一致时读方无从判断该信哪个。 - */ -function cloudRuns(record: Record, text: string): DeckIrRun[] | undefined { - const paragraphs = cloudParagraphs(record); - if (paragraphs.length === 0) return undefined; - const runs: DeckIrRun[] = []; - paragraphs.forEach((paragraph, index) => { - if (index > 0) runs.push({ text: '\n' }); - for (const run of paragraph.runs) { - const style = object(run.style); - const underline = string(style?.u); - const strike = string(style?.strike); - runs.push({ text: run.t as string, - ...(style?.b === true ? { bold: true } : {}), ...(style?.i === true ? { italic: true } : {}), - ...(underline && underline !== 'none' ? { underline: true } : {}), - ...(strike && strike !== 'noStrike' ? { strike: true } : {}) }); - } - }); - return runs.map((run) => run.text).join('') === text ? runs : undefined; -} - -/** 有文本运行的段落,与 `text`、`cloudRuns` 同一种切法:跳过空段落。 */ -function cloudParagraphs(record: Record): Array<{ style: Record | undefined; runs: Array> }> { - return array(object(record.txBody)?.children).flatMap((raw) => { - const paragraph = object(raw); - const runs = array(paragraph?.children).flatMap((run) => { - const parsed = object(run); - return parsed && typeof parsed.t === 'string' ? [parsed] : []; - }); - return runs.length > 0 ? [{ style: object(paragraph?.style), runs }] : []; - }); -} - -/** 一页幻灯片的版式与母版:占位符没写的位置、项目符号从这里继承,与 local/pptx/parser.ts 同一条链。 */ -interface SlideInheritance { - layoutPlaceholders: Array>>; - masterPlaceholders: Array>>; - master: Record | undefined; - defaultTextStyle: Record | undefined; -} - -type InheritedShapes = { layout: Record | undefined; master: Record | undefined }; - -function slideInheritance(raw: Record, slide: Record | undefined): SlideInheritance { - const masters = array(raw.slideMasters).flatMap((item) => object(item) ? [object(item)!] : []); - const master = masters.find((item) => item._ref === slide?._masterRef); - const layout = masters.flatMap((item) => array(item.slideLayouts)).map(object).find((item) => item?._ref === slide?._layoutRef); - return { - layoutPlaceholders: placeholdersOf(layout?.spTree), - masterPlaceholders: placeholdersOf(master?.spTree), - master, - defaultTextStyle: object(raw.defaultTextStyle), - }; -} - -function placeholdersOf(tree: unknown): Array>> { - return array(tree).flatMap((item) => { - const shape = object(item); - if (!shape) return []; - const placeholder = object(shape.ph); - return [...(placeholder ? [{ key: cloudPlaceholderKey(placeholder), value: shape }] : []), ...placeholdersOf(shape.children)]; - }); -} - -function cloudPlaceholderKey(placeholder: Record): PlaceholderKey { - const idx = placeholder.idx; - return { type: string(placeholder.type), idx: typeof idx === 'number' || typeof idx === 'string' ? String(idx) : undefined }; -} - -function cloudListParagraphs(record: Record, slide: SlideInheritance, key: PlaceholderKey | undefined, inherited: InheritedShapes | undefined): TextParagraph[] | undefined { - let length = 0; - return paragraphStructure(cloudParagraphs(record).map((paragraph, index): TextParagraph => { - if (index > 0) length += 1; - const start = length; - length += paragraph.runs.reduce((sum, run) => sum + (run.t as string).length, 0); - const level = Math.min(Math.max(number(paragraph.style?.lvl) ?? 0, 0), 8); - const list = resolveList(cloudBulletChain(paragraph.style, level, record, key, inherited, slide)); - return { start, end: length, level, ...(list ? { list } : {}) }; - })); -} - -/** 段落 → 形状 lstStyle → 版式占位符 → 母版占位符 → 母版文字样式 → 默认文字样式。 */ -function* cloudBulletChain(style: Record | undefined, level: number, record: Record, placeholder: PlaceholderKey | undefined, - inherited: InheritedShapes | undefined, slide: SlideInheritance): Generator { - const levelKey = levelStyleKey(level); - const levelOf = (list: unknown): Record | undefined => object(object(list)?.[levelKey]); - yield cloudDeclaredBullet(style); - yield cloudDeclaredBullet(levelOf(object(record.txBody)?.lstStyle)); - yield cloudDeclaredBullet(levelOf(object(inherited?.layout?.txBody)?.lstStyle)); - yield cloudDeclaredBullet(levelOf(object(inherited?.master?.txBody)?.lstStyle)); - yield cloudDeclaredBullet(levelOf(slide.master?.[masterTextStyleKey(placeholder)])); - yield cloudDeclaredBullet(levelOf(slide.defaultTextStyle)); -} - -/** 云端 IR 不带图片项目符号(`buBlip`):presentation 没有读出这个元素,那一层按「没说」处理。 */ -function cloudDeclaredBullet(style: Record | undefined): BulletDeclaration { - if (!style) return undefined; - return bulletDeclaration({ none: style.buNone === true, autoNumber: Boolean(string(style.buAutoNum)), character: Boolean(string(style.buChar)) }); -} - -function cloudText(record: Record): string { - if (typeof record.text === 'string') return record.text; - if (typeof record.t === 'string') return record.t; - const txBody = object(record.txBody); - if (txBody) return collectText(txBody).join(''); - return ''; -} - -function collectText(value: unknown): string[] { - if (!value || typeof value !== 'object') return []; - if (Array.isArray(value)) return value.flatMap(collectText); - const record = value as Record; - return [...(typeof record.t === 'string' ? [record.t] : []), ...Object.entries(record).filter(([key]) => key !== 't').flatMap(([, child]) => collectText(child))]; -} - -/** - * 同时在途的资产下载数。与 assets/localize.ts 的 convert 路径取同一个值:同一个后端、 - * 同一批签名地址,没有理由两条路径压出不同的并发。 - */ -const ASSET_FETCH_CONCURRENCY = 4; -/** 单个资产的尝试次数(含首次)。 */ -const ASSET_FETCH_ATTEMPTS = 3; -/** 值得重试的响应:另一端过载或限流,重来一次可能就成了。4xx 重试只是白等。 */ -const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]); - -async function cloudAssets(ir: unknown, signal?: AbortSignal): Promise { - const images = object(ir)?.images; - if (!Array.isArray(images)) return []; - const requests = images.flatMap((raw) => { - const image = object(raw); - const url = image && (image.accessURL ?? image.url); - const path = image && (image.assetPath ?? image.path); - return typeof url === 'string' && typeof path === 'string' ? [{ url, path }] : []; - }); - if (requests.length === 0) return []; - // 串行下载在真实文档上是几十到上百次往返(实测 PDF 96 张、PPTX 135 张);并发上限沿用 - // convert 路径的值,避免把后端的签名地址服务打爆。 - const limit = pLimit(ASSET_FETCH_CONCURRENCY); - const fetched = await Promise.all(requests.map((request) => limit(() => fetchCloudAsset(request, signal)))); - return fetched.filter((asset): asset is CandidateAsset => asset !== undefined); -} - -/** - * 取一个资产;取不到返回 undefined。 - * - * 解析阶段缺图不该让整份解析失败 —— 它由 candidate 评估报成 `cloud_asset_unavailable`, - * 调用方看得见。但「不失败」不等于「不重试」:原先一次网络抖动就永久丢一张图,而这条 - * 路径拿的是有效期很短的签名地址,重来一次通常就成了。中断信号仍然逐层抛出。 - */ -async function fetchCloudAsset(request: { url: string; path: string }, signal?: AbortSignal): Promise { - for (let attempt = 0; attempt < ASSET_FETCH_ATTEMPTS; attempt += 1) { - signal?.throwIfAborted(); - try { - const response = await fetch(request.url, signal ? { signal } : {}); - if (response.ok) return { path: request.path, data: new Uint8Array(await response.arrayBuffer()) }; - if (!RETRYABLE_STATUS.has(response.status)) return undefined; - } catch { - signal?.throwIfAborted(); - } - if (attempt < ASSET_FETCH_ATTEMPTS - 1) await backoff(200 * 2 ** attempt, signal); - } - return undefined; -} - -function backoff(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, ms); - const onAbort = () => { clearTimeout(timer); reject(signal?.reason ?? new Error('aborted')); }; - signal?.addEventListener('abort', onAbort, { once: true }); - }); -} - -function findResult3(ir: unknown): ResultArtifact | undefined { - const root = object(ir); if (!root) return undefined; - if ((root.schemaVersion === 'result.v3' || root.version === 'result.v3') && Array.isArray(root.elements) && Array.isArray(root.pages)) return root as unknown as ResultArtifact; - return findResult3(root.document); -} - -function formatForType(type: ParseTaskType): DocumentFormat { - if (type === 'pdf.pdfParse') return 'pdf'; if (type === 'pptx.parse') return 'pptx'; - if (type === 'docx.parseTextAndImage') return 'docx'; if (type === 'keynote.parseTextAndImage') return 'keynote'; return 'html'; -} - -function object(value: unknown): Record | undefined { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : undefined; } -function array(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } -function number(value: unknown): number | undefined { return typeof value === 'number' && Number.isFinite(value) ? value : undefined; } -function string(value: unknown): string | undefined { return typeof value === 'string' && value ? value : undefined; } diff --git a/src/ir/ids.ts b/src/ir/ids.ts deleted file mode 100644 index f219389..0000000 --- a/src/ir/ids.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createHash } from 'node:crypto'; - -/** Stable across repeated parses by the same parser major and normalized params. */ -export function stableId(sourceSha256: string, locator: string, prefix = 'n'): string { - const digest = createHash('sha256').update(sourceSha256).update('\0').update(locator).digest('hex').slice(0, 20); - return `${prefix}_${digest}`; -} -export function sha256(data: Uint8Array | string): string { - return createHash('sha256').update(data).digest('hex'); -} diff --git a/src/ir/pptx-lists.ts b/src/ir/pptx-lists.ts deleted file mode 100644 index 6fdc261..0000000 --- a/src/ir/pptx-lists.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * PPTX 段落的列表结构:大纲级别与项目符号,本地解析器与云端适配器共用这一套判据。 - * - * 两个引擎原先都把一个文本框的段落摊平成几行字:讲义里「server:」底下缩进的要点、编号步骤, - * 在 Markdown 里和普通正文没有分别。OOXML 的项目符号大多不写在段落上,而是从版式、母版继承, - * 所以这里给出继承链的顺序与每一层的读法;各引擎只负责从自己的数据里把这条链取出来。 - */ - -export type ListKind = 'bullet' | 'number'; - -/** 一个段落在节点 `text` 里的范围(UTF-16 下标,左闭右开)、大纲级别(0 起)与项目符号。 */ -export interface TextParagraph { - start: number; - end: number; - level: number; - list?: ListKind | undefined; -} - -/** 某一层样式对项目符号的表态:要(哪一种)、明确不要(`buNone`),或没说(交给下一层)。 */ -export type BulletDeclaration = ListKind | 'none' | undefined; - -/** 一层段落属性里写了哪些项目符号元素。 */ -export interface BulletElements { - none?: boolean; - autoNumber?: boolean; - character?: boolean; - picture?: boolean; -} - -export function bulletDeclaration(elements: BulletElements): BulletDeclaration { - if (elements.none) return 'none'; - if (elements.autoNumber) return 'number'; - if (elements.character || elements.picture) return 'bullet'; - return undefined; -} - -/** - * 沿继承链取第一个表态的层:段落自己 → 形状的 lstStyle → 版式占位符 → 母版占位符 → - * 母版文字样式 → 演示文稿默认样式。传生成器即可按需求值。 - */ -export function resolveList(declarations: Iterable): ListKind | undefined { - for (const declaration of declarations) { - if (declaration) return declaration === 'none' ? undefined : declaration; - } - return undefined; -} - -/** 大纲级别 → lstStyle / txStyles 里的键:级别 0 是 `lvl1pPr`。 */ -export function levelStyleKey(level: number): string { - return `lvl${Math.min(Math.max(Math.trunc(level), 0), 8) + 1}pPr`; -} - -/** - * 值得写进 IR 的段落结构:有列表项,或不止一个段落。只有一段的文本框照旧只有 text 与 runs。 - * - * 多段也要记:`text` 里段落之间与段内换行(``)都是 `\n`,不记段落边界,Markdown 只能把 - * 整个文本框当成一段——实测讲义「Client process: …」「Server process: …」两段并成了一行。 - */ -export function paragraphStructure(paragraphs: readonly TextParagraph[]): TextParagraph[] | undefined { - return paragraphs.length > 1 || paragraphs.some((paragraph) => paragraph.list) ? [...paragraphs] : undefined; -} diff --git a/src/ir/pptx-placeholders.ts b/src/ir/pptx-placeholders.ts deleted file mode 100644 index f54d63b..0000000 --- a/src/ir/pptx-placeholders.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * PPTX 占位符的继承:幻灯片上的占位符没写的属性(位置、项目符号……)从版式、再从母版上的 - * 对应占位符取。本地解析器与云端适配器共用这套匹配规则,两边从各自的数据里取出候选即可。 - */ - -export interface PlaceholderKey { - type?: string | undefined; - idx?: string | undefined; -} - -/** 版式或母版上的一个占位符:匹配用的键,与各引擎自己的数据。 */ -export interface PlaceholderEntry { - key: PlaceholderKey; - value: T; -} - -const FURNITURE_PLACEHOLDER_TYPES: ReadonlySet = new Set(['dt', 'ftr', 'sldNum', 'hdr']); - -/** - * 占位符归类:标题、四类家具各成一类,其余(正文、内容、副标题、图片、表格……)都按正文。 - * 母版上每类只有一个占位符,版式占位符按这个归类找到它的母版占位符。 - */ -export function placeholderKind(type: string | undefined): string { - if (type === 'title' || type === 'ctrTitle') return 'title'; - if (type && FURNITURE_PLACEHOLDER_TYPES.has(type)) return type; - return 'body'; -} - -/** 母版文字样式:标题占位符用 titleStyle,家具与不是占位符的形状用 otherStyle,其余用 bodyStyle。 */ -export function masterTextStyleKey(placeholder: PlaceholderKey | undefined): 'titleStyle' | 'bodyStyle' | 'otherStyle' { - if (!placeholder) return 'otherStyle'; - const kind = placeholderKind(placeholder.type); - return kind === 'title' ? 'titleStyle' : kind === 'body' ? 'bodyStyle' : 'otherStyle'; -} - -/** - * 幻灯片占位符 → 版式占位符:有 idx 先按 idx 找(PowerPoint 的做法),找不到再按类型, - * 类型也对不上就按归类。 - */ -export function matchLayoutPlaceholder(target: PlaceholderKey, candidates: ReadonlyArray>): PlaceholderEntry | undefined { - if (target.idx !== undefined) { - const byIdx = candidates.find((candidate) => candidate.key.idx === target.idx); - if (byIdx) return byIdx; - } - const sameType = (type: string | undefined): string => type === 'ctrTitle' ? 'title' : type ?? 'obj'; - const byType = candidates.find((candidate) => sameType(candidate.key.type) === sameType(target.type)); - if (byType) return byType; - return candidates.find((candidate) => placeholderKind(candidate.key.type) === placeholderKind(target.type)); -} - -/** - * 幻灯片占位符在版式与母版上的对应项。母版按归类找,归类以版式占位符的类型为准——版式上的 - * 内容占位符常常只写 idx 不写类型,那时退回幻灯片占位符自己的类型。 - */ -export function inheritedPlaceholders( - target: PlaceholderKey, - layout: ReadonlyArray>, - master: ReadonlyArray>, -): { layout: T | undefined; master: T | undefined } { - const fromLayout = matchLayoutPlaceholder(target, layout); - const kind = placeholderKind(fromLayout?.key.type ?? target.type); - return { layout: fromLayout?.value, master: master.find((candidate) => placeholderKind(candidate.key.type) === kind)?.value }; -} diff --git a/src/ir/result3-adapter.ts b/src/ir/result3-adapter.ts deleted file mode 100644 index 72c39e3..0000000 --- a/src/ir/result3-adapter.ts +++ /dev/null @@ -1,179 +0,0 @@ -import type { Element, Mark, ResultArtifact, Warning } from '@deckflow/pdf-lite-parse'; -import { stableId } from './ids.js'; -import type { CandidateAsset, DeckIR, DeckIrNode, ParseCandidate, QualityCheck } from './schema.js'; -import { makeIr, qualityOf, type SourceIdentity } from '../local/common.js'; - -export function result3ToDeckIr(options: { - document: ResultArtifact; - source: SourceIdentity; - assets?: CandidateAsset[]; - producer: { engine: 'local' | 'cloud'; name: string; version: string }; -}): ParseCandidate { - const { document, source } = options; - const allElements = [...document.elements, ...(document.furniture ?? [])]; - const elementIds = new Map(allElements.map((element) => [element.id, stableId(source.sha256, `pdf:element:${element.id}`)])); - const nodes: DeckIrNode[] = allElements.map((element) => elementNode(source.sha256, element, elementIds)); - for (const node of nodes) { - if (node.parentId) nodes.find((candidate) => candidate.id === node.parentId)?.children.push(node.id); - } - let nextOrder = Math.max(-1, ...nodes.map((node) => node.order)) + 1; - for (const element of allElements) { - if (element.type !== 'table') continue; - const tableNode = nodes.find((node) => node.id === elementIds.get(element.id)); - if (!tableNode) continue; - for (let rowIndex = 0; rowIndex < element.table.rows; rowIndex += 1) { - const rowId = stableId(source.sha256, `pdf:element:${element.id}:row:${rowIndex}`); - const rowNode: DeckIrNode = { - id: rowId, type: 'table_row', parentId: tableNode.id, children: [], order: nextOrder++, page: element.page, - sourceRef: { page: element.page, path: `elements/${element.id}/rows/${rowIndex}` }, - }; - nodes.push(rowNode); tableNode.children.push(rowId); - for (const cell of element.table.cells.filter((candidate) => candidate.r === rowIndex).sort((a, b) => a.c - b.c)) { - const cellId = stableId(source.sha256, `pdf:element:${element.id}:cell:${cell.r}:${cell.c}`); - nodes.push({ - id: cellId, type: 'table_cell', parentId: rowId, children: [], order: nextOrder++, text: cell.text, - page: cell.page, bbox: cell.bbox, confidence: cell.confidence, - sourceRef: { page: cell.page, objectIds: cell.sourceObjectIds, path: `elements/${element.id}/cells/${cell.r}/${cell.c}` }, - extensions: { row: cell.r, column: cell.c, rowSpan: cell.rowSpan, gridSpan: cell.colSpan, isHeader: cell.isHeader, role: cell.role, ...(cell.sourceRasters ? { sourceRasters: cell.sourceRasters } : {}) }, - }); - rowNode.children.push(cellId); - } - } - } - for (const annotation of document.annotations ?? []) { - nodes.push({ - id: stableId(source.sha256, `pdf:annotation:${annotation.id}`), type: 'annotation', parentId: null, children: [], - order: nextOrder++, text: annotation.contents, page: annotation.page, bbox: annotation.bbox, - sourceRef: { page: annotation.page, objectIds: annotation.sourceObjectIds }, - extensions: { subtype: annotation.subtype, target: annotation.target }, - }); - } - const checks = warningsToChecks(document.warnings ?? [], source.sha256); - const unknownElements = allElements.filter((element) => element.type === 'unknown'); - if (unknownElements.length) checks.push({ - code: 'unclassified_objects', severity: 'warning', message: `${unknownElements.length} PDF object(s) were preserved without a semantic classification.`, - pages: [...new Set(unknownElements.map((element) => element.page))], - nodeIds: unknownElements.map((element) => elementIds.get(element.id)!), - }); - for (const page of document.pages) { - if (page.status === 'failed') checks.push({ code: 'page_parse_failed', severity: 'error', pages: [page.index], message: `PDF page ${page.index} could not be parsed.` }); - else if (page.status === 'degraded') checks.push({ code: 'page_degraded', severity: 'warning', pages: [page.index], message: `PDF page ${page.index} was parsed with reduced fidelity.` }); - } - const textCharacters = nodes.reduce((sum, node) => sum + (node.text?.length ?? 0), 0); - const textByPage = new Map(); - for (const node of nodes) if (node.page !== undefined) textByPage.set(node.page, (textByPage.get(node.page) ?? 0) + (node.text?.trim().length ?? 0)); - const scannedPages = document.pages.filter(page => { - const characters = textByPage.get(page.index) ?? 0; - return characters < 20 && page.probe?.imageAreaRatio > 0.9; - }); - if (scannedPages.length) checks.push({ code: 'ocr_required', severity: 'warning', pages: scannedPages.map(page => page.index), - message: 'Some pages have a large raster and little extracted text; they may need OCR (image covers can also match).', detail: { method: 'image-text-heuristic' } }); - const quality = qualityOf(dedupeChecks(checks), { - pages: { parsed: document.pages.filter((page) => page.status !== 'failed').length, total: document.pages.length }, - objects: { parsed: allElements.length - unknownElements.length, opaque: unknownElements.length, total: allElements.length }, - textCharacters, - sourceObjectCoverage: document.pages.length === 0 ? 0 : document.pages.reduce((sum, page) => sum + page.sourceObjectCoverage, 0) / document.pages.length, - }); - const pages = document.pages.map((page) => ({ - id: stableId(source.sha256, `pdf:page:${page.index}`, 'p'), index: page.index, - width: page.width, height: page.height, - nodeIds: nodes.filter((node) => node.page === page.index).map((node) => node.id), - sourceRef: { page: page.index }, - })); - const ir = makeIr({ - format: 'pdf', source, - producer: options.producer, - metadata: { ...document.docInfo, outline: document.outline, encrypted: document.source.encrypted }, - pages, nodes, ...(options.assets ? { assets: options.assets } : {}), quality, - }); - return { ir, quality, assets: options.assets ?? [], warnings: quality.checks.map((check) => check.message) }; -} - -function elementNode(hash: string, element: Element, ids: Map): DeckIrNode { - const sourceRef = { page: element.page, objectIds: element.sourceObjectIds ?? [], path: `elements/${element.id}`, provenance: element.provenance }; - const node: DeckIrNode = { - id: ids.get(element.id)!, type: element.type, parentId: element.parentId ? (ids.get(element.parentId) ?? null) : null, children: [], - order: element.order, text: element.text, page: element.page, bbox: element.bbox, sourceRef, - confidence: element.confidence, - ...(element.style ? { style: { ...element.style } } : {}), - }; - if (element.marks?.length) { - node.runs = markedRuns(element.text, element.marks); - const links = element.marks.filter((mark): mark is Extract => mark.type === 'link').map((mark) => ({ href: linkHref(mark.target), text: element.text.slice(mark.start, mark.end) })); - if (links.length) node.links = links; - } - node.extensions = { - ...(element.marks?.length ? { marks: element.marks } : {}), - ...(element.bboxes ? { bboxes: element.bboxes } : {}), - ...(element.sourceRasters ? { sourceRasters: element.sourceRasters } : {}), - ...(element.continuesFrom !== undefined ? { continuesFrom: element.continuesFrom } : {}), - isBodyContent: element.isBodyContent, - }; - if (element.type === 'heading') node.extensions = { ...(node.extensions ?? {}), level: element.level }; - if (element.type === 'list' || element.type === 'list_item') node.extensions = { ...(node.extensions ?? {}), list: 'list' in element ? element.list : { marker: element.marker, depth: element.depth } }; - if (element.type === 'table') node.extensions = { ...(node.extensions ?? {}), table: element.table }; - if (element.type === 'figure' || element.type === 'chart') node.extensions = { ...(node.extensions ?? {}), assetPath: element.figure.assetPath, kind: element.figure.kind }; - if (element.type === 'formula') node.extensions = { ...(node.extensions ?? {}), formula: element.formula }; - if (element.type === 'code') node.extensions = { ...(node.extensions ?? {}), language: element.code.language }; - if (element.type === 'caption') node.extensions = { ...(node.extensions ?? {}), captionOf: ids.get(element.captionOf) ?? element.captionOf }; - if ('furnitureKind' in element) node.extensions = { ...(node.extensions ?? {}), furnitureKind: element.furnitureKind }; - if (element.type === 'unknown') node.opaque = { type: 'pdf_unknown', reason: 'Upstream parser could not classify this source object.' }; - return node; -} - -function markedRuns(text: string, marks: Mark[]): NonNullable { - const points = new Set([0, text.length]); - for (const mark of marks) { points.add(Math.max(0, Math.min(text.length, mark.start))); points.add(Math.max(0, Math.min(text.length, mark.end))); } - const ordered = [...points].sort((a, b) => a - b); const runs: NonNullable = []; - for (let index = 0; index < ordered.length - 1; index += 1) { - const start = ordered[index]!; const end = ordered[index + 1]!; if (end <= start) continue; - const active = marks.filter((mark) => mark.start <= start && mark.end >= end); - const link = active.find((mark): mark is Extract => mark.type === 'link'); - const vertical = active.find((mark) => mark.type === 'sup' || mark.type === 'sub'); - runs.push({ text: text.slice(start, end), - ...(active.some((mark) => mark.type === 'bold') ? { bold: true } : {}), - ...(active.some((mark) => mark.type === 'italic') ? { italic: true } : {}), - ...(active.some((mark) => mark.type === 'underline') ? { underline: true } : {}), - ...(active.some((mark) => mark.type === 'strike') ? { strike: true } : {}), - ...(active.some((mark) => mark.type === 'code') ? { code: true } : {}), - ...(link ? { href: linkHref(link.target) } : {}), - ...(vertical ? { style: { verticalAlign: vertical.type } } : {}), - }); - } - return runs; -} - -function linkHref(target: Extract['target']): string { - if (target.kind === 'external') return target.href; - return target.destination ? `#${target.destination}` : `#page-${target.page ?? 1}`; -} - -function warningsToChecks(warnings: Warning[], hash: string): QualityCheck[] { - return warnings.map((warning) => ({ - code: warningCode(warning.code), severity: warning.severity === 'warn' ? 'warning' : warning.severity, - message: warning.message, - ...(warning.scope === 'page' || warning.scope === 'element' ? { pages: [warning.page] } : {}), - ...(warning.scope === 'element' ? { nodeIds: [stableId(hash, `pdf:element:${warning.elementId}`)] } : {}), - ...(warning.detail ? { detail: warning.detail } : {}), - })); -} - -function warningCode(code: string): string { - const mapped: Record = { - NO_TEXT_LAYER: 'ocr_required', BROKEN_TEXT_LAYER: 'ocr_required', TEXT_LAYER_SUSPECT: 'text_layer_suspect', - FONT_NOT_MAPPABLE: 'font_not_mappable', LAYOUT_UNCERTAIN: 'reading_order_ambiguous', - TABLE_GRID_UNCLOSED: 'table_ambiguous', RASTERIZER_UNAVAILABLE: 'composite_figure_unavailable', - SOURCE_OBJECT_LOSS: 'source_object_loss', UNREPRESENTABLE_CONTENT: 'unrepresentable_content', - }; - return mapped[code] ?? code.toLowerCase(); -} - -function dedupeChecks(checks: QualityCheck[]): QualityCheck[] { - const seen = new Set(); - return checks.filter((check) => { - const key = `${check.code}:${check.message}:${check.pages?.join(',') ?? ''}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); -} diff --git a/src/ir/schema.ts b/src/ir/schema.ts deleted file mode 100644 index 04d0890..0000000 --- a/src/ir/schema.ts +++ /dev/null @@ -1,115 +0,0 @@ -export const DECK_IR_SCHEMA_VERSION = 'deckir.v1' as const; - -export type DocumentFormat = 'pdf' | 'pptx' | 'docx' | 'html' | 'keynote'; -export type QualityStatus = 'pass' | 'degraded' | 'unsupported'; - -export interface SourceRef { - part?: string | undefined; - relationship?: string | undefined; - path?: string | undefined; - page?: number | undefined; - objectIds?: string[] | undefined; - [key: string]: unknown; -} - -export interface DeckIrRun { - text: string; - bold?: boolean | undefined; - italic?: boolean | undefined; - underline?: boolean | undefined; - strike?: boolean | undefined; - code?: boolean | undefined; - href?: string | undefined; - style?: Record | undefined; -} - -export interface DeckIrNode { - id: string; - type: string; - parentId: string | null; - children: string[]; - order: number; - text?: string | undefined; - runs?: DeckIrRun[] | undefined; - style?: Record | undefined; - links?: Array<{ href: string; text?: string | undefined }> | undefined; - page?: number | undefined; - bbox?: [number, number, number, number] | undefined; - zIndex?: number | undefined; - sourceRef: SourceRef; - confidence?: number | undefined; - issues?: string[] | undefined; - extensions?: Record | undefined; - opaque?: { type: string; reason?: string | undefined; data?: Record | undefined } | undefined; -} - -export interface DeckIrPage { - id: string; - index: number; - width?: number | undefined; - height?: number | undefined; - nodeIds: string[]; - sourceRef: SourceRef; -} - -export interface DeckIrAsset { - id: string; - path: string; - hash: string; - bytes: number; - mediaType?: string | undefined; - sourceRef?: SourceRef | undefined; -} - -export interface QualityCheck { - code: string; - severity: 'info' | 'warning' | 'error'; - message: string; - pages?: number[] | undefined; - nodeIds?: string[] | undefined; - detail?: Record | undefined; -} - -export interface QualityReport { - status: QualityStatus; - checks: QualityCheck[]; - coverage: { - pages?: { parsed: number; total: number } | undefined; - objects?: { parsed: number; opaque: number; total?: number | undefined } | undefined; - textCharacters?: number | undefined; - [key: string]: unknown; - }; - recommendation?: 'cloud' | undefined; -} - -export interface DeckIR { - schemaVersion: typeof DECK_IR_SCHEMA_VERSION; - format: DocumentFormat; - source: { sha256: string; name: string; bytes: number }; - producer: { engine: 'local' | 'cloud'; name: string; version: string }; - document: { - metadata: Record; - pages: DeckIrPage[]; - nodes: DeckIrNode[]; - assets: DeckIrAsset[]; - }; - quality: QualityReport; -} - -export interface CandidateAsset { - /** Logical path used by nodes before artifact materialization. */ - path: string; - data: Uint8Array; - mediaType?: string | undefined; - sourceRef?: SourceRef | undefined; -} - -export interface ParseCandidate { - assessment?: import('../shared/assessment-types.js').Assessment; - decision?: import('../shared/policy-types.js').RouteDecision; - ir: DeckIR; - quality: QualityReport; - assets: CandidateAsset[]; - remote?: { taskId: string; irKey: string; expiresAt?: string | undefined } | undefined; - warnings?: string[] | undefined; -} diff --git a/src/ir/slide-order.ts b/src/ir/slide-order.ts deleted file mode 100644 index d1d8fd6..0000000 --- a/src/ir/slide-order.ts +++ /dev/null @@ -1,107 +0,0 @@ -import type { DeckIrNode, DeckIrPage } from './schema.js'; - -/** 两个形状上下重叠至少占较高者的这个比例,才算同一行。按较高者算,高的正文栏不会把旁边图示的标签整列吞进一行。 */ -const SAME_ROW_MIN_OVERLAP = 0.5; - -/** 版式家具:页脚、日期、页码、页眉占位符。 */ -const FURNITURE_TYPES: ReadonlySet = new Set(['footer', 'header', 'page_number']); - -/** 子节点按版面重排的容器。表格的行与单元格是结构顺序,不能按位置重排。 */ -const SPATIAL_CONTAINERS: ReadonlySet = new Set(['group']); - -/** - * 幻灯片的阅读顺序。 - * - * OOXML 形状树的先后是 z-order(谁叠在谁上面),不是阅读顺序。图示页上两者差得最远:实测 - * 一页 Cookie 时序图,标签按 z-order 读出来是「响应、响应、cookie 文件、一周后、请求……」, - * 时序全乱。这里按版面重排:标题占位符最先;接着是正文类占位符(正文、副标题、内容),它们 - * 是这一页的主体;然后是其余有位置的形状。后两组各自自上而下,同一行内自左而右。页脚、页码 - * 这类家具,以及没有位置的节点(讲者备注、嵌入对象关系)排在最后,彼此保持原有先后。组合 - * 内部同样重排,组合本身按它的外框参与排序。 - * - * 正文占位符不和散落的形状一起按位置排:实测一页「正文栏 + 右侧图示」,图例标签的上沿比 - * 正文框高一点,纯按位置就把「outgoing message queue」排到了「Three major components」前面。 - * - * 只改 `order`、父节点的 `children` 与页面的 `nodeIds`;z-order 由各解析器记在 `zIndex`。 - * 每页节点原本占着一段连续的 order,重排只在这段里换位,页与页之间互不影响。 - */ -export function orderSlideNodes(nodes: readonly DeckIrNode[], pages: DeckIrPage[]): void { - const byId = new Map(nodes.map((node) => [node.id, node])); - for (const page of pages) { - const pageNodes = page.nodeIds.flatMap((id) => { - const node = byId.get(id); - return node ? [node] : []; - }); - if (pageNodes.length === 0) continue; - const inPage = new Set(pageNodes.map((node) => node.id)); - let next = Math.min(...pageNodes.map((node) => node.order)); - const visit = (node: DeckIrNode): void => { - const kids = node.children.flatMap((id) => { - const child = byId.get(id); - return child && inPage.has(child.id) ? [child] : []; - }); - const sorted = SPATIAL_CONTAINERS.has(node.type) ? readingOrder(kids) : [...kids].sort(byOriginalOrder); - node.order = next; - next += 1; - node.children = [...sorted.map((child) => child.id), ...node.children.filter((id) => !inPage.has(id))]; - sorted.forEach(visit); - }; - readingOrder(pageNodes.filter((node) => node.parentId === null || !inPage.has(node.parentId))).forEach(visit); - page.nodeIds = pageNodes.sort((left, right) => left.order - right.order).map((node) => node.id); - } -} - -function readingOrder(items: readonly DeckIrNode[]): DeckIrNode[] { - type Placed = DeckIrNode & { bbox: [number, number, number, number] }; - const titles: DeckIrNode[] = []; - const placeholders: Placed[] = []; - const positioned: Placed[] = []; - const furniture: DeckIrNode[] = []; - const unplaced: DeckIrNode[] = []; - for (const item of items) { - if (item.type === 'heading') titles.push(item); - else if (FURNITURE_TYPES.has(item.type)) furniture.push(item); - else if (!item.bbox) unplaced.push(item); - else if (isPlaceholder(item)) placeholders.push(item as Placed); - else positioned.push(item as Placed); - } - return [ - ...titles.sort(byOriginalOrder), - ...rowMajor(placeholders), - ...rowMajor(positioned), - ...furniture.sort(byOriginalOrder), - ...unplaced.sort(byOriginalOrder), - ]; -} - -/** 两个解析器都把占位符属性记在 `extensions.placeholder`;标题与家具已在前面分走。 */ -function isPlaceholder(node: DeckIrNode): boolean { - const placeholder = node.extensions?.placeholder; - return typeof placeholder === 'object' && placeholder !== null; -} - -function rowMajor(items: ReadonlyArray): DeckIrNode[] { - const sorted = [...items].sort((left, right) => - left.bbox[1] - right.bbox[1] || left.bbox[0] - right.bbox[0] || byOriginalOrder(left, right)); - const rows: Array<{ top: number; bottom: number; items: Array }> = []; - for (const item of sorted) { - const [, top, , bottom] = item.bbox; - const row = rows.at(-1); - if (row) { - const overlap = Math.min(row.bottom, bottom) - Math.max(row.top, top); - const taller = Math.max(row.bottom - row.top, bottom - top); - if (taller > 0 && overlap >= SAME_ROW_MIN_OVERLAP * taller) { - row.items.push(item); - row.top = Math.min(row.top, top); - row.bottom = Math.max(row.bottom, bottom); - continue; - } - } - rows.push({ top, bottom, items: [item] }); - } - return rows.flatMap((row) => row.items.sort((left, right) => left.bbox[0] - right.bbox[0] || byOriginalOrder(left, right))); -} - -function byOriginalOrder(left: DeckIrNode, right: DeckIrNode): number { - return left.order - right.order; -} diff --git a/src/ir/validate.ts b/src/ir/validate.ts deleted file mode 100644 index 0c9065e..0000000 --- a/src/ir/validate.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { DeckOpsError } from '../errors/index.js'; -import { DECK_IR_SCHEMA_VERSION, type DeckIR, type DeckIrNode } from './schema.js'; - -/** - * Dependency-free runtime validation mirroring schemas/deckir.v1.schema.json. - * Keeping this small avoids loading a general-purpose schema engine at startup. - */ -export function validateDeckIR(value: unknown): DeckIR { - if (!isRecord(value) || value.schemaVersion !== DECK_IR_SCHEMA_VERSION) { - throw DeckOpsError.input('ir.json does not contain deckir.v1.'); - } - if (!['pdf', 'pptx', 'docx', 'html', 'keynote'].includes(String(value.format))) { - throw DeckOpsError.input('DeckIR has an unsupported format.'); - } - if (!isRecord(value.source) || !/^[a-f0-9]{64}$/.test(String(value.source.sha256)) || !isString(value.source.name) || !isNonNegativeInteger(value.source.bytes)) { - throw DeckOpsError.input('DeckIR source metadata is incomplete.'); - } - if (!isRecord(value.producer) || !['local', 'cloud'].includes(String(value.producer.engine)) || !isString(value.producer.name) || typeof value.producer.version !== 'string') { - throw DeckOpsError.input('DeckIR producer metadata is incomplete.'); - } - if (!isRecord(value.document) || !isRecord(value.document.metadata) || !Array.isArray(value.document.pages) || !Array.isArray(value.document.nodes) || !Array.isArray(value.document.assets)) { - throw DeckOpsError.input('DeckIR document collections are incomplete.'); - } - if (!isRecord(value.quality) || !['pass', 'degraded', 'unsupported'].includes(String(value.quality.status)) || !Array.isArray(value.quality.checks) || !isRecord(value.quality.coverage)) { - throw DeckOpsError.input('DeckIR quality report is incomplete.'); - } - - const ids = new Set(); const nodeById = new Map(); - for (const raw of value.document.nodes) { - if (!isNode(raw) || ids.has(raw.id)) { - throw DeckOpsError.input('DeckIR contains an invalid or duplicate node id.'); - } - ids.add(raw.id); - nodeById.set(raw.id, raw); - } - for (const raw of value.document.nodes as DeckIrNode[]) { - if (raw.parentId !== null && !ids.has(raw.parentId)) { - throw DeckOpsError.input(`DeckIR node ${raw.id} refers to a missing parent.`); - } - if (raw.children.some((id) => !ids.has(id))) { - throw DeckOpsError.input(`DeckIR node ${raw.id} refers to a missing child.`); - } - if (raw.parentId !== null && !nodeById.get(raw.parentId)?.children.includes(raw.id)) { - throw DeckOpsError.input(`DeckIR node ${raw.id} is not registered by its parent.`); - } - if (raw.children.some((id) => nodeById.get(id)?.parentId !== raw.id)) { - throw DeckOpsError.input(`DeckIR node ${raw.id} has an inconsistent child relationship.`); - } - } - const pageIds = new Set(); - for (const page of value.document.pages) { - if (!isRecord(page) || !isString(page.id) || pageIds.has(page.id) || !isNonNegativeInteger(page.index) || - !Array.isArray(page.nodeIds) || page.nodeIds.some((id) => !isString(id) || !ids.has(id)) || !isRecord(page.sourceRef)) { - throw DeckOpsError.input('DeckIR contains an invalid page index.'); - } - pageIds.add(page.id); - } - const assetIds = new Set(); const assetPaths = new Set(); - for (const asset of value.document.assets) { - if (!isRecord(asset) || !isString(asset.id) || assetIds.has(asset.id) || !isString(asset.path) || assetPaths.has(asset.path) || - !/^assets\/[a-f0-9]{64}(?:\.[a-z0-9]{1,8})?$/.test(asset.path) || !/^[a-f0-9]{64}$/.test(String(asset.hash)) || - !isNonNegativeInteger(asset.bytes)) { - throw DeckOpsError.input('DeckIR contains an invalid asset index.'); - } - assetIds.add(asset.id); assetPaths.add(asset.path); - } - return value as unknown as DeckIR; -} -function isNode(value: unknown): value is DeckIrNode { - return isRecord(value) && isString(value.id) && isString(value.type) && - (value.parentId === null || isString(value.parentId)) && Array.isArray(value.children) && - value.children.every(isString) && isNonNegativeInteger(value.order) && isRecord(value.sourceRef); -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function isString(value: unknown): value is string { - return typeof value === 'string' && value.length > 0; -} - -function isNonNegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} diff --git a/src/local/common.ts b/src/local/common.ts deleted file mode 100644 index 5d22c96..0000000 --- a/src/local/common.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { finalizeAssets } from '../ir/assets.js'; -import { DECK_IR_SCHEMA_VERSION, type CandidateAsset, type DeckIR, type DeckIrNode, type DocumentFormat, type QualityCheck, type QualityReport } from '../ir/schema.js'; - -export interface SourceIdentity { - sha256: string; - name: string; - bytes: number; -} - -export function qualityOf(checks: QualityCheck[], coverage: QualityReport['coverage']): QualityReport { - const status = checks.some((check) => check.severity === 'error' || check.severity === 'warning') ? 'degraded' : 'pass'; - return { status, checks, coverage }; -} - -export function makeIr(options: { - format: DocumentFormat; - source: SourceIdentity; - producer: { name: string; version: string; engine?: 'local' | 'cloud' }; - metadata?: Record; - pages?: DeckIR['document']['pages']; - nodes: DeckIrNode[]; - assets?: CandidateAsset[]; - quality: QualityReport; -}): DeckIR { - return { - schemaVersion: DECK_IR_SCHEMA_VERSION, - format: options.format, - source: options.source, - producer: { engine: options.producer.engine ?? 'local', name: options.producer.name, version: options.producer.version }, - document: { - metadata: options.metadata ?? {}, - pages: options.pages ?? [], - nodes: options.nodes, - assets: finalizeAssets(options.assets ?? [], options.nodes), - }, - quality: options.quality, - }; -} - -export function mediaTypeForPath(file: string): string | undefined { - const ext = file.toLowerCase().split('.').pop(); - return ({ png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', svg: 'image/svg+xml', - webp: 'image/webp', tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp', emf: 'image/emf', wmf: 'image/wmf' } as Record)[ext ?? '']; -} - -/** - * OOXML 包里一张图片的资产路径与媒体类型。 - * - * 扩展名认得就照扩展名;认不得就看文件头。实测一份讲义有两张 PNG 存成 `ppt/media/image92.tmp`, - * 原先照原名落成 `.tmp` 资产,Markdown 里的链接没有查看器打得开。认出来后在逻辑路径上补一个 - * 真实扩展名(`image92.tmp.png`),落盘时的文件名随之而来;不改原名,免得与包里别的部件撞名。 - */ -export function packageImageAsset(target: string, data: Uint8Array): { path: string; mediaType?: string } { - const declared = mediaTypeForPath(target); - if (declared) return { path: target, mediaType: declared }; - const sniffed = sniffImage(data); - return sniffed ? { path: `${target}${sniffed.extension}`, mediaType: sniffed.mediaType } : { path: target }; -} - -/** 按文件头认图片类型;认不出返回 undefined。 */ -export function sniffImage(data: Uint8Array): { mediaType: string; extension: string } | undefined { - const starts = (...bytes: number[]): boolean => bytes.every((byte, index) => data[index] === byte); - const ascii = (offset: number, text: string): boolean => [...text].every((character, index) => data[offset + index] === character.charCodeAt(0)); - if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return { mediaType: 'image/png', extension: '.png' }; - if (starts(0xff, 0xd8, 0xff)) return { mediaType: 'image/jpeg', extension: '.jpg' }; - if (ascii(0, 'GIF87a') || ascii(0, 'GIF89a')) return { mediaType: 'image/gif', extension: '.gif' }; - if (ascii(0, 'RIFF') && ascii(8, 'WEBP')) return { mediaType: 'image/webp', extension: '.webp' }; - if (starts(0x49, 0x49, 0x2a, 0x00) || starts(0x4d, 0x4d, 0x00, 0x2a)) return { mediaType: 'image/tiff', extension: '.tiff' }; - if (ascii(0, 'BM') && data.byteLength >= 26) return { mediaType: 'image/bmp', extension: '.bmp' }; - if (starts(0x01, 0x00, 0x00, 0x00) && ascii(40, ' EMF')) return { mediaType: 'image/emf', extension: '.emf' }; - if (starts(0xd7, 0xcd, 0xc6, 0x9a) || starts(0x01, 0x00, 0x09, 0x00) || starts(0x02, 0x00, 0x09, 0x00)) return { mediaType: 'image/wmf', extension: '.wmf' }; - return undefined; -} diff --git a/src/local/docx/parser.ts b/src/local/docx/parser.ts deleted file mode 100644 index 187d262..0000000 --- a/src/local/docx/parser.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { DeckIrNode, DeckIrRun, ParseCandidate, QualityCheck, CandidateAsset } from '../../ir/schema.js'; -import { stableId } from '../../ir/ids.js'; -import { makeIr, packageImageAsset, qualityOf, type SourceIdentity } from '../common.js'; -import type { LocalLimits } from '../limits.js'; -import { OpcPackage, type Relationship } from '../opc/package.js'; -import { children, descendants, first, textContent, type XmlNode } from '../xml.js'; - -export interface DocxOptions { - trackedChanges?: 'final' | 'original' | 'all'; -} - -interface Context { - pkg: OpcPackage; - source: SourceIdentity; - rels: Map; - styles: Map; - numbering: Map>; - strategy: NonNullable; - nodes: DeckIrNode[]; - assets: CandidateAsset[]; - checks: QualityCheck[]; - order: number; -} - -export function parseDocx(data: Uint8Array, source: SourceIdentity, limits: LocalLimits, options: DocxOptions = {}): ParseCandidate { - const pkg = new OpcPackage(data, limits); - if (!pkg.has('[Content_Types].xml') || !pkg.has('word/document.xml')) { - throw new Error('DOCX package is missing required OOXML parts.'); - } - const ctx: Context = { - pkg, source, rels: pkg.relationships('word/document.xml'), styles: readStyles(pkg), numbering: readNumbering(pkg), - strategy: options.trackedChanges ?? 'final', nodes: [], assets: [], checks: [], order: 0, - }; - const document = pkg.xml('word/document.xml'); - const body = descendants(document, 'body')[0]; - if (!body) throw new Error('word/document.xml has no document body.'); - parseBlocks(body, ctx, null, 'word/document.xml'); - - for (const [part, type] of [ - ['word/footnotes.xml', 'footnote'], ['word/endnotes.xml', 'endnote'], ['word/comments.xml', 'comment'], - ] as const) { - if (!pkg.has(part)) continue; - const partRoot = pkg.xml(part); - const partCtx = { ...ctx, rels: pkg.relationships(part) }; - parseBlocks(partRoot, partCtx, null, part, type); - ctx.order = partCtx.order; - } - for (const rel of ctx.rels.values()) { - if (rel.external) continue; - const basename = rel.target.split('/').pop() ?? ''; - if (!/^header\d+\.xml$/.test(basename) && !/^footer\d+\.xml$/.test(basename)) continue; - if (!pkg.has(rel.target)) continue; - const partCtx = { ...ctx, rels: pkg.relationships(rel.target) }; - parseBlocks(pkg.xml(rel.target), partCtx, null, rel.target, basename.startsWith('header') ? 'header' : 'footer'); - ctx.order = partCtx.order; - } - - for (const alt of descendants(body, 'altChunk')) { - ctx.checks.push({ code: 'alt_chunk_unsupported', severity: 'error', message: 'DOCX contains altChunk content that cannot be expanded locally.' }); - addOpaque(ctx, null, 'altChunk', 'word/document.xml', alt.attributes.id ?? alt.attributes['r:id']); - } - auditUnsupportedContent(ctx, body); - const opaque = ctx.nodes.filter((node) => node.opaque).length; - const quality = qualityOf(ctx.checks, { - objects: { parsed: ctx.nodes.length - opaque, opaque, total: ctx.nodes.length }, - textCharacters: ctx.nodes.reduce((sum, node) => sum + (node.text?.length ?? 0), 0), - }); - const metadata = readCoreProperties(pkg); - const ir = makeIr({ format: 'docx', source, producer: { name: 'deckparse-docx', version: '3' }, metadata, - nodes: ctx.nodes, assets: ctx.assets, quality }); - return { ir, quality, assets: ctx.assets, warnings: quality.checks.map((check) => check.message) }; -} - -function parseBlocks(root: XmlNode, ctx: Context, parentId: string | null, part: string, forcedType?: string): void { - for (const child of root.children) { - if (child.local === 'p') parseParagraph(child, ctx, parentId, part, forcedType); - else if (child.local === 'tbl') parseTable(child, ctx, parentId, part); - else if (['body', 'hdr', 'ftr', 'footnotes', 'endnotes', 'comments', 'footnote', 'endnote', 'comment', 'sdt', 'sdtContent'].includes(child.local)) { - parseBlocks(child, ctx, parentId, part, forcedType); - } - } -} - -function parseParagraph(node: XmlNode, ctx: Context, parentId: string | null, part: string, forcedType?: string): string | undefined { - const locator = `${part}:p:${ctx.order}`; - const pPr = first(node, 'pPr'); - const styleId = first(pPr ?? emptyNode(), 'pStyle')?.attributes.val; - const numPr = first(pPr ?? emptyNode(), 'numPr'); - const numId = first(numPr ?? emptyNode(), 'numId')?.attributes.val; - const level = Number(first(numPr ?? emptyNode(), 'ilvl')?.attributes.val ?? 0); - const runs = collectRuns(node, ctx, part); - const text = runs.map((run) => run.text).join(''); - const images = imageRefs(node, ctx.rels); - if (!text && images.length === 0 && descendants(node, 'oMath').length === 0) return undefined; - const resolvedStyle = resolveStyle(styleId, ctx.styles); - const type = forcedType ?? (numId ? 'list_item' : headingType(resolvedStyle?.name)); - const id = stableId(ctx.source.sha256, locator); - const deckNode: DeckIrNode = { - id, type, parentId, children: [], order: ctx.order++, text, runs, sourceRef: { part, path: locator }, - ...(resolvedStyle ? { style: { styleId, name: resolvedStyle.name } } : {}), - }; - if (type === 'heading') deckNode.extensions = { ...(deckNode.extensions ?? {}), level: headingLevel(resolvedStyle?.name) }; - if (numId) { - const definition = ctx.numbering.get(numId)?.get(level); - deckNode.extensions = { numbering: { numId, level, format: definition?.format, text: definition?.text } }; - } - if (descendants(node, 'br').some((item) => item.attributes.type === 'page')) { - deckNode.extensions = { ...(deckNode.extensions ?? {}), pageBreakAfter: true }; - } - ctx.nodes.push(deckNode); - attach(ctx, parentId, id); - for (const image of images) addImageNode(image, ctx, id, part); - for (const math of descendants(node, 'oMath')) { - const mathText = textContent(math).trim(); - const mathId = stableId(ctx.source.sha256, `${locator}:math:${deckNode.children.length}`); - ctx.nodes.push({ id: mathId, type: 'formula', parentId: id, children: [], order: ctx.order++, text: mathText, - sourceRef: { part, path: `${locator}/oMath` }, extensions: { ommlText: mathText, omml: xmlSnapshot(math) }, - opaque: { type: 'omml', reason: 'OMML is preserved but not converted to a layout-equivalent formula.' } }); - deckNode.children.push(mathId); - ctx.checks.push({ code: 'formula_partial', severity: 'warning', message: 'An OMML formula was preserved as text and opaque source structure.', nodeIds: [mathId] }); - } - return id; -} - -function parseTable(table: XmlNode, ctx: Context, parentId: string | null, part: string): void { - const tableId = stableId(ctx.source.sha256, `${part}:table:${ctx.order}`); - const tableNode: DeckIrNode = { id: tableId, type: 'table', parentId, children: [], order: ctx.order++, sourceRef: { part, path: `table/${tableId}` }, extensions: { rows: children(table, 'tr').length } }; - ctx.nodes.push(tableNode); - attach(ctx, parentId, tableId); - for (const [rowIndex, row] of children(table, 'tr').entries()) { - const rowId = stableId(ctx.source.sha256, `${tableId}:row:${rowIndex}`); - const rowNode: DeckIrNode = { id: rowId, type: 'table_row', parentId: tableId, children: [], order: ctx.order++, sourceRef: { part, path: `table/${tableId}/row/${rowIndex}` } }; - ctx.nodes.push(rowNode); tableNode.children.push(rowId); - for (const [cellIndex, cell] of children(row, 'tc').entries()) { - const cellId = stableId(ctx.source.sha256, `${rowId}:cell:${cellIndex}`); - const tcPr = first(cell, 'tcPr'); - const cellRuns = descendants(cell, 'p').flatMap((paragraph, index) => [...(index ? [{ text: '\n' }] : []), ...collectRuns(paragraph, ctx, part)]); - const cellNode: DeckIrNode = { id: cellId, type: 'table_cell', parentId: rowId, children: [], order: ctx.order++, text: cellRuns.map((run) => run.text).join(''), runs: cellRuns, - sourceRef: { part, path: `table/${tableId}/row/${rowIndex}/cell/${cellIndex}` }, - extensions: { row: rowIndex, column: cellIndex, gridSpan: Number(first(tcPr ?? emptyNode(), 'gridSpan')?.attributes.val ?? 1), - verticalMerge: first(tcPr ?? emptyNode(), 'vMerge')?.attributes.val ?? (first(tcPr ?? emptyNode(), 'vMerge') ? 'continue' : undefined) } }; - ctx.nodes.push(cellNode); rowNode.children.push(cellId); - parseBlocks(cell, ctx, cellId, part); - } - } -} - -function collectRuns(node: XmlNode, ctx: Context, part: string, inheritedHref?: string): DeckIrRun[] { - const runs: DeckIrRun[] = []; - const walk = (current: XmlNode, href?: string, deleted = false, inserted = false): void => { - const nextDeleted = deleted || current.local === 'del'; - const nextInserted = inserted || current.local === 'ins'; - if (nextDeleted && ctx.strategy === 'final' || nextInserted && ctx.strategy === 'original') return; - let nextHref = href; - if (current.local === 'hyperlink') { - const relId = current.attributes.id ?? current.attributes['r:id']; - nextHref = relId ? ctx.rels.get(relId)?.target : current.attributes.anchor ? `#${current.attributes.anchor}` : undefined; - } - if (current.local === 'r') { - const value = current.children.map((child) => child.local === 't' || child.local === 'delText' ? textContent(child) : child.local === 'tab' ? '\t' : child.local === 'br' ? '\n' : '').join(''); - if (value) { - const rPr = first(current, 'rPr'); - runs.push({ text: value, - ...(first(rPr ?? emptyNode(), 'b') ? { bold: true } : {}), ...(first(rPr ?? emptyNode(), 'i') ? { italic: true } : {}), - ...(first(rPr ?? emptyNode(), 'u') ? { underline: true } : {}), ...(onOff(first(rPr ?? emptyNode(), 'strike')) ? { strike: true } : {}), - ...(nextHref ? { href: nextHref } : {}), - ...(ctx.strategy === 'all' && (nextDeleted || nextInserted) ? { style: { revision: nextDeleted ? 'deleted' : 'inserted' } } : {}), - }); - } - return; - } - for (const child of current.children) walk(child, nextHref, nextDeleted, nextInserted); - }; - walk(node, inheritedHref); - return runs; -} - -function imageRefs(node: XmlNode, rels: Map): Relationship[] { - const result: Relationship[] = []; - for (const blip of descendants(node, 'blip')) { - const id = blip.attributes.embed ?? blip.attributes['r:embed'] ?? blip.attributes.link ?? blip.attributes['r:link']; - const rel = id ? rels.get(id) : undefined; - if (rel && !result.some((item) => item.id === rel.id)) result.push(rel); - } - return result; -} - -function addImageNode(rel: Relationship, ctx: Context, parentId: string, part: string): void { - const id = stableId(ctx.source.sha256, `${part}:image:${rel.id}:${parentId}`); - if (rel.external) { - ctx.nodes.push({ id, type: 'image', parentId, children: [], order: ctx.order++, sourceRef: { part, relationship: rel.id }, extensions: { externalUrl: rel.target }, issues: ['external_asset'] }); - ctx.nodes.find((node) => node.id === parentId)?.children.push(id); - ctx.checks.push({ code: 'external_asset', severity: 'warning', message: 'DOCX contains an externally linked image that was not downloaded.', nodeIds: [id] }); - return; - } - if (!ctx.pkg.has(rel.target)) { - ctx.checks.push({ code: 'missing_media', severity: 'error', message: `DOCX image relationship ${rel.id} points to a missing part.` }); - return; - } - const data = ctx.pkg.readAsset(rel.target); - const asset = packageImageAsset(rel.target, data); - ctx.assets.push({ ...asset, data, sourceRef: { part, relationship: rel.id } }); - ctx.nodes.push({ id, type: 'image', parentId, children: [], order: ctx.order++, sourceRef: { part, relationship: rel.id }, extensions: { assetPath: asset.path } }); - ctx.nodes.find((node) => node.id === parentId)?.children.push(id); -} - -function addOpaque(ctx: Context, parentId: string | null, type: string, part: string, relationship?: string): string { - const id = stableId(ctx.source.sha256, `${part}:opaque:${type}:${ctx.order}`); - ctx.nodes.push({ id, type: 'opaque', parentId, children: [], order: ctx.order++, sourceRef: { part, ...(relationship ? { relationship } : {}) }, opaque: { type } }); - attach(ctx, parentId, id); - return id; -} - -function auditUnsupportedContent(ctx: Context, body: XmlNode): void { - const seenRelationships = new Set(); - for (const object of [...descendants(body, 'object'), ...descendants(body, 'OLEObject')]) { - const relationship = object.attributes.id ?? object.attributes['r:id']; - if (relationship && seenRelationships.has(relationship)) continue; - if (relationship) seenRelationships.add(relationship); - const id = addOpaque(ctx, null, 'embedded_object', 'word/document.xml', relationship); - ctx.checks.push({ code: 'embedded_object_unsupported', severity: 'warning', message: 'DOCX contains an embedded object that is preserved as opaque metadata.', nodeIds: [id] }); - } - for (const rel of ctx.rels.values()) { - if (seenRelationships.has(rel.id) || !/(?:oleObject|package)$/i.test(rel.type)) continue; - seenRelationships.add(rel.id); - const id = addOpaque(ctx, null, 'embedded_object', 'word/document.xml', rel.id); - ctx.checks.push({ code: 'embedded_object_unsupported', severity: 'warning', message: 'DOCX contains an embedded package that is not expanded locally.', nodeIds: [id] }); - } - for (const part of ctx.pkg.names().filter((name) => /(?:^|\/)vbaProject\.bin$/i.test(name))) { - const id = addOpaque(ctx, null, 'vba_project', part); - ctx.checks.push({ code: 'macro_preserved', severity: 'warning', message: 'DOCX contains a VBA project. It was not executed and is preserved as an opaque part.', nodeIds: [id] }); - } -} - -function attach(ctx: Context, parentId: string | null, childId: string): void { - if (parentId) ctx.nodes.find((node) => node.id === parentId)?.children.push(childId); -} - -function readStyles(pkg: OpcPackage): Context['styles'] { - const result = new Map(); - if (!pkg.has('word/styles.xml')) return result; - for (const style of descendants(pkg.xml('word/styles.xml'), 'style')) { - const id = style.attributes.styleId; - const name = first(style, 'name')?.attributes.val; - if (id && name) result.set(id, { name, ...(first(style, 'basedOn')?.attributes.val ? { basedOn: first(style, 'basedOn')!.attributes.val } : {}) }); - } - return result; -} - -function resolveStyle(id: string | undefined, styles: Context['styles']): { name: string; basedOn?: string } | undefined { - let current = id; - let resolved: { name: string; basedOn?: string } | undefined; - const seen = new Set(); - while (current && !seen.has(current)) { seen.add(current); resolved ??= styles.get(current); current = styles.get(current)?.basedOn; } - return resolved; -} - -function readNumbering(pkg: OpcPackage): Context['numbering'] { - const result: Context['numbering'] = new Map(); - if (!pkg.has('word/numbering.xml')) return result; - const root = pkg.xml('word/numbering.xml'); - const abstracts = new Map>(); - for (const abstract of descendants(root, 'abstractNum')) { - const id = abstract.attributes.abstractNumId; - const levels = new Map(); - for (const level of children(abstract, 'lvl')) levels.set(Number(level.attributes.ilvl ?? 0), { format: first(level, 'numFmt')?.attributes.val ?? 'bullet', text: first(level, 'lvlText')?.attributes.val ?? '•' }); - if (id) abstracts.set(id, levels); - } - for (const num of descendants(root, 'num')) { - const id = num.attributes.numId; const abstractId = first(num, 'abstractNumId')?.attributes.val; - if (id && abstractId && abstracts.has(abstractId)) result.set(id, abstracts.get(abstractId)!); - } - return result; -} - -function readCoreProperties(pkg: OpcPackage): Record { - if (!pkg.has('docProps/core.xml')) return {}; - const root = pkg.xml('docProps/core.xml'); - const metadata: Record = {}; - for (const key of ['title', 'subject', 'creator', 'description', 'keywords', 'created', 'modified', 'lastModifiedBy']) { - const value = descendants(root, key)[0]; if (value) metadata[key] = textContent(value); - } - return metadata; -} - -function headingType(name?: string): string { - return name && /^heading\s*[1-9]/i.test(name) ? 'heading' : 'paragraph'; -} - -function headingLevel(name?: string): number { return Math.min(9, Math.max(1, Number(name?.match(/[1-9]/)?.[0] ?? 2))); } - -function xmlSnapshot(node: XmlNode): Record { - return { name: node.local, ...(Object.keys(node.attributes).length ? { attributes: node.attributes } : {}), - ...(node.text ? { text: node.text } : {}), ...(node.children.length ? { children: node.children.map(xmlSnapshot) } : {}) }; -} - -function emptyNode(): XmlNode { return { local: '', uri: '', attributes: {}, children: [], text: '' }; } - -function onOff(node: XmlNode | undefined): boolean { return !!node && !['0', 'false', 'off'].includes((node.attributes.val ?? node.attributes['w:val'] ?? '').toLowerCase()); } diff --git a/src/local/html/parser.ts b/src/local/html/parser.ts deleted file mode 100644 index ddcf4d8..0000000 --- a/src/local/html/parser.ts +++ /dev/null @@ -1,168 +0,0 @@ -import { parse, type DefaultTreeAdapterMap } from 'parse5'; -import { stableId } from '../../ir/ids.js'; -import type { DeckIrNode, ParseCandidate, QualityCheck } from '../../ir/schema.js'; -import { makeIr, qualityOf, type SourceIdentity } from '../common.js'; -import type { LocalLimits } from '../limits.js'; - -type HtmlNode = DefaultTreeAdapterMap['node']; -type HtmlElement = DefaultTreeAdapterMap['element']; - -const CONTENT_TAGS = new Set(['article', 'section', 'main', 'nav', 'aside', 'header', 'footer', 'div', 'address', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', 'code', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'figure', 'figcaption', 'img', 'a']); -const SKIP_TAGS = new Set(['script', 'style', 'template', 'noscript', 'svg', 'canvas']); -const TEXT_CONTAINER_TAGS = new Set(['address', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', 'code', 'li', 'th', 'td', 'figcaption', 'a']); -const BLOCK_CHILD_TAGS = new Set(['article', 'section', 'main', 'nav', 'aside', 'header', 'footer', 'div', 'address', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'blockquote', 'pre', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td', 'figure', 'figcaption']); - -export function parseHtmlSource(html: string, source: SourceIdentity, baseUrl?: string): ParseCandidate { - const document = parse(html); - const body = findElement(document, 'body') ?? document; - const main = findElement(body, 'main') ?? findElement(body, 'article'); - const checks: QualityCheck[] = []; - if (!main) checks.push({ code: 'main_content_ambiguous', severity: 'warning', message: 'No main/article landmark was found; the semantic body was retained.' }); - if (findElement(document, 'script')) checks.push({ code: 'scripts_ignored', severity: 'info', message: 'HTML scripts were retained only as a safety signal and were not executed.' }); - const nodes: DeckIrNode[] = []; - let order = 0; - const walk = (raw: HtmlNode, parentId: string | null, path: string): void => { - if (!isElement(raw)) return; - const tag = raw.tagName.toLowerCase(); - if (SKIP_TAGS.has(tag)) return; - const childPath = `${path}/${tag}[${order}]`; - let nextParent = parentId; - let capturesText = false; - if (CONTENT_TAGS.has(tag)) { - const id = stableId(source.sha256, `html:${childPath}`); - const href = attr(raw, 'href'); - const src = attr(raw, 'src'); - const text = directSemanticText(raw).trim(); - capturesText = Boolean(text) && !hasBlockContentChild(raw); - const nestedLinks = descendantsOf(raw, 'a').map((link) => ({ href: resolveUrl(attr(link, 'href') ?? '', baseUrl), text: textOf(link).trim() })).filter((link) => link.href); - const type = htmlType(tag); - const node: DeckIrNode = { id, type, parentId, children: [], order: order++, ...(text ? { text } : {}), - sourceRef: { path: childPath }, - ...(href ? { links: [{ href: resolveUrl(href, baseUrl), ...(text ? { text } : {}) }] } : nestedLinks.length ? { links: nestedLinks } : {}), - extensions: { tag, ...(attr(raw, 'id') ? { htmlId: attr(raw, 'id') } : {}), - ...(src ? { sourceUrl: resolveUrl(src, baseUrl), externalUrl: resolveUrl(src, baseUrl), alt: attr(raw, 'alt'), width: numberAttr(raw, 'width'), height: numberAttr(raw, 'height') } : {}) } }; - if (href || nestedLinks.length) node.runs = inlineRuns(raw, baseUrl); - if (/^h[1-6]$/.test(tag)) node.extensions = { ...node.extensions, level: Number(tag[1]) }; - nodes.push(node); - if (parentId) nodes.find((item) => item.id === parentId)?.children.push(id); - nextParent = id; - } - if (TEXT_CONTAINER_TAGS.has(tag) || capturesText) { - for (const image of descendantsOf(raw, 'img')) walk(image, nextParent, childPath); - return; - } - for (const child of childNodes(raw)) walk(child, nextParent, childPath); - }; - for (const child of childNodes((main ?? body) as HtmlNode)) walk(child, null, main ? '/main' : '/body'); - - const textCharacters = nodes.reduce((sum, node) => sum + (node.text?.length ?? 0), 0); - const remoteImages = nodes.filter((node) => node.type === 'image' && typeof node.extensions?.sourceUrl === 'string'); - if (remoteImages.length) checks.push({ code: 'remote_assets_not_localized', severity: 'warning', message: `${remoteImages.length} HTML image(s) remain remote references and were not downloaded.`, nodeIds: remoteImages.map((node) => node.id) }); - const hydration = /(?:__NEXT_DATA__|__NUXT__|data-reactroot|ng-version|id=["'](?:root|app)["'])/i.test(html); - if (textCharacters < 120 && hydration) checks.push({ code: 'runtime_required', severity: 'error', message: 'The initial HTML contains little readable content and appears to require JavaScript hydration.' }); - const quality = qualityOf(checks, { objects: { parsed: nodes.length, opaque: 0 }, textCharacters }); - const title = findElement(document, 'title'); - const ir = makeIr({ format: 'html', source, producer: { name: 'deckparse-html-source', version: '1' }, - metadata: { title: title ? textOf(title).trim() : undefined, baseUrl }, nodes, quality }); - return { ir, quality, assets: [], warnings: quality.checks.map((check) => check.message) }; -} - -export async function fetchHtml(url: string, limits: LocalLimits, signal?: AbortSignal): Promise<{ html: string; url: string; bytes: Uint8Array }> { - let current = new URL(url); - if (!['http:', 'https:'].includes(current.protocol)) throw new TypeError('Only http(s) URLs are supported.'); - for (let redirect = 0; redirect <= limits.redirects; redirect += 1) { - const timeout = AbortSignal.timeout(limits.timeoutMs); - const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; - const response = await fetch(current, { redirect: 'manual', signal: combined, headers: { accept: 'text/html,application/xhtml+xml' } }); - if (response.status >= 300 && response.status < 400) { - const location = response.headers.get('location'); - if (!location || redirect === limits.redirects) throw new Error('HTML redirect limit exceeded.'); - current = new URL(location, current); - if (!['http:', 'https:'].includes(current.protocol)) throw new Error('HTML redirected to a non-http(s) URL.'); - continue; - } - if (!response.ok) throw new Error(`HTML request failed with HTTP ${response.status}.`); - const type = response.headers.get('content-type')?.toLowerCase() ?? ''; - if (type && !type.includes('text/html') && !type.includes('application/xhtml+xml')) throw new Error(`URL returned unsupported content type ${type}.`); - const length = Number(response.headers.get('content-length') ?? 0); - if (length > limits.urlBytes) throw new Error('HTML response exceeds the local size limit.'); - const reader = response.body?.getReader(); - if (!reader) throw new Error('HTML response has no body.'); - const chunks: Uint8Array[] = []; let total = 0; - while (true) { - const next = await reader.read(); if (next.done) break; - total += next.value.byteLength; if (total > limits.urlBytes) { await reader.cancel(); throw new Error('HTML response exceeds the local size limit.'); } - chunks.push(next.value); - } - const bytes = new Uint8Array(total); let offset = 0; - for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } - return { html: new TextDecoder().decode(bytes), url: current.href, bytes }; - } - throw new Error('HTML redirect limit exceeded.'); -} - -function htmlType(tag: string): string { - if (/^h[1-6]$/.test(tag)) return 'heading'; - return ({ p: 'paragraph', a: 'link', img: 'image', ul: 'list', ol: 'list', li: 'list_item', - table: 'table', tr: 'table_row', th: 'table_cell', td: 'table_cell', pre: 'code_block', code: 'code', - blockquote: 'blockquote', figcaption: 'caption' } as Record)[tag] ?? tag; -} - -function directSemanticText(node: HtmlElement): string { - if (TEXT_CONTAINER_TAGS.has(node.tagName)) return textOf(node); - return hasBlockContentChild(node) ? '' : textOf(node); -} - -function hasBlockContentChild(node: HtmlElement): boolean { - return childNodes(node).some((child) => isElement(child) && BLOCK_CHILD_TAGS.has(child.tagName)); -} - -function textOf(node: HtmlNode): string { - if ('nodeName' in node && node.nodeName === '#text') return (node as DefaultTreeAdapterMap['textNode']).value; - if (isElement(node) && SKIP_TAGS.has(node.tagName)) return ''; - return childNodes(node).map(textOf).join(''); -} - -function inlineRuns(root: HtmlNode, base?: string): import('../../ir/schema.js').DeckIrRun[] { - const runs: import('../../ir/schema.js').DeckIrRun[] = []; - const visit = (node: HtmlNode, href?: string): void => { - if ('nodeName' in node && node.nodeName === '#text') { - const text = (node as DefaultTreeAdapterMap['textNode']).value; - if (text) runs.push({ text, ...(href ? { href } : {}) }); - return; - } - if (!isElement(node) || SKIP_TAGS.has(node.tagName)) return; - const ownHref = node.tagName === 'a' ? resolveUrl(attr(node, 'href') ?? '', base) || href : href; - for (const child of childNodes(node)) visit(child, ownHref); - }; - visit(root); - if (runs.length) { runs[0]!.text = runs[0]!.text.replace(/^\s+/, ''); runs.at(-1)!.text = runs.at(-1)!.text.replace(/\s+$/, ''); } - return runs.filter((run) => run.text); -} - -function findElement(root: HtmlNode, tag: string): HtmlElement | undefined { - if (isElement(root) && root.tagName === tag) return root; - for (const child of childNodes(root)) { const found = findElement(child, tag); if (found) return found; } - return undefined; -} - -function descendantsOf(root: HtmlNode, tag: string): HtmlElement[] { - const result: HtmlElement[] = []; - for (const child of childNodes(root)) { - if (isElement(child) && child.tagName === tag) result.push(child); - result.push(...descendantsOf(child, tag)); - } - return result; -} - -function isElement(node: HtmlNode): node is HtmlElement { - return typeof (node as Partial).tagName === 'string'; -} - -function childNodes(node: HtmlNode): HtmlNode[] { - return 'childNodes' in node ? [...(node.childNodes as HtmlNode[])] : []; -} - -function attr(node: HtmlElement, name: string): string | undefined { return node.attrs.find((item) => item.name === name)?.value; } -function numberAttr(node: HtmlElement, name: string): number | undefined { const value = Number(attr(node, name)); return Number.isFinite(value) && value > 0 ? value : undefined; } -function resolveUrl(value: string, base?: string): string { try { return base ? new URL(value, base).href : value; } catch { return value; } } diff --git a/src/local/limits.ts b/src/local/limits.ts deleted file mode 100644 index 0f022de..0000000 --- a/src/local/limits.ts +++ /dev/null @@ -1,42 +0,0 @@ -export interface LocalLimits { - sourceBytes: number; - zipEntries: number; - zipEntryBytes: number; - zipExpandedBytes: number; - zipCompressionRatio: number; - assetBytes: number; - assetTotalBytes: number; - xmlDepth: number; - xmlAttributes: number; - xmlTextBytes: number; - xmlEvents: number; - urlBytes: number; - redirects: number; - timeoutMs: number; - workerHeapMb: number; -} -export const DEFAULT_LOCAL_LIMITS: LocalLimits = Object.freeze({ - sourceBytes: 256 * 1024 * 1024, - zipEntries: 10_000, - zipEntryBytes: 128 * 1024 * 1024, - zipExpandedBytes: 512 * 1024 * 1024, - zipCompressionRatio: 1_000, - assetBytes: 32 * 1024 * 1024, - assetTotalBytes: 256 * 1024 * 1024, - xmlDepth: 256, - xmlAttributes: 256, - xmlTextBytes: 32 * 1024 * 1024, - xmlEvents: 5_000_000, - urlBytes: 32 * 1024 * 1024, - redirects: 5, - timeoutMs: 120_000, - workerHeapMb: 256, -}); - -export function resolveLimits(overrides: Partial = {}): LocalLimits { - const limits = { ...DEFAULT_LOCAL_LIMITS, ...overrides }; - for (const [key, value] of Object.entries(limits)) { - if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`Local limit ${key} must be a positive integer.`); - } - return limits; -} diff --git a/src/local/opc/package.ts b/src/local/opc/package.ts deleted file mode 100644 index 9f49b80..0000000 --- a/src/local/opc/package.ts +++ /dev/null @@ -1,186 +0,0 @@ -import path from 'node:path'; -import { inflateSync } from 'fflate'; -import { DeckOpsError } from '../../errors/index.js'; -import type { LocalLimits } from '../limits.js'; -import { parseXml, type XmlNode } from '../xml.js'; - -interface ZipEntry { - name: string; - flags: number; - method: number; - compressedSize: number; - expandedSize: number; - localOffset: number; -} - -export interface Relationship { - id: string; - type: string; - target: string; - external: boolean; -} - -/** Minimal central-directory reader: validates first, inflates requested parts only. */ -export class OpcPackage { - private readonly entries = new Map(); - private readonly canonicalEntries = new Set(); - private expandedRead = 0; - private readonly countedReads = new Set(); - private assetRead = 0; - private readonly countedAssets = new Set(); - - constructor(private readonly data: Uint8Array, private readonly limits: LocalLimits) { - if (data.byteLength > limits.sourceBytes) throw DeckOpsError.input('Source exceeds the local input size limit.'); - this.index(); - } - - has(name: string): boolean { - return this.entries.has(normalizePart(name)); - } - - names(): string[] { - return [...this.entries.keys()].sort(); - } - - read(name: string): Uint8Array { - const normalized = normalizePart(name); - const entry = this.entries.get(normalized); - if (!entry) throw DeckOpsError.input(`OOXML package is missing ${normalized}.`); - if (!this.countedReads.has(normalized)) { this.countedReads.add(normalized); this.expandedRead += entry.expandedSize; } - if (this.expandedRead > this.limits.zipExpandedBytes) throw DeckOpsError.input('OOXML expanded data exceeds the local limit.'); - const view = new DataView(this.data.buffer, this.data.byteOffset, this.data.byteLength); - if (entry.localOffset < 0 || entry.localOffset + 30 > this.data.byteLength) throw DeckOpsError.input(`ZIP entry ${normalized} has a truncated local header.`); - if (view.getUint32(entry.localOffset, true) !== 0x04034b50) throw DeckOpsError.input(`ZIP entry ${normalized} has an invalid local header.`); - if (view.getUint16(entry.localOffset + 8, true) !== entry.method || (view.getUint16(entry.localOffset + 6, true) & 1) !== 0) throw DeckOpsError.input(`ZIP entry ${normalized} has conflicting local metadata.`); - const nameLength = view.getUint16(entry.localOffset + 26, true); - const extraLength = view.getUint16(entry.localOffset + 28, true); - const localNameEnd = entry.localOffset + 30 + nameLength; - if (localNameEnd > this.data.byteLength || normalizePart(new TextDecoder().decode(this.data.subarray(entry.localOffset + 30, localNameEnd))) !== normalized) throw DeckOpsError.input(`ZIP entry ${normalized} has a conflicting local path.`); - const start = entry.localOffset + 30 + nameLength + extraLength; - const end = start + entry.compressedSize; - if (end > this.data.byteLength) throw DeckOpsError.input(`ZIP entry ${normalized} is truncated.`); - const compressed = this.data.subarray(start, end); - let output: Uint8Array; - if (entry.method === 0) output = compressed.slice(); - else if (entry.method === 8) output = inflateSync(compressed); - else throw DeckOpsError.unsupported(`ZIP compression method ${entry.method} is not supported.`); - if (output.byteLength !== entry.expandedSize) throw DeckOpsError.input(`ZIP entry ${normalized} has an invalid expanded size.`); - return output; - } - - readAsset(name: string): Uint8Array { - const normalized = normalizePart(name); - const entry = this.entries.get(normalized); - if (!entry) throw DeckOpsError.input(`OOXML package is missing ${normalized}.`); - if (entry.expandedSize > this.limits.assetBytes) throw DeckOpsError.input(`OOXML asset ${normalized} exceeds the local asset-size limit.`); - if (!this.countedAssets.has(normalized)) { - this.countedAssets.add(normalized); - this.assetRead += entry.expandedSize; - } - if (this.assetRead > this.limits.assetTotalBytes) throw DeckOpsError.input('OOXML assets exceed the local cumulative asset-size limit.'); - return this.read(normalized); - } - - xml(name: string): XmlNode { - return parseXml(this.read(name), this.limits, normalizePart(name)); - } - - relationships(ownerPart: string): Map { - const relsPart = relationshipsPart(ownerPart); - if (!this.has(relsPart)) return new Map(); - const root = this.xml(relsPart); - const result = new Map(); - for (const rel of root.children.flatMap((node) => node.local === 'Relationships' ? node.children : [])) { - if (rel.local !== 'Relationship') continue; - const id = rel.attributes.Id; - const target = rel.attributes.Target; - const type = rel.attributes.Type; - if (!id || !target || !type) continue; - const external = rel.attributes.TargetMode === 'External'; - result.set(id, { - id, - type, - target: external ? target : resolvePart(ownerPart, target), - external, - }); - } - return result; - } - - private index(): void { - if (this.data.byteLength < 22) throw DeckOpsError.input('File is not a valid ZIP package.'); - const view = new DataView(this.data.buffer, this.data.byteOffset, this.data.byteLength); - let eocd = -1; - const min = Math.max(0, this.data.byteLength - 65_557); - for (let at = this.data.byteLength - 22; at >= min; at -= 1) { - if (view.getUint32(at, true) === 0x06054b50) { eocd = at; break; } - } - if (eocd < 0) throw DeckOpsError.input('ZIP end-of-central-directory record was not found.'); - const count = view.getUint16(eocd + 10, true); - const centralSize = view.getUint32(eocd + 12, true); - const centralOffset = view.getUint32(eocd + 16, true); - if (count === 0xffff || centralSize === 0xffffffff || centralOffset === 0xffffffff) { - throw DeckOpsError.unsupported('ZIP64 OOXML packages are not supported locally.'); - } - if (count > this.limits.zipEntries) throw DeckOpsError.input('ZIP package contains too many entries.'); - if (centralOffset + centralSize > this.data.byteLength) throw DeckOpsError.input('ZIP central directory is truncated.'); - let at = centralOffset; - let declaredExpanded = 0; - for (let i = 0; i < count; i += 1) { - if (at + 46 > centralOffset + centralSize) throw DeckOpsError.input('ZIP central directory is truncated.'); - if (view.getUint32(at, true) !== 0x02014b50) throw DeckOpsError.input('ZIP central directory is malformed.'); - const flags = view.getUint16(at + 8, true); - const method = view.getUint16(at + 10, true); - const compressedSize = view.getUint32(at + 20, true); - const expandedSize = view.getUint32(at + 24, true); - const nameLength = view.getUint16(at + 28, true); - const extraLength = view.getUint16(at + 30, true); - const commentLength = view.getUint16(at + 32, true); - const localOffset = view.getUint32(at + 42, true); - if ((flags & 1) !== 0) throw DeckOpsError.unsupported('Encrypted ZIP entries are not supported locally.'); - if (expandedSize > this.limits.zipEntryBytes) throw DeckOpsError.input('A ZIP entry exceeds the local expanded-size limit.'); - if (compressedSize === 0 && expandedSize > 0 || compressedSize > 0 && expandedSize / compressedSize > this.limits.zipCompressionRatio) { - throw DeckOpsError.input('ZIP entry exceeds the allowed compression ratio.'); - } - declaredExpanded += expandedSize; - if (declaredExpanded > this.limits.zipExpandedBytes) throw DeckOpsError.input('ZIP package exceeds the local expanded-size limit.'); - const nameStart = at + 46; - const nameEnd = nameStart + nameLength; - if (nameEnd > this.data.byteLength) throw DeckOpsError.input('ZIP entry name is truncated.'); - const name = normalizePart(new TextDecoder().decode(this.data.subarray(nameStart, nameEnd))); - if (!name.endsWith('/')) { - const canonical = path.posix.normalize(decodeURIComponent(name).replace(/\\/g, '/')); - if (this.entries.has(name) || this.canonicalEntries.has(canonical)) throw DeckOpsError.input(`ZIP package contains duplicate path ${name}.`); - this.canonicalEntries.add(canonical); - this.entries.set(name, { name, flags, method, compressedSize, expandedSize, localOffset }); - } - at = nameEnd + extraLength + commentLength; - } - } -} - -export function normalizePart(value: string): string { - const unix = value.replace(/\\/g, '/'); - let decoded: string; - try { decoded = decodeURIComponent(unix).replace(/\\/g, '/'); } - catch (cause) { throw DeckOpsError.input(`OOXML package contains an invalid encoded path: ${value}`, { cause }); } - if (unix.startsWith('/') || /^[A-Za-z]:\//.test(unix)) throw DeckOpsError.input(`Unsafe ZIP path: ${value}`); - const normalized = path.posix.normalize(unix); - const decodedNormalized = path.posix.normalize(decoded); - if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized) || - !decodedNormalized || decodedNormalized === '.' || decodedNormalized === '..' || decodedNormalized.startsWith('../') || path.posix.isAbsolute(decodedNormalized) || /^[A-Za-z]:\//.test(decodedNormalized) || /[\0-\x1f]/.test(decoded)) { - throw DeckOpsError.input(`Unsafe ZIP path: ${value}`); - } - return normalized; -} - -export function resolvePart(ownerPart: string, target: string): string { - const base = path.posix.dirname(normalizePart(ownerPart)); - const pathTarget = target.split('#', 1)[0]!.split('?', 1)[0]!; - return pathTarget.startsWith('/') ? normalizePart(pathTarget.replace(/^\/+/, '')) : normalizePart(path.posix.join(base, pathTarget)); -} - -function relationshipsPart(ownerPart: string): string { - const normalized = normalizePart(ownerPart); - return path.posix.join(path.posix.dirname(normalized), '_rels', `${path.posix.basename(normalized)}.rels`); -} diff --git a/src/local/pdf/adapter.ts b/src/local/pdf/adapter.ts deleted file mode 100644 index 36ab1d6..0000000 --- a/src/local/pdf/adapter.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { ParseArtifacts, ParseOptions } from '@deckflow/pdf-lite-parse'; -import { DeckOpsError } from '../../errors/index.js'; -import { result3ToDeckIr } from '../../ir/result3-adapter.js'; -import type { ParseCandidate } from '../../ir/schema.js'; -import { mediaTypeForPath, type SourceIdentity } from '../common.js'; -import type { LocalLimits } from '../limits.js'; - -export interface LocalPdfOptions { - password?: string; - pageFurniture?: 'off' | 'drop' | 'extract'; - overlaidText?: 'auto' | 'keep' | 'drop'; - includeImages?: boolean; -} - -export async function parsePdf(input: string | Uint8Array, source: SourceIdentity, limits: LocalLimits, options: LocalPdfOptions = {}): Promise { - // Deliberately lazy: formats/help and OOXML paths do not load PDF.js. - const { parseArtifacts } = await import('@deckflow/pdf-lite-parse'); - const upstreamOptions: ParseOptions = { - images: options.includeImages === false ? 'none' : 'embedded', - ...(options.password !== undefined ? { password: options.password } : {}), - ...(options.pageFurniture !== undefined ? { pageFurniture: options.pageFurniture } : {}), - ...(options.overlaidText !== undefined ? { overlaidText: options.overlaidText } : {}), - }; - const artifacts: ParseArtifacts = await parseArtifacts(input, upstreamOptions); - const assets = [...artifacts.assets].map(([assetPath, data]) => ({ assetPath, data })); - let assetBytes = 0; - for (const asset of assets) { - if (asset.data.byteLength > limits.assetBytes) throw DeckOpsError.input(`PDF asset ${asset.assetPath} exceeds the local asset-size limit.`); - assetBytes += asset.data.byteLength; - if (assetBytes > limits.assetTotalBytes) throw DeckOpsError.input('PDF assets exceed the local cumulative asset-size limit.'); - } - const candidate = result3ToDeckIr({ - document: artifacts.document, - source, - producer: { engine: 'local', name: 'pdf-lite-parse', version: artifacts.metadata.parserVersion }, - assets: assets.map(({ assetPath, data }) => ({ path: assetPath, data, ...(mediaTypeForPath(assetPath) ? { mediaType: mediaTypeForPath(assetPath) } : {}) })), - }); - if (options.includeImages === false) { - candidate.assets = []; - candidate.ir.document.assets = []; - for (const node of candidate.ir.document.nodes) { - if (node.extensions && 'assetPath' in node.extensions) delete node.extensions.assetPath; - } - } - return candidate; -} diff --git a/src/local/pptx/chart.ts b/src/local/pptx/chart.ts deleted file mode 100644 index 1049687..0000000 --- a/src/local/pptx/chart.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { ChartData, ChartSeries } from '../../ir/chart-data.js'; -import { children, first, textContent, type XmlNode } from '../xml.js'; - -/** 图表类型元素(`barChart`、`pie3DChart`……)→ 类型名(`bar`、`pie`)。 */ -const CHART_ELEMENT = /^(.+?)(?:3D)?Chart$/; - -/** - * 读图表部件(`ppt/charts/chartN.xml`)里缓存的标题、类别与各系列数值;读不出任何系列返回 - * undefined。与 presentation 的 `readCharts` 同一套规则,两个引擎得出同一张表。 - */ -export function readChartPart(root: XmlNode): ChartData | undefined { - const chart = first(first(root, 'chartSpace') ?? root, 'chart'); - if (!chart) return undefined; - const typeElements = (first(chart, 'plotArea')?.children ?? []).filter((element) => CHART_ELEMENT.test(element.local)); - if (!typeElements.length) return undefined; - const kind = typeElements[0]!.local.match(CHART_ELEMENT)?.[1] ?? 'chart'; - let categories: string[] = []; - const series: ChartSeries[] = []; - for (const typeElement of typeElements) { - for (const ser of children(typeElement, 'ser')) { - // 系列名多半引用单元格(文字在缓存里),少数直接写在 `c:v`。 - const tx = first(ser, 'tx'); - const name = cachedStrings(tx)[0] ?? text(tx ? first(tx, 'v') : undefined); - const values = cachedNumbers(first(ser, 'val') ?? first(ser, 'yVal')); - if (!values.values.length) continue; - if (!categories.length) categories = cachedStrings(first(ser, 'cat') ?? first(ser, 'xVal')); - series.push({ ...(name ? { name } : {}), values: values.values, ...(values.formatCode ? { formatCode: values.formatCode } : {}) }); - } - } - if (!series.length) return undefined; - const titleElement = first(chart, 'title'); - const title = titleElement && first(chart, 'autoTitleDeleted')?.attributes.val !== '1' - ? text(findDeep(titleElement, ['rich'])) || cachedStrings(titleElement)[0] : undefined; - return { ...(title ? { title } : {}), kind, categories, series }; -} - -/** `strCache` / `numCache` / `multiLvlStrCache` 里按 idx 排好的点;多级类别取最内层。 */ -function cachedStrings(owner: XmlNode | undefined): string[] { - const cache = findDeep(owner, ['strCache', 'numCache', 'multiLvlStrCache']); - if (!cache) return []; - return points(cache.local === 'multiLvlStrCache' ? first(cache, 'lvl') : cache).map((point) => point ?? ''); -} - -function cachedNumbers(owner: XmlNode | undefined): { values: Array; formatCode?: string } { - const cache = findDeep(owner, ['numCache']); - if (!cache) return { values: [] }; - const formatCode = text(first(cache, 'formatCode')); - const values = points(cache).map((point) => { - if (point === undefined || point.trim() === '') return null; - const value = Number(point); - return Number.isFinite(value) ? value : null; - }); - return { values, ...(formatCode ? { formatCode } : {}) }; -} - -/** 缓存里的点按 `idx` 落位,`ptCount` 给出总数;缺的点留空。 */ -function points(cache: XmlNode | undefined): Array { - if (!cache) return []; - const count = Number(first(cache, 'ptCount')?.attributes.val ?? 0); - const result: Array = []; - for (const pt of children(cache, 'pt')) { - const index = Number(pt.attributes.idx ?? result.length); - if (Number.isInteger(index) && index >= 0) result[index] = text(first(pt, 'v')); - } - if (Number.isInteger(count) && count > result.length) result.length = count; - return Array.from(result, (point) => point); -} - -function findDeep(node: XmlNode | undefined, names: readonly string[]): XmlNode | undefined { - if (!node) return undefined; - for (const child of node.children) { - if (names.includes(child.local)) return child; - const nested = findDeep(child, names); - if (nested) return nested; - } - return undefined; -} - -function text(node: XmlNode | undefined): string { - return node ? textContent(node).trim() : ''; -} diff --git a/src/local/pptx/parser.ts b/src/local/pptx/parser.ts deleted file mode 100644 index 00d9b9f..0000000 --- a/src/local/pptx/parser.ts +++ /dev/null @@ -1,594 +0,0 @@ -import { DeckOpsError } from '../../errors/index.js'; -import { stableId } from '../../ir/ids.js'; -import type { CandidateAsset, DeckIrNode, DeckIrRun, ParseCandidate, QualityCheck } from '../../ir/schema.js'; -import { imageAltText } from '../../ir/alt-text.js'; -import { chartTableNodes } from '../../ir/chart-data.js'; -import { - bulletDeclaration, levelStyleKey, paragraphStructure, resolveList, type BulletDeclaration, type TextParagraph, -} from '../../ir/pptx-lists.js'; -import { inheritedPlaceholders, masterTextStyleKey, type PlaceholderEntry, type PlaceholderKey } from '../../ir/pptx-placeholders.js'; -import { orderSlideNodes } from '../../ir/slide-order.js'; -import { makeIr, packageImageAsset, qualityOf, type SourceIdentity } from '../common.js'; -import type { LocalLimits } from '../limits.js'; -import { OpcPackage, type Relationship } from '../opc/package.js'; -import { children, descendants, first, textContent, type XmlNode } from '../xml.js'; -import { readChartPart } from './chart.js'; - -interface Context { - pkg: OpcPackage; - source: SourceIdentity; - nodes: DeckIrNode[]; - assets: CandidateAsset[]; - checks: QualityCheck[]; - order: number; - page: number; - part: string; - rels: Map; - /** 当前形状树坐标 → 幻灯片坐标(pt)。组合的子形状写在组合自己的子画布里,逐层复合。 */ - transform: Transform; - /** 这一页的版式与母版:占位符没写的位置、项目符号从这里继承。 */ - inheritance: SlideInheritance; - /** 这一页已按预览图或「看不见」处理过的嵌入对象关系;审计时不再报成不支持。 */ - handledObjects: Set; - /** 整份文档的嵌入对象去向,最后各汇总成一条 info。 */ - embedded: EmbeddedTally; - /** 这一页旧式嵌入对象的预览图(VML 形状 id → 图片部件),用到时才读。 */ - vmlImages?: Map; -} - -interface EmbeddedTally { - preview: { nodeIds: string[]; pages: Set }; - hidden: { nodeIds: string[]; pages: Set }; -} - -/** - * 小于这个尺寸(pt)的嵌入对象在幻灯片上看不见。实测一份咨询讲义的 12 个嵌入对象全是 - * think-cell 插件放在页面上的元数据,边长 0.125pt,原先每个都报一条「不支持的嵌入对象」。 - */ -const HIDDEN_OBJECT_MAX_PT = 1; - -/** 版式或母版上的一个占位符:幻灯片占位符可能继承的位置与列表样式。 */ -interface InheritedPlaceholder { xfrm: XmlNode | undefined; lstStyle: XmlNode | undefined } - -/** 版式、母版各自提供的继承来源;按部件缓存,几十页共用一个版式不必重复解析。 */ -interface InheritanceSource { - placeholders: Array>; - textStyles: Partial>; -} - -/** 一页幻灯片的继承链:版式占位符 → 母版占位符 → 母版文字样式 → 演示文稿默认样式。 */ -interface SlideInheritance { - layout: InheritanceSource | undefined; - master: InheritanceSource | undefined; - defaultTextStyle: XmlNode | undefined; -} - -interface Transform { scaleX: number; scaleY: number; translateX: number; translateY: number } - -const IDENTITY: Transform = { scaleX: 1, scaleY: 1, translateX: 0, translateY: 0 }; - -/** - * 版式家具占位符 → 节点类型。页脚、日期、页码每页一份,是母版带下来的版式,不是这一页的内容: - * 实测一份 99 页讲义,页脚「2: Application Layer」与页码在 Markdown 里各占 92 行。文字仍留在 - * IR 里,只是 Markdown 视图不渲染这几类节点。 - */ -const FURNITURE_PLACEHOLDERS: Readonly> = { ftr: 'footer', dt: 'footer', hdr: 'header', sldNum: 'page_number' }; - -export function parsePptx(data: Uint8Array, source: SourceIdentity, limits: LocalLimits): ParseCandidate { - const pkg = new OpcPackage(data, limits); - if (!pkg.has('[Content_Types].xml') || !pkg.has('ppt/presentation.xml')) { - throw DeckOpsError.input('PPTX package is missing required OOXML parts.'); - } - const presentation = pkg.xml('ppt/presentation.xml'); - const presentationRels = pkg.relationships('ppt/presentation.xml'); - const sldSz = descendants(presentation, 'sldSz')[0]; - const width = emuToPt(Number(sldSz?.attributes.cx ?? 0)); - const height = emuToPt(Number(sldSz?.attributes.cy ?? 0)); - const slideIds = descendants(presentation, 'sldId'); - const nodes: DeckIrNode[] = []; - const assets: CandidateAsset[] = []; - const checks: QualityCheck[] = []; - const pages = []; - let order = 0; - const inheritanceSources = new Map(); - const defaultTextStyle = descendants(presentation, 'defaultTextStyle')[0]; - const embedded: EmbeddedTally = { preview: { nodeIds: [], pages: new Set() }, hidden: { nodeIds: [], pages: new Set() } }; - - for (const [index, slideId] of slideIds.entries()) { - // p:sldId carries both numeric id and namespaced r:id; relationship id wins. - const relId = slideId.attributes['r:id'] ?? slideId.attributes.id; - const rel = relId ? presentationRels.get(relId) : undefined; - if (!rel || rel.external || !pkg.has(rel.target)) { - checks.push({ code: 'missing_slide', severity: 'error', message: `Slide ${index + 1} is missing from the package.`, pages: [index + 1] }); - continue; - } - const page = index + 1; - const before = nodes.length; - const rels = pkg.relationships(rel.target); - const ctx: Context = { pkg, source, nodes, assets, checks, order, page, part: rel.target, rels, transform: IDENTITY, - inheritance: slideInheritance(pkg, rels, inheritanceSources, defaultTextStyle), handledObjects: new Set(), embedded }; - const slide = pkg.xml(rel.target); - const tree = descendants(slide, 'spTree')[0]; - if (tree) parseShapeTree(tree, ctx, null); - parseNotes(ctx); - auditUnsupportedRelationships(ctx); - order = ctx.order; - pages.push({ - id: stableId(source.sha256, `pptx:slide:${rel.target}`, 'p'), index: page, - ...(width > 0 ? { width } : {}), ...(height > 0 ? { height } : {}), - nodeIds: nodes.slice(before).filter((node) => node.page === page).map((node) => node.id), sourceRef: { part: rel.target, page }, - }); - } - if (embedded.preview.nodeIds.length) { - checks.push({ code: 'embedded_object_preview', severity: 'info', pages: [...embedded.preview.pages], nodeIds: embedded.preview.nodeIds, - message: `${embedded.preview.nodeIds.length} embedded objects are shown as their preview images; their own content (for example a worksheet) is not expanded.` }); - } - if (embedded.hidden.nodeIds.length) { - checks.push({ code: 'embedded_object_hidden', severity: 'info', pages: [...embedded.hidden.pages], nodeIds: embedded.hidden.nodeIds, - message: `${embedded.hidden.nodeIds.length} embedded objects draw nothing visible on the slide (zero-size shells or add-in metadata) and are left out of the Markdown.` }); - } - for (const part of pkg.names().filter((name) => /(?:^|\/)vbaProject\.bin$/i.test(name))) { - const id = stableId(source.sha256, `${part}:opaque:vba_project`); - nodes.push({ id, type: 'opaque', parentId: null, children: [], order: order++, sourceRef: { part }, opaque: { type: 'vba_project', reason: 'VBA is never executed by the local parser.' } }); - checks.push({ code: 'macro_preserved', severity: 'warning', message: 'PPTX contains a VBA project. It was not executed and is preserved as an opaque part.', nodeIds: [id] }); - } - orderSlideNodes(nodes, pages); - const opaque = nodes.filter((node) => node.opaque).length; - const quality = qualityOf(checks, { - pages: { parsed: pages.length, total: slideIds.length }, - objects: { parsed: nodes.length - opaque, opaque, total: nodes.length }, - textCharacters: nodes.reduce((sum, node) => sum + (node.text?.length ?? 0), 0), - }); - const ir = makeIr({ format: 'pptx', source, producer: { name: 'deckparse-pptx', version: '5' }, - metadata: { ...readCoreProperties(pkg), slideSize: { width, height }, theme: readTheme(pkg, presentationRels) }, - pages, nodes, assets, quality }); - return { ir, quality, assets, warnings: quality.checks.map((check) => check.message) }; -} - -function parseShapeTree(tree: XmlNode, ctx: Context, parentId: string | null): void { - for (const child of tree.children) { - if (child.local === 'sp' || child.local === 'cxnSp') parseShape(child, ctx, parentId); - else if (child.local === 'pic') parsePicture(child, ctx, parentId); - else if (child.local === 'graphicFrame') parseGraphicFrame(child, ctx, parentId); - else if (child.local === 'grpSp') parseGroup(child, ctx, parentId); - } -} - -function parseShape(shape: XmlNode, ctx: Context, parentId: string | null): void { - const native = descendants(shape, 'cNvPr')[0]; - const nativeId = native?.attributes.id ?? String(ctx.order); - const id = stableId(ctx.source.sha256, `${ctx.part}:shape:${nativeId}`); - const { runs, spans } = shapeParagraphs(shape, ctx.rels); - const text = runs.map((run) => run.text).join(''); - const placeholder = descendants(shape, 'ph')[0]; - const placeholderType = placeholder?.attributes.type; - const placeholderKey = placeholder ? keyOf(placeholder) : undefined; - const inherited = placeholderKey ? inheritedPlaceholders(placeholderKey, ctx.inheritance.layout?.placeholders ?? [], ctx.inheritance.master?.placeholders ?? []) : undefined; - const xfrm = descendants(shape, 'xfrm')[0]; - // 占位符常常不写自己的位置,照版式或母版上的那一个摆。原先这样的正文没有框,排在整页最后: - // 实测讲义 21 页的正文跑到了图示标签后面。继承来的框是幻灯片坐标,不经组合换算。 - const ownBbox = bboxOf(xfrm, ctx.transform); - const layoutBbox = ownBbox ? undefined : bboxOf(inherited?.layout?.xfrm, IDENTITY); - const masterBbox = ownBbox || layoutBbox ? undefined : bboxOf(inherited?.master?.xfrm, IDENTITY); - const bbox = ownBbox ?? layoutBbox ?? masterBbox; - const bboxInheritedFrom = layoutBbox ? 'layout' : masterBbox ? 'master' : undefined; - // 空的标题占位符不是标题:它没有字,当成 heading 只会在 Markdown 里留下一行空的 `##`。 - const type = text && (placeholderType === 'title' || placeholderType === 'ctrTitle') ? 'heading' - : (placeholderType && FURNITURE_PLACEHOLDERS[placeholderType]) || (text ? 'text' : 'shape'); - const paragraphs = type === 'text' - ? paragraphStructure(spans.map(({ start, end, pPr }): TextParagraph => { - const level = Math.min(Math.max(Number(pPr?.attributes.lvl ?? 0) || 0, 0), 8); - const list = resolveList(bulletChain(pPr, level, shape, placeholderKey, inherited, ctx.inheritance)); - return { start, end, level, ...(list ? { list } : {}) }; - })) - : undefined; - const deckNode: DeckIrNode = { - id, type, - parentId, children: [], order: ctx.order++, text, runs, page: ctx.page, zIndex: ctx.order, - sourceRef: { part: ctx.part, page: ctx.page, path: `shape/${nativeId}` }, - ...(bbox ? { bbox } : {}), - extensions: { nativeId, name: native?.attributes.name, placeholder: placeholder ? { ...placeholder.attributes } : undefined, - rawTransform: rawTransform(xfrm), geometry: descendants(shape, 'prstGeom')[0]?.attributes.prst, - ...(bboxInheritedFrom ? { bboxInheritedFrom } : {}), ...(paragraphs ? { paragraphs } : {}) }, - }; - ctx.nodes.push(deckNode); attach(ctx, parentId, id); -} - -/** - * 一个段落的项目符号,按 OOXML 的继承链逐层问:段落自己的 pPr → 形状的 lstStyle → - * 版式占位符 → 母版占位符 → 母版文字样式 → 演示文稿默认样式。实测讲义的要点全都写在 - * 母版 bodyStyle 里,段落上只有 `lvl`;引导行则在段落上写 `` 把它关掉。 - */ -function* bulletChain(pPr: XmlNode | undefined, level: number, shape: XmlNode, placeholder: PlaceholderKey | undefined, - inherited: { layout: InheritedPlaceholder | undefined; master: InheritedPlaceholder | undefined } | undefined, slide: SlideInheritance): Generator { - const key = levelStyleKey(level); - yield declaredBullet(pPr); - yield declaredBullet(levelOf(first(first(shape, 'txBody') ?? emptyNode(), 'lstStyle'), key)); - yield declaredBullet(levelOf(inherited?.layout?.lstStyle, key)); - yield declaredBullet(levelOf(inherited?.master?.lstStyle, key)); - yield declaredBullet(levelOf(slide.master?.textStyles[masterTextStyleKey(placeholder)], key)); - yield declaredBullet(levelOf(slide.defaultTextStyle, key)); -} - -function declaredBullet(pPr: XmlNode | undefined): BulletDeclaration { - if (!pPr) return undefined; - return bulletDeclaration({ - none: Boolean(first(pPr, 'buNone')), - autoNumber: Boolean(first(pPr, 'buAutoNum')?.attributes.type), - character: Boolean(first(pPr, 'buChar')?.attributes.char), - picture: Boolean(first(pPr, 'buBlip')), - }); -} - -function levelOf(list: XmlNode | undefined, key: string): XmlNode | undefined { - return list ? first(list, key) : undefined; -} - -function keyOf(placeholder: XmlNode): PlaceholderKey { - return { type: placeholder.attributes.type, idx: placeholder.attributes.idx }; -} - -function slideInheritance(pkg: OpcPackage, slideRels: Map, cache: Map, defaultTextStyle: XmlNode | undefined): SlideInheritance { - const related = (rels: Map, suffix: string): string | undefined => { - const rel = [...rels.values()].find((item) => item.type.endsWith(suffix) && !item.external && pkg.has(item.target)); - return rel?.target; - }; - const source = (part: string | undefined): InheritanceSource | undefined => { - if (!part) return undefined; - let cached = cache.get(part); - if (!cached) { cached = readInheritanceSource(pkg.xml(part)); cache.set(part, cached); } - return cached; - }; - const layoutPart = related(slideRels, '/slideLayout'); - const masterPart = layoutPart ? related(pkg.relationships(layoutPart), '/slideMaster') : undefined; - return { layout: source(layoutPart), master: source(masterPart), defaultTextStyle }; -} - -function readInheritanceSource(root: XmlNode): InheritanceSource { - const tree = descendants(root, 'spTree')[0]; - const placeholders = (tree ? descendants(tree, 'sp') : []).flatMap((sp): Array> => { - const placeholder = descendants(sp, 'ph')[0]; - return placeholder ? [{ key: keyOf(placeholder), value: { - xfrm: first(first(sp, 'spPr') ?? emptyNode(), 'xfrm'), - lstStyle: first(first(sp, 'txBody') ?? emptyNode(), 'lstStyle'), - } }] : []; - }); - const txStyles = descendants(root, 'txStyles')[0]; - const textStyles: InheritanceSource['textStyles'] = {}; - for (const name of ['titleStyle', 'bodyStyle', 'otherStyle'] as const) { - const style = txStyles ? first(txStyles, name) : undefined; - if (style) textStyles[name] = style; - } - return { placeholders, textStyles }; -} - -/** - * 形状里有文字的段落:文本运行(段落之间插一个 `\n`,跳过空段落)与每段在其中的范围。 - * 幻灯片形状与备注页形状同一种切法,与云端 `text` 的拼法一致。 - */ -function shapeParagraphs(shape: XmlNode, rels: Map): { runs: DeckIrRun[]; spans: Array<{ start: number; end: number; pPr: XmlNode | undefined }> } { - const runs: DeckIrRun[] = []; - const spans: Array<{ start: number; end: number; pPr: XmlNode | undefined }> = []; - let length = 0; - for (const paragraph of descendants(shape, 'p')) { - const paragraphRuns = textRuns(paragraph, rels); - if (!paragraphRuns.length) continue; - if (runs.length) { runs.push({ text: '\n' }); length += 1; } - const start = length; - for (const run of paragraphRuns) { runs.push(run); length += run.text.length; } - spans.push({ start, end: length, pPr: first(paragraph, 'pPr') }); - } - return { runs, spans }; -} - -function parsePicture(pic: XmlNode, ctx: Context, parentId: string | null): void { - const native = descendants(pic, 'cNvPr')[0]; - const nativeId = native?.attributes.id ?? String(ctx.order); - const id = stableId(ctx.source.sha256, `${ctx.part}:picture:${nativeId}`); - const blip = descendants(pic, 'blip')[0]; - const relId = blip?.attributes.embed ?? blip?.attributes['r:embed'] ?? blip?.attributes.link ?? blip?.attributes['r:link']; - const rel = relId ? ctx.rels.get(relId) : undefined; - // 替代文字只收作者写的描述;网址、文件路径、剪贴画缓存名这类原文留在 descr 里。 - const descr = native?.attributes.descr; - const alt = imageAltText(descr); - const node: DeckIrNode = { id, type: 'image', parentId, children: [], order: ctx.order++, text: alt ?? '', - page: ctx.page, zIndex: ctx.order, sourceRef: { part: ctx.part, page: ctx.page, ...(relId ? { relationship: relId } : {}), path: `picture/${nativeId}` }, - ...(bboxOf(descendants(pic, 'xfrm')[0], ctx.transform) ? { bbox: bboxOf(descendants(pic, 'xfrm')[0], ctx.transform) } : {}), - extensions: { nativeId, name: native?.attributes.name, ...(alt ? { alt } : {}), ...(descr ? { descr } : {}) } }; - if (rel?.external) { - node.extensions = { ...node.extensions, externalUrl: rel.target }; node.issues = ['external_asset']; - ctx.checks.push({ code: 'external_asset', severity: 'warning', message: `Slide ${ctx.page} contains an externally linked image that was not downloaded.`, pages: [ctx.page], nodeIds: [id] }); - } - else if (rel && ctx.pkg.has(rel.target)) { - const bytes = ctx.pkg.readAsset(rel.target); - const asset = packageImageAsset(rel.target, bytes); - ctx.assets.push({ ...asset, data: bytes, sourceRef: { part: ctx.part, relationship: rel.id } }); - node.extensions = { ...node.extensions, assetPath: asset.path }; - } else { - node.issues = ['missing_media']; - ctx.checks.push({ code: 'missing_media', severity: 'error', message: `Slide ${ctx.page} contains a picture with missing media.`, pages: [ctx.page], nodeIds: [id] }); - } - ctx.nodes.push(node); attach(ctx, parentId, id); -} - -function parseGraphicFrame(frame: XmlNode, ctx: Context, parentId: string | null): void { - const native = descendants(frame, 'cNvPr')[0]; - const nativeId = native?.attributes.id ?? String(ctx.order); - const table = descendants(frame, 'tbl')[0]; - if (table) { parseTable(table, frame, ctx, parentId, nativeId); return; } - const graphicData = descendants(frame, 'graphicData')[0]; - const uri = graphicData?.attributes.uri ?? ''; - if (/\/ole$/.test(uri) && parseEmbeddedObject(frame, ctx, parentId, nativeId)) return; - const relElement = descendants(frame, 'chart')[0] ?? descendants(frame, 'relIds')[0]; - const relId = relElement?.attributes.id ?? relElement?.attributes['r:id'] ?? relElement?.attributes.dm ?? relElement?.attributes['r:dm']; - const rel = relId ? ctx.rels.get(relId) : undefined; - const kind = /chart/i.test(uri) ? 'chart' : /diagram/i.test(uri) ? 'smartart' : 'graphic_frame'; - const id = stableId(ctx.source.sha256, `${ctx.part}:${kind}:${nativeId}`); - if (kind === 'chart' && rel && !rel.external && ctx.pkg.has(rel.target)) { - const chart = readChartPart(ctx.pkg.xml(rel.target)); - if (chart) { - // 图表的类别与数值缓存在图表部件里,展开成一张表挂在图表节点下;标题作图表节点的文字。 - const bbox = bboxOf(first(frame, 'xfrm'), ctx.transform); - const node: DeckIrNode = { id, type: 'chart', parentId, children: [], order: ctx.order++, text: chart.title ?? '', page: ctx.page, zIndex: ctx.order, - sourceRef: { part: ctx.part, page: ctx.page, relationship: rel.id, path: `chart/${nativeId}` }, ...(bbox ? { bbox } : {}), - extensions: { nativeId, name: native?.attributes.name, chart: { kind: chart.kind, ...(chart.title ? { title: chart.title } : {}), part: rel.target } } }; - ctx.nodes.push(node); attach(ctx, parentId, id); - ctx.nodes.push(...chartTableNodes(node, chart, (suffix) => stableId(ctx.source.sha256, `${id}:${suffix}`), () => ctx.order++)); - return; - } - } - const visibleText = descendants(frame, 't').map(textContent).join(' '); - ctx.nodes.push({ id, type: kind, parentId, children: [], order: ctx.order++, text: visibleText, page: ctx.page, - sourceRef: { part: ctx.part, page: ctx.page, ...(relId ? { relationship: relId } : {}), path: `${kind}/${nativeId}` }, - ...(bboxOf(descendants(frame, 'xfrm')[0], ctx.transform) ? { bbox: bboxOf(descendants(frame, 'xfrm')[0], ctx.transform) } : {}), - opaque: { type: kind, reason: 'The object and visible text are preserved, but its full semantic model is not expanded.', - data: { uri, target: rel?.target } }, extensions: { nativeId, name: native?.attributes.name } }); - attach(ctx, parentId, id); - ctx.checks.push({ code: `${kind}_partial`, severity: 'warning', message: `Slide ${ctx.page} contains ${kind} whose full semantics are not expanded locally.`, pages: [ctx.page], nodeIds: [id] }); -} - -/** - * 嵌入对象(OLE)。看不见的(插件元数据)只留一个不渲染的节点;看得见的用它的预览图作一张 - * 图片——剪贴画、工作簿在幻灯片上显示的就是这张图。预览图先找对象里的 `p:pic`,旧式文件在 - * VML 绘图里按 spid 找。两处都没有就交回通用分支,照旧报「不支持」。 - */ -function parseEmbeddedObject(frame: XmlNode, ctx: Context, parentId: string | null, nativeId: string): boolean { - const objects = descendants(frame, 'oleObj'); - if (!objects.length) return false; - const attribute = (name: string): string | undefined => objects.map((object) => object.attributes[name]).find((value) => typeof value === 'string' && value !== ''); - const progId = attribute('progId'); - const objectName = attribute('name'); - const embeddedObject = { ...(progId ? { progId } : {}), ...(objectName ? { name: objectName } : {}) }; - const relIds = objects.map((object) => object.attributes['r:id'] ?? object.attributes.id).filter((value): value is string => Boolean(value)); - const native = descendants(frame, 'cNvPr')[0]; - const bbox = bboxOf(first(frame, 'xfrm'), ctx.transform); - const id = stableId(ctx.source.sha256, `${ctx.part}:embedded:${nativeId}`); - const sourceRef = { part: ctx.part, page: ctx.page, ...(relIds[0] ? { relationship: relIds[0] } : {}), path: `embedded/${nativeId}` }; - const tally = (bucket: EmbeddedTally['preview']): void => { - bucket.nodeIds.push(id); bucket.pages.add(ctx.page); - for (const relId of relIds) ctx.handledObjects.add(relId); - attach(ctx, parentId, id); - }; - const preview = embeddedObjectPreview(objects, ctx); - // 什么也不画的对象:边长不到 1pt,或没有预览图且声明的图像尺寸为 0(实测讲义里 3 个空的剪贴画壳)。 - const tiny = bbox !== undefined && (bbox[2] - bbox[0] < HIDDEN_OBJECT_MAX_PT || bbox[3] - bbox[1] < HIDDEN_OBJECT_MAX_PT); - const empty = !preview && attribute('imgW') === '0' && attribute('imgH') === '0'; - if (tiny || empty) { - ctx.nodes.push({ id, type: 'embedded_object', parentId, children: [], order: ctx.order++, page: ctx.page, zIndex: ctx.order, sourceRef, ...(bbox ? { bbox } : {}), - opaque: { type: 'embedded_object', reason: 'The object draws nothing visible on the slide.', data: embeddedObject }, - extensions: { nativeId, name: native?.attributes.name, embeddedObject } }); - tally(ctx.embedded.hidden); - return true; - } - if (!preview) return false; - const bytes = ctx.pkg.readAsset(preview); - const asset = packageImageAsset(preview, bytes); - ctx.assets.push({ ...asset, data: bytes, sourceRef: { part: ctx.part } }); - ctx.nodes.push({ id, type: 'image', parentId, children: [], order: ctx.order++, text: '', page: ctx.page, zIndex: ctx.order, sourceRef, ...(bbox ? { bbox } : {}), - extensions: { nativeId, name: native?.attributes.name, assetPath: asset.path, embeddedObject } }); - tally(ctx.embedded.preview); - return true; -} - -function embeddedObjectPreview(objects: XmlNode[], ctx: Context): string | undefined { - for (const object of objects) { - const blip = descendants(object, 'blip')[0]; - const relId = blip?.attributes['r:embed'] ?? blip?.attributes.embed; - const rel = relId ? ctx.rels.get(relId) : undefined; - if (rel && !rel.external && ctx.pkg.has(rel.target)) return rel.target; - } - const spid = objects.map((object) => object.attributes.spid).find(Boolean); - const target = spid ? slideVmlImages(ctx).get(spid) : undefined; - return target && ctx.pkg.has(target) ? target : undefined; -} - -/** 这一页 VML 绘图里的图片:形状 id(与 `p:oleObj@spid` 相同)→ `v:imagedata` 指向的图片部件。 */ -function slideVmlImages(ctx: Context): Map { - if (ctx.vmlImages) return ctx.vmlImages; - const images = new Map(); - for (const rel of ctx.rels.values()) { - if (!rel.type.endsWith('/vmlDrawing') || rel.external || !ctx.pkg.has(rel.target)) continue; - let root: XmlNode; - try { root = ctx.pkg.xml(rel.target); } catch { continue; } - const vmlRels = ctx.pkg.relationships(rel.target); - for (const shape of descendants(root, 'shape')) { - const imagedata = descendants(shape, 'imagedata')[0]; - const relId = imagedata?.attributes['o:relid'] ?? imagedata?.attributes.relid ?? imagedata?.attributes['r:id']; - const target = relId ? vmlRels.get(relId)?.target : undefined; - if (!target) continue; - for (const key of [shape.attributes.id, shape.attributes['o:spid'], shape.attributes.spid]) if (key) images.set(key, target); - } - } - ctx.vmlImages = images; - return images; -} - -function parseTable(table: XmlNode, frame: XmlNode, ctx: Context, parentId: string | null, nativeId: string): void { - const tableId = stableId(ctx.source.sha256, `${ctx.part}:table:${nativeId}`); - const tableNode: DeckIrNode = { id: tableId, type: 'table', parentId, children: [], order: ctx.order++, page: ctx.page, - sourceRef: { part: ctx.part, page: ctx.page, path: `table/${nativeId}` }, - ...(bboxOf(descendants(frame, 'xfrm')[0], ctx.transform) ? { bbox: bboxOf(descendants(frame, 'xfrm')[0], ctx.transform) } : {}), - extensions: { rows: children(table, 'tr').length, columns: children(first(table, 'tblGrid') ?? emptyNode(), 'gridCol').length } }; - ctx.nodes.push(tableNode); attach(ctx, parentId, tableId); - for (const [rowIndex, row] of children(table, 'tr').entries()) { - const rowId = stableId(ctx.source.sha256, `${tableId}:row:${rowIndex}`); - const rowNode: DeckIrNode = { id: rowId, type: 'table_row', parentId: tableId, children: [], order: ctx.order++, page: ctx.page, sourceRef: { part: ctx.part, page: ctx.page, path: `table/${nativeId}/row/${rowIndex}` } }; - ctx.nodes.push(rowNode); tableNode.children.push(rowId); - for (const [column, cell] of children(row, 'tc').entries()) { - const cellId = stableId(ctx.source.sha256, `${rowId}:cell:${column}`); - const cellRuns = descendants(cell, 'p').flatMap((paragraph, index) => [...(index ? [{ text: '\n' }] : []), ...textRuns(paragraph, ctx.rels)]); - const cellNode: DeckIrNode = { id: cellId, type: 'table_cell', parentId: rowId, children: [], order: ctx.order++, text: cellRuns.map((run) => run.text).join(''), runs: cellRuns, page: ctx.page, - sourceRef: { part: ctx.part, page: ctx.page, path: `table/${nativeId}/row/${rowIndex}/cell/${column}` }, - extensions: { row: rowIndex, column, rowSpan: Number(cell.attributes.rowSpan ?? 1), gridSpan: Number(cell.attributes.gridSpan ?? 1), hMerge: cell.attributes.hMerge === '1', vMerge: cell.attributes.vMerge === '1' } }; - ctx.nodes.push(cellNode); rowNode.children.push(cellId); - } - } -} - -function parseGroup(group: XmlNode, ctx: Context, parentId: string | null): void { - const native = descendants(first(group, 'nvGrpSpPr') ?? emptyNode(), 'cNvPr')[0]; - const nativeId = native?.attributes.id ?? String(ctx.order); - const id = stableId(ctx.source.sha256, `${ctx.part}:group:${nativeId}`); - const xfrm = first(first(group, 'grpSpPr') ?? emptyNode(), 'xfrm'); - const bbox = bboxOf(xfrm, ctx.transform); - ctx.nodes.push({ id, type: 'group', parentId, children: [], order: ctx.order++, page: ctx.page, zIndex: ctx.order, - sourceRef: { part: ctx.part, page: ctx.page, path: `group/${nativeId}` }, ...(bbox ? { bbox } : {}), - extensions: { rawTransform: rawTransform(xfrm) } }); - attach(ctx, parentId, id); - // 平移与缩放按子画布换算成幻灯片坐标;旋转与翻转没有换算,那样的组合里子形状的框只是近似。 - const rotated = Number(xfrm?.attributes.rot ?? 0) !== 0 || xfrm?.attributes.flipH === '1' || xfrm?.attributes.flipV === '1'; - if (rotated) { - ctx.checks.push({ code: 'group_transform_partial', severity: 'warning', message: `Slide ${ctx.page} contains a rotated or flipped group; its children's positions are approximate.`, pages: [ctx.page], nodeIds: [id] }); - } - const parentTransform = ctx.transform; - ctx.transform = childTransform(xfrm, parentTransform); - try { parseShapeTree(group, ctx, id); } finally { ctx.transform = parentTransform; } -} - -/** - * 组合的子画布:子形状的坐标写在 `chOff`/`chExt` 定义的空间里,按 `off`/`ext` 映射回组合外框。 - * 原先直接用子形状自己的 `xfrm`,框落在另一套坐标里,既不能按位置排序,也让每个组合都报 - * `group_transform_partial`(实测一份 99 页讲义 339 个组合,整份产物因此判成 degraded)。 - */ -function childTransform(xfrm: XmlNode | undefined, parent: Transform): Transform { - if (!xfrm) return parent; - const off = first(xfrm, 'off'); const ext = first(xfrm, 'ext'); - const chOff = first(xfrm, 'chOff'); const chExt = first(xfrm, 'chExt'); - if (!off || !ext || !chOff || !chExt) return parent; - const scale = (outer: string | undefined, inner: string | undefined): number => { - const extent = Number(inner ?? 0); - return extent > 0 ? Number(outer ?? 0) / extent : 1; - }; - const scaleX = scale(ext.attributes.cx, chExt.attributes.cx); - const scaleY = scale(ext.attributes.cy, chExt.attributes.cy); - const offsetX = emuToPt(Number(off.attributes.x ?? 0)) - emuToPt(Number(chOff.attributes.x ?? 0)) * scaleX; - const offsetY = emuToPt(Number(off.attributes.y ?? 0)) - emuToPt(Number(chOff.attributes.y ?? 0)) * scaleY; - return { - scaleX: parent.scaleX * scaleX, - scaleY: parent.scaleY * scaleY, - translateX: parent.translateX + parent.scaleX * offsetX, - translateY: parent.translateY + parent.scaleY * offsetY, - }; -} - -function parseNotes(ctx: Context): void { - const rel = [...ctx.rels.values()].find((item) => item.type.endsWith('/notesSlide')); - if (!rel || rel.external || !ctx.pkg.has(rel.target)) return; - const root = ctx.pkg.xml(rel.target); - const rels = ctx.pkg.relationships(rel.target); - for (const [index, shape] of descendants(root, 'sp').entries()) { - const placeholder = descendants(shape, 'ph')[0]?.attributes.type; - if (placeholder === 'sldImg' || placeholder === 'hdr' || placeholder === 'ftr' || placeholder === 'dt' || placeholder === 'sldNum') continue; - // 与幻灯片上的文本框同一种切法:跳过空段落,多段时记下段落边界,Markdown 逐段分开。 - const { runs, spans } = shapeParagraphs(shape, rels); - const text = runs.map((run) => run.text).join(''); - if (!text.trim()) continue; - const paragraphs = paragraphStructure(spans.map(({ start, end }): TextParagraph => ({ start, end, level: 0 }))); - const id = stableId(ctx.source.sha256, `${rel.target}:note:${index}`); - ctx.nodes.push({ id, type: 'speaker_note', parentId: null, children: [], order: ctx.order++, text, runs, page: ctx.page, - sourceRef: { part: rel.target, page: ctx.page, path: `note/${index}` }, ...(paragraphs ? { extensions: { paragraphs } } : {}) }); - } -} - -function auditUnsupportedRelationships(ctx: Context): void { - for (const rel of ctx.rels.values()) { - const kind = /oleObject|package/i.test(rel.type) ? 'embedded_object' : /audio|video|media/i.test(rel.type) ? 'media' : undefined; - if (!kind || ctx.handledObjects.has(rel.id)) continue; - const id = stableId(ctx.source.sha256, `${ctx.part}:opaque:${kind}:${rel.id}`); - ctx.nodes.push({ id, type: 'opaque', parentId: null, children: [], order: ctx.order++, page: ctx.page, - sourceRef: { part: ctx.part, page: ctx.page, relationship: rel.id }, - opaque: { type: kind, reason: 'The relationship is preserved but its binary payload is not interpreted.', data: { target: rel.target, external: rel.external } } }); - ctx.checks.push({ code: `${kind}_unsupported`, severity: 'warning', message: `Slide ${ctx.page} contains ${kind === 'media' ? 'audio/video media' : 'an embedded object'} that is not expanded locally.`, pages: [ctx.page], nodeIds: [id] }); - } -} - -function textRuns(paragraph: XmlNode, rels: Map): DeckIrRun[] { - const result: DeckIrRun[] = []; - for (const run of paragraph.children) { - if (run.local === 'br') { result.push({ text: '\n' }); continue; } - if (run.local !== 'r' && run.local !== 'fld') continue; - const text = descendants(run, 't').map(textContent).join(''); - if (!text) continue; - const rPr = first(run, 'rPr'); - const link = descendants(rPr ?? emptyNode(), 'hlinkClick')[0]; - const relId = link?.attributes.id ?? link?.attributes['r:id']; - const href = relId ? rels.get(relId)?.target : undefined; - result.push({ text, ...(rPr?.attributes.b === '1' ? { bold: true } : {}), ...(rPr?.attributes.i === '1' ? { italic: true } : {}), - ...(rPr?.attributes.u && rPr.attributes.u !== 'none' ? { underline: true } : {}), ...(href ? { href } : {}), - style: { ...(rPr?.attributes.sz ? { fontSize: Number(rPr.attributes.sz) / 100 } : {}), - ...(rPr?.attributes.lang ? { lang: rPr.attributes.lang } : {}), ...(first(rPr ?? emptyNode(), 'latin')?.attributes.typeface ? { fontFamily: first(rPr ?? emptyNode(), 'latin')!.attributes.typeface } : {}) } }); - } - return result; -} - -function bboxOf(xfrm: XmlNode | undefined, transform: Transform): [number, number, number, number] | undefined { - if (!xfrm) return undefined; - const off = first(xfrm, 'off'); const ext = first(xfrm, 'ext'); - if (!off || !ext) return undefined; - const x = emuToPt(Number(off.attributes.x ?? 0)); const y = emuToPt(Number(off.attributes.y ?? 0)); - const round = (value: number): number => Math.round(value * 1000) / 1000; - return [ - round(transform.translateX + transform.scaleX * x), - round(transform.translateY + transform.scaleY * y), - round(transform.translateX + transform.scaleX * (x + emuToPt(Number(ext.attributes.cx ?? 0)))), - round(transform.translateY + transform.scaleY * (y + emuToPt(Number(ext.attributes.cy ?? 0)))), - ]; -} - -function rawTransform(xfrm?: XmlNode): Record | undefined { - if (!xfrm) return undefined; - return { ...xfrm.attributes, off: first(xfrm, 'off')?.attributes, ext: first(xfrm, 'ext')?.attributes, - childOffset: first(xfrm, 'chOff')?.attributes, childExtent: first(xfrm, 'chExt')?.attributes }; -} - -function emuToPt(value: number): number { return Math.round(value / 12700 * 1000) / 1000; } - -function attach(ctx: Context, parentId: string | null, id: string): void { - if (parentId) ctx.nodes.find((node) => node.id === parentId)?.children.push(id); -} - -function readTheme(pkg: OpcPackage, presentationRels: Map): Record { - const master = [...presentationRels.values()].find((rel) => rel.type.endsWith('/slideMaster')); - if (!master || master.external || !pkg.has(master.target)) return {}; - const themeRel = [...pkg.relationships(master.target).values()].find((rel) => rel.type.endsWith('/theme')); - if (!themeRel || themeRel.external || !pkg.has(themeRel.target)) return {}; - const root = pkg.xml(themeRel.target); - const scheme = descendants(root, 'clrScheme')[0]; - const colors: Record = {}; - for (const color of scheme?.children ?? []) { - const value = color.children[0]?.attributes.val ?? color.children[0]?.attributes.lastClr; - if (value) colors[color.local] = value; - } - return { name: descendants(root, 'theme')[0]?.attributes.name, colors, part: themeRel.target }; -} - -function readCoreProperties(pkg: OpcPackage): Record { - if (!pkg.has('docProps/core.xml')) return {}; - const root = pkg.xml('docProps/core.xml'); const out: Record = {}; - for (const key of ['title', 'subject', 'creator', 'description', 'keywords', 'created', 'modified', 'lastModifiedBy']) { - const value = descendants(root, key)[0]; if (value) out[key] = textContent(value); - } - return out; -} - -function emptyNode(): XmlNode { return { local: '', uri: '', attributes: {}, children: [], text: '' }; } diff --git a/src/local/runner.ts b/src/local/runner.ts deleted file mode 100644 index 05858f0..0000000 --- a/src/local/runner.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { parsePdf } from './pdf/adapter.js'; -import { Worker } from 'node:worker_threads'; -import { DeckOpsError } from '../errors/index.js'; -import { parseDocx } from './docx/parser.js'; -import { parseHtmlSource } from './html/parser.js'; -import { parsePptx } from './pptx/parser.js'; -import type { WorkerRequest, WorkerResponse } from './worker-protocol.js'; - -export async function runLocalWorker(request: WorkerRequest, signal: AbortSignal): Promise { - signal.throwIfAborted(); - // Vitest executes TypeScript source directly; production bundles use the bounded worker entry. - if (import.meta.url.endsWith('.ts')) return runDirect(request); - const worker = new Worker(new URL('./local-worker.js', import.meta.url), { - resourceLimits: { maxOldGenerationSizeMb: request.limits.workerHeapMb }, - }); - return new Promise((resolve, reject) => { - let settled = false; - const finish = (callback: () => void): void => { if (settled) return; settled = true; signal.removeEventListener('abort', aborted); callback(); }; - const aborted = (): void => finish(() => { void worker.terminate(); reject(signal.reason instanceof Error ? signal.reason : new Error('Local parser aborted.')); }); - signal.addEventListener('abort', aborted, { once: true }); - if (signal.aborted) { aborted(); return; } - worker.once('message', (response: WorkerResponse) => finish(() => { - void worker.terminate(); - if (response.ok) resolve(response.candidate); - else reject(response.error.code ? new DeckOpsError(response.error.code as never, response.error.message, { ...(response.error.hint ? { hint: response.error.hint } : {}) }) : new Error(response.error.message)); - })); - worker.once('error', (error) => finish(() => reject(error))); - worker.once('exit', (code) => { if (code !== 0) finish(() => reject(new Error(`Local parser worker exited with code ${code}.`))); }); - const data = request.kind === 'docx' || request.kind === 'pptx' ? request.data : undefined; - const transfer = data && data.buffer instanceof ArrayBuffer && data.byteOffset === 0 && data.byteLength === data.buffer.byteLength ? [data.buffer] : []; - worker.postMessage(request, transfer); - }); -} - -function runDirect(request: WorkerRequest) { - if (request.kind === 'pdf') return parsePdf(request.input, request.source, request.limits, request.options); - if (request.kind === 'docx') return Promise.resolve(parseDocx(request.data, request.source, request.limits, request.options)); - if (request.kind === 'pptx') return Promise.resolve(parsePptx(request.data, request.source, request.limits)); - return Promise.resolve(parseHtmlSource(request.html, request.source, request.baseUrl)); -} diff --git a/src/local/worker-protocol.ts b/src/local/worker-protocol.ts deleted file mode 100644 index 5a967f4..0000000 --- a/src/local/worker-protocol.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { ParseCandidate } from '../ir/schema.js'; -import type { SourceIdentity } from './common.js'; -import type { DocxOptions } from './docx/parser.js'; -import type { LocalLimits } from './limits.js'; - -export type WorkerRequest = - | { kind: 'pdf'; input: string | Uint8Array; source: SourceIdentity; limits: LocalLimits; options: import('./pdf/adapter.js').LocalPdfOptions } - | { kind: 'docx'; data: Uint8Array; source: SourceIdentity; limits: LocalLimits; options: DocxOptions } - | { kind: 'pptx'; data: Uint8Array; source: SourceIdentity; limits: LocalLimits } - | { kind: 'html'; html: string; source: SourceIdentity; baseUrl?: string | undefined; limits: LocalLimits }; - -export type WorkerResponse = - | { ok: true; candidate: ParseCandidate } - | { ok: false; error: { name: string; message: string; code?: string | undefined; hint?: string | undefined; stack?: string | undefined } }; diff --git a/src/local/worker.ts b/src/local/worker.ts deleted file mode 100644 index c4e61ea..0000000 --- a/src/local/worker.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { parsePdf } from './pdf/adapter.js'; -import { parentPort } from 'node:worker_threads'; -import { parseDocx } from './docx/parser.js'; -import { parseHtmlSource } from './html/parser.js'; -import { parsePptx } from './pptx/parser.js'; -import type { WorkerRequest, WorkerResponse } from './worker-protocol.js'; - -if (!parentPort) throw new Error('deckops local worker must run in a worker thread.'); - -parentPort.once('message', async (request: WorkerRequest) => { - try { - const candidate = request.kind === 'pdf' ? await parsePdf(request.input, request.source, request.limits, request.options) - : request.kind === 'docx' ? parseDocx(request.data, request.source, request.limits, request.options) - : request.kind === 'pptx' ? parsePptx(request.data, request.source, request.limits) - : parseHtmlSource(request.html, request.source, request.baseUrl); - parentPort!.postMessage({ ok: true, candidate } satisfies WorkerResponse); - } catch (error) { - const value = error as Error & { code?: string; hint?: string }; - parentPort!.postMessage({ ok: false, error: { name: value.name, message: value.message, - ...(value.code ? { code: value.code } : {}), ...(value.hint ? { hint: value.hint } : {}), ...(value.stack ? { stack: value.stack } : {}) } } satisfies WorkerResponse); - } -}); diff --git a/src/local/xml.ts b/src/local/xml.ts deleted file mode 100644 index a3fb86b..0000000 --- a/src/local/xml.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { SaxesParser, type SaxesAttributeNS, type SaxesTagNS } from 'saxes'; -import { DeckOpsError } from '../errors/index.js'; -import type { LocalLimits } from './limits.js'; - -export interface XmlNode { - local: string; - uri: string; - attributes: Record; - children: XmlNode[]; - text: string; -} -/** Bounded namespace-aware SAX parse into a deliberately tiny tree. */ -export function parseXml(data: Uint8Array | string, limits: LocalLimits, part: string): XmlNode { - const xml = typeof data === 'string' ? data : new TextDecoder().decode(data); - if (/ { - guardEvent(); - if (stack.length > limits.xmlDepth) throw DeckOpsError.input(`${part} exceeds the XML depth limit.`); - const attrs: Record = {}; - const values = Object.values(tag.attributes) as SaxesAttributeNS[]; - if (values.length > limits.xmlAttributes) throw DeckOpsError.input(`${part} has too many attributes on one element.`); - for (const attr of values) { - attrs[attr.name] = attr.value; - attrs[attr.local] ??= attr.value; - } - const node: XmlNode = { local: tag.local, uri: tag.uri, attributes: attrs, children: [], text: '' }; - stack.at(-1)!.children.push(node); - stack.push(node); - }); - parser.on('text', (text: string) => { - guardEvent(); - textBytes += Buffer.byteLength(text); - if (textBytes > limits.xmlTextBytes) throw DeckOpsError.input(`${part} exceeds the XML text limit.`); - stack.at(-1)!.text += text; - }); - parser.on('cdata', (text: string) => { - guardEvent(); - textBytes += Buffer.byteLength(text); - if (textBytes > limits.xmlTextBytes) throw DeckOpsError.input(`${part} exceeds the XML text limit.`); - stack.at(-1)!.text += text; - }); - parser.on('closetag', () => { - guardEvent(); - stack.pop(); - }); - parser.on('error', (error: Error) => { - throw DeckOpsError.input(`${part} is not valid XML: ${error.message}`, { cause: error }); - }); - parser.write(xml).close(); - return root; - - function guardEvent(): void { - events += 1; - if (events > limits.xmlEvents) throw DeckOpsError.input(`${part} exceeds the XML event limit.`); - } -} - -export function children(node: XmlNode, local: string): XmlNode[] { - return node.children.filter((child) => child.local === local); -} - -export function descendants(node: XmlNode, local: string): XmlNode[] { - const found: XmlNode[] = []; - const visit = (current: XmlNode): void => { - for (const child of current.children) { - if (child.local === local) found.push(child); - visit(child); - } - }; - visit(node); - return found; -} - -export function first(node: XmlNode, local: string): XmlNode | undefined { - return node.children.find((child) => child.local === local); -} - -export function textContent(node: XmlNode): string { - return `${node.text}${node.children.map(textContent).join('')}`; -} diff --git a/src/node/sdk.ts b/src/node/sdk.ts new file mode 100644 index 0000000..e248f12 --- /dev/null +++ b/src/node/sdk.ts @@ -0,0 +1,122 @@ +import fs from 'node:fs/promises'; +import type { DeckClient } from '../client/index.js'; +import type { CredentialOverrides } from '../config/index.js'; +import { DeckOpsError } from '../errors/index.js'; +import type { CommonFlags, ConvertEnvelope, ConvertFlags, Manifest, ParseEnvelope, ParseFlags } from '../types.js'; +import { irPath, readManifest } from './artifact.js'; +import { cloudClient } from './cloud.js'; +import { runConvert } from './convert-op.js'; +import { resolveInput } from './input.js'; +import { runParse } from './parse-op.js'; +import { runRead, type ReadResult } from './read-op.js'; +import { validateConvertFlags, validateParseFlags } from './validation.js'; + +/** Node SDK: the cloud client plus filesystem artifacts and the shared credential chain. */ + +export type ClientOptions = CredentialOverrides; +export type ParseInputOptions = ParseFlags & CommonFlags & { out?: string; from?: string }; +export type ConvertInputOptions = ConvertFlags & ParseFlags & CommonFlags & { out?: string; from?: string }; +export type ReadInputOptions = ParseInputOptions & { format?: 'markdown' | 'ir'; reportFile?: string; cacheDir?: string; anchors?: boolean }; +export type ArtifactConvertOptions = ConvertFlags & CommonFlags & { out?: string }; + +const PARSE_KEYS = ['profile', 'password', 'includeImages', 'stayImageAreaRate', 'mode'] as const; +const CONVERT_KEYS = ['to', 'anchors', 'splitPages', 'strict', 'keepRemoteImages'] as const; +const COMMON_KEYS = ['signal', 'spaceId', 'timeout', 'force'] as const; +const READ_KEYS = ['out', 'format', 'reportFile', 'cacheDir', 'anchors'] as const; + +/** An artifact on disk: the server IR it stores and a convert() bound to it. */ +export class ParsedDocument { + constructor(readonly dir: string, readonly manifest: Manifest, private readonly client: () => Promise) {} + + get taskId(): string { return this.manifest.parse.taskId; } + get irKey(): string { return this.manifest.parse.irKey; } + get irSchemaVersion(): string { return this.manifest.parse.irSchemaVersion; } + get expiresAt(): string { return this.manifest.parse.expiresAt; } + + /** ir.json as the server returned it. */ + async ir(): Promise { + return JSON.parse(await fs.readFile(irPath(this.dir), 'utf-8')); + } + + async convert(options: ArtifactConvertOptions = {}): Promise { + assertKnown(options, [...CONVERT_KEYS, ...COMMON_KEYS, 'out'], 'convert'); + const input = { kind: 'artifact' as const, dir: this.dir, manifest: this.manifest }; + const flags = pick(options, CONVERT_KEYS); + validateConvertFlags(input, flags); + return runConvert({ input, inputLabel: this.dir, ...(options.out !== undefined ? { out: options.out } : {}), flags, + common: pick(options, COMMON_KEYS), client: this.client }); + } +} + +export interface DeckOpsClient { + read(input: string, options: ReadInputOptions & { format: 'ir' }): Promise>; + read(input: string, options?: ReadInputOptions & { format?: 'markdown' }): Promise>; + parse(input: string, options?: ParseInputOptions): Promise; + parseEnvelope(input: string, options?: ParseInputOptions): Promise; + convert(input: string, options?: ConvertInputOptions): Promise; + openArtifact(dir: string): Promise; +} + +export function createClient(options: ClientOptions = {}): DeckOpsClient { + assertKnown(options, ['apiKey', 'token', 'spaceId', 'apiBase'], 'client'); + let cached: DeckClient | undefined; + const client = async (): Promise => (cached ??= await cloudClient(options)); + + const parseEnvelope = async (input: string, settings: ParseInputOptions = {}): Promise => { + assertKnown(settings, [...PARSE_KEYS, ...COMMON_KEYS, 'out', 'from'], 'parse'); + const resolved = await resolveInput(input, settings.from !== undefined ? { from: settings.from } : {}); + const flags = pick(settings, PARSE_KEYS); + validateParseFlags(resolved, flags); + return runParse({ input: resolved, inputLabel: input, ...(settings.out !== undefined ? { out: settings.out } : {}), flags, + common: pick(settings, COMMON_KEYS), client }); + }; + + const read = async (input: string, settings: ReadInputOptions = {}): Promise => { + assertKnown(settings, [...PARSE_KEYS, ...COMMON_KEYS, ...READ_KEYS, 'from'], 'read'); + const resolved = await resolveInput(input, settings.from !== undefined ? { from: settings.from } : {}); + const flags = pick(settings, PARSE_KEYS); + validateParseFlags(resolved, flags); + return runRead({ input: resolved, inputLabel: input, flags, common: pick(settings, COMMON_KEYS), client, + ...pick>(settings, READ_KEYS) }); + }; + + return { + read: read as DeckOpsClient['read'], + parseEnvelope, + parse: async (input, settings = {}) => { + const envelope = await parseEnvelope(input, settings); + return new ParsedDocument(envelope.artifact, await readManifest(envelope.artifact), client); + }, + openArtifact: async (dir) => new ParsedDocument(dir, await readManifest(dir), client), + convert: async (input, settings = {}) => { + assertKnown(settings, [...CONVERT_KEYS, ...PARSE_KEYS, ...COMMON_KEYS, 'out', 'from'], 'convert'); + const resolved = await resolveInput(input, settings.from !== undefined ? { from: settings.from } : {}); + const parseFlags = pick(settings, PARSE_KEYS); + const flags = pick(settings, CONVERT_KEYS); + validateParseFlags(resolved, parseFlags); + validateConvertFlags(resolved, flags); + return runConvert({ input: resolved, inputLabel: input, ...(settings.out !== undefined ? { out: settings.out } : {}), flags, parseFlags, + common: pick(settings, COMMON_KEYS), client }); + }, + }; +} + +/** A removed or misspelled option must not be ignored: dropping `engine: 'local'` silently would upload. */ +function assertKnown(options: object, allowed: readonly string[], label: string): void { + if (typeof options !== 'object' || options === null || Array.isArray(options)) throw DeckOpsError.usage(`${label} options must be an object.`); + const unknown = Object.keys(options).filter((key) => !allowed.includes(key)); + if (unknown.length) throw DeckOpsError.usage(`Unknown ${label} option${unknown.length > 1 ? 's' : ''}: ${unknown.join(', ')}.`); +} + +function pick(options: object, keys: readonly string[]): T { + return Object.fromEntries(Object.entries(options).filter(([key, value]) => keys.includes(key) && value !== undefined)) as T; +} + +const defaultClient = createClient(); +export async function parse(input: string, options?: ParseInputOptions): Promise { return defaultClient.parse(input, options); } +export async function openArtifact(dir: string): Promise { return defaultClient.openArtifact(dir); } +export async function convert(input: string, options?: ConvertInputOptions): Promise { return defaultClient.convert(input, options); } + +export function read(input: string, options: ReadInputOptions & { format: 'ir' }): Promise>; +export function read(input: string, options?: ReadInputOptions & { format?: 'markdown' }): Promise>; +export function read(input: string, options?: ReadInputOptions): Promise { return defaultClient.read(input, options as ReadInputOptions & { format: 'ir' }); } diff --git a/src/quality/assessment.ts b/src/quality/assessment.ts deleted file mode 100644 index 49a442a..0000000 --- a/src/quality/assessment.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { DeckIR, ParseCandidate } from '../ir/schema.js'; -import type { DeckProbeReport } from '../shared/preflight.js'; -import { candidateAssetOutputPath } from '../ir/assets.js'; -import { validateDeckIR } from '../ir/validate.js'; -import { bodyTextStats } from '../shared/text-quality.js'; -export type { QualityIssue, Assessment } from '../shared/assessment-types.js'; -import type { QualityIssue, Assessment } from '../shared/assessment-types.js'; -const REMEDIES: Record = { - ocr_required: 'ocr', missing_slide: 'document_parse', page_parse_failed: 'document_parse', source_page_count_mismatch: 'document_parse', - no_body_text: 'document_parse', garbled_text: 'document_parse', control_characters: 'document_parse', - chart_partial: 'chart_content', smartart_partial: 'smartart_content', table_ambiguous: 'table_structure', - source_object_loss: 'document_parse', local_parse_failed: 'document_parse', text_layer_suspect: 'document_parse', - group_transform_partial: 'visual_layout', composite_figure_unavailable: 'visual_layout', unrepresentable_content: 'visual_layout', - graphic_frame_partial: 'visual_layout', embedded_object_unsupported: 'embedded_content', media_unsupported: 'embedded_content', - embedded_object_undetected: 'embedded_content', -}; -const VISUAL = new Set(['group_transform_partial', 'composite_figure_unavailable', 'media_unsupported', 'embedded_object_unsupported', 'graphic_frame_partial', 'unrepresentable_content']); -const INFORMATIONAL = new Set(['macro_preserved', 'external_asset']); -const sorted = (values: Iterable) => [...new Set(values)].sort((a, b) => a - b); -export function assessCandidate(candidate: ParseCandidate, probe?: DeckProbeReport, knownSourcePages?: number): Assessment { - validateDeckIR(candidate.ir); - const ir = candidate.ir; - const issues: QualityIssue[] = candidate.quality.checks.map(check => ({ ...check, - impact: INFORMATIONAL.has(check.code) || check.severity === 'info' ? 'informational' : VISUAL.has(check.code) ? 'visual_loss' - : check.code.includes('asset') || check.code === 'missing_media' ? 'delivery' - : check.code.includes('partial') || check.code.includes('ambiguous') ? 'structure_loss' : 'content_missing', - evidenceKind: check.code === 'ocr_required' && check.detail?.method === 'image-text-heuristic' ? 'heuristic' : 'parser', - ...(REMEDIES[check.code] ? { neededCapability: REMEDIES[check.code] } : {}), - })); - const key = ir.format === 'pdf' ? 'pdf.page_count' : ir.format === 'pptx' ? 'powerpoint.slide_count' : undefined; - const evidence = key ? probe?.results[key] : undefined; - const exactProbe = evidence?.status === 'resolved' && evidence.confidence === 'exact' && typeof evidence.value === 'number' && Number.isSafeInteger(evidence.value) && evidence.value >= 0 ? evidence.value : undefined; - const total = knownSourcePages ?? (ir.producer.engine === 'local' && key ? candidate.quality.coverage.pages?.total : undefined); - const sourcePageCount = exactProbe ?? (typeof total === 'number' && Number.isSafeInteger(total) && total >= 0 ? total : undefined); - const pageIndices = new Set(ir.document.pages.map(p => p.index)); - const failedPages = sorted(issues.filter(i => i.code === 'page_parse_failed').flatMap(i => i.pages ?? [])); - const missing = new Set(issues.filter(i => i.code === 'missing_slide').flatMap(i => i.pages ?? [])); - let missingPageCount = missing.size; - if (sourcePageCount !== undefined) { - const present = sorted([...pageIndices].filter(p => p >= 1 && p <= sourcePageCount)); - missingPageCount = sourcePageCount - present.length; - // Bound the list independently of an untrusted source count. Always return the full count. - let previous = 0; - for (const next of [...present, sourcePageCount + 1]) { - for (let p = previous + 1; p < next && missing.size < 1000; p++) missing.add(p); - previous = next; - if (missing.size >= 1000) break; - } - if (missingPageCount > 0 || pageIndices.size !== sourcePageCount) issues.push({ code: 'source_page_count_mismatch', severity: 'error', - message: `Source declares ${sourcePageCount} pages/slides; result contains ${pageIndices.size}, with ${missingPageCount} missing.`, impact: 'content_missing', evidenceKind: exactProbe !== undefined ? 'source_comparison' : 'parser', neededCapability: 'document_parse' }); - } - const stats = bodyTextStats(ir); - const searchableTextCharacters = stats.characters; - if (!searchableTextCharacters) issues.push({ code: 'no_body_text', severity: 'warning', message: 'No searchable body text was extracted. Images or a blank document can also produce this result.' + (ir.producer.engine === 'local' ? ' Local OCR is not provided.' : ''), impact: 'content_missing', evidenceKind: 'source_comparison', neededCapability: issues.some(i => i.code === 'ocr_required') ? 'ocr' : 'document_parse' }); - if (stats.text.length >= 100 && (stats.text.match(/\uFFFD/g)?.length ?? 0) / stats.text.length > 0.05) issues.push({ code: 'garbled_text', severity: 'warning', message: 'Extracted text contains an unusually high replacement-character ratio.', impact: 'content_missing', evidenceKind: 'heuristic', neededCapability: 'document_parse' }); - if (stats.controlCharacters) issues.push({ code: 'control_characters', severity: 'warning', message: `${stats.controlCharacters} unsupported control characters in body text; Markdown replaces them with U+FFFD, without recovering the source text.`, impact: 'content_missing', evidenceKind: 'source_comparison', neededCapability: 'document_parse', detail: { count: stats.controlCharacters, replacement: 'U+FFFD' } }); - const assetPaths = new Set(ir.document.assets.map(a => a.path)); - if (ir.document.nodes.some(n => typeof n.extensions?.assetPath === 'string' && !assetPaths.has(n.extensions.assetPath))) issues.push({ code: 'asset_reference_missing', severity: 'error', message: 'An IR image refers to an unavailable artifact asset.', impact: 'delivery', evidenceKind: 'source_comparison' }); - const unassessed = ['reading_order_correctness', 'semantic_accuracy']; - if (ir.producer.engine === 'cloud' && !('sourceObjectCoverage' in candidate.quality.coverage)) unassessed.push('cloud_content_coverage'); - if (exactProbe === undefined && key) unassessed.push('source_page_completeness'); - const relevant = issues.filter(i => i.severity !== 'info' && i.impact !== 'informational'); - const reasons = [...new Set(relevant.filter(i => i.impact !== 'delivery').map(i => i.code))]; - return { evaluatorVersion: 'rules.v2', status: relevant.length ? 'needs_attention' : unassessed.includes('cloud_content_coverage') ? 'insufficient_evidence' : 'no_issue_detected', issues, unassessed, - scope: { pages: sorted(pageIndices), ...(sourcePageCount !== undefined ? { sourcePageCount } : {}) }, - summary: { ...(sourcePageCount !== undefined ? { sourcePages: sourcePageCount, sourcePagesEvidence: exactProbe !== undefined ? 'probe' as const : 'parser' as const } : {}), - parsedPages: [...pageIndices].filter(p => !failedPages.includes(p) && !missing.has(p)).length, - missingPages: sorted(missing).slice(0, 1000), missingPageCount, ...(missingPageCount > 1000 ? { missingPagesTruncated: true } : {}), failedPages, - searchableTextCharacters, textlessPages: sorted([...pageIndices].filter(p => !stats.pages.get(p))), - ocrSuspectedPages: sorted(issues.filter(i => i.code === 'ocr_required').flatMap(i => i.pages ?? [])), - visualRiskPages: sorted(issues.filter(i => i.impact === 'visual_loss').flatMap(i => i.pages ?? [])), controlCharacters: stats.controlCharacters }, - ...(ir.producer.engine === 'local' && reasons.length ? { recommendation: { engine: 'cloud' as const, paid: true as const, uploadScope: 'entire_document' as const, reasonCodes: reasons, - message: 'Consider cloud high-quality parsing (paid; uploads the entire document). This is a recommendation, not an upload. Recovery of content missing from the source is not guaranteed.' } } : {}) }; -} - -/** 解析器表示或报告嵌入对象的方式;命中任一种就说明它知道这些东西存在。 */ -const EMBEDDED_REPORTED = new Set(['embedded_object_unsupported', 'embedded_object_preview', 'embedded_object_hidden', 'graphic_frame_partial', 'chart_partial', 'smartart_partial', 'media_unsupported']); -const EMBEDDED_TYPES = new Set(['graphic_frame', 'chart', 'diagram', 'graphic', 'opaque', 'smartart', 'embedded_object']); -/** 只在 OOXML 上判:probe 的嵌入对象事实是按 OOXML 部件数得出的,别的格式没有可比口径。 */ -const EMBEDDED_FORMATS = new Set(['pptx', 'docx']); - -function exactValue(probe: DeckProbeReport | undefined, target: string): unknown { - const evidence = probe?.results[target]; - return evidence?.status === 'resolved' && evidence.confidence === 'exact' ? evidence.value : undefined; -} - -/** - * 源里有嵌入对象,产物里却既没表示、也没报告 —— 把这件事显式记成降级。 - * - * 这类缺失不会自己暴露:解析器没产出节点,也就没有任何 check,产物于是以 `pass` 交付。 - * 实测同一份 99 页文档,本地报 147 条 `embedded_object_unsupported` 而判 degraded,云端 - * 一条都没有却判 pass —— 两边差的不是质量,是「知不知道自己丢了东西」。 - * - * 判的是证据不是损失:probe 看到了,产物没提到,仅此而已。所以文案说的是「无法确认」, - * 而不是断言内容已丢。按引擎分叉会让这条永远只盯着云端,本地哪天回退同样必须报。 - */ -export function crossCheckEmbeddedObjects(candidate: ParseCandidate, probe?: DeckProbeReport): void { - if (!probe || !EMBEDDED_FORMATS.has(candidate.ir.format)) return; - const partCount = ['powerpoint.chart_part_count', 'powerpoint.smartart_data_part_count'] - .map((target) => exactValue(probe, target)) - .filter((value): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value > 0) - .reduce((sum, value) => sum + value, 0); - if (partCount === 0 && exactValue(probe, 'security.has_embedded_files') !== true) return; - const quality = candidate.ir.quality; - if (quality.checks.some((check) => EMBEDDED_REPORTED.has(check.code))) return; - // 以预览图出现的嵌入对象是图片节点,带着 `extensions.embeddedObject`,同样算表示了。 - const represented = candidate.ir.document.nodes.filter((node) => EMBEDDED_TYPES.has(node.type) || node.extensions?.embeddedObject).length; - // 数个数而不是「有没有」:一份文档里 1 张图表 + 1 个 SmartArt,产物只给出一个图表空壳, - // 布尔判据会就此收声,SmartArt 与嵌入对象的缺失就再没人提。部件数拿不到时退回布尔判据。 - if (partCount > 0 ? represented >= partCount : represented > 0) return; - quality.checks.push({ code: 'embedded_object_undetected', severity: 'warning', - message: `The source contains embedded objects${partCount ? ` (${partCount} chart/SmartArt parts, ${represented} represented)` : ''}, but this result neither represents nor reports them; their content cannot be confirmed present.`, - ...(partCount ? { detail: { parts: partCount, represented } } : {}) }); - quality.status = 'degraded'; - // 适配器把同一个 quality 对象同时挂在 candidate 与 ir 上;显式对齐,不依赖这个巧合。 - candidate.quality = quality; -} - -export function textOnPage(ir: DeckIR, page: number): number { return bodyTextStats(ir).pages.get(page) ?? 0; } -/** Check observed regressions and resolved defects, without a semantic model or provider score. */ -export function improvesCandidate(local: ParseCandidate, cloud: ParseCandidate, before: Assessment, after: Assessment): boolean { - if (local.ir.source.sha256 !== cloud.ir.source.sha256 || local.ir.format !== cloud.ir.format) return false; - const relevant = (a: Assessment) => a.issues.filter(i => i.severity !== 'info' && i.impact !== 'informational'); - const previous = relevant(before), next = relevant(after); - if (!previous.length || !after.summary?.searchableTextCharacters) return false; - if (next.some(i => !previous.some(old => old.code === i.code && old.severity === i.severity))) return false; - if (before.scope.pages.some(p => !after.scope.pages.includes(p))) return false; - const expected = before.summary?.sourcePages; - if (expected !== undefined && before.summary?.missingPageCount && after.scope.pages.filter(p => p >= 1 && p <= expected).length < expected && !next.some(i => i.code === 'source_page_count_mismatch')) return false; - if ((after.summary?.missingPageCount ?? 0) > (before.summary?.missingPageCount ?? 0) || (after.summary?.failedPages.length ?? 0) > (before.summary?.failedPages.length ?? 0)) return false; - const assets = new Set(cloud.assets.map(candidateAssetOutputPath)); - if (cloud.ir.document.assets.some(a => !assets.has(a.path))) return false; - const resolved = previous.filter(i => !next.some(n => n.code === i.code)); - if (!resolved.length) return false; - const stats = bodyTextStats(cloud.ir); - return resolved.every(i => !['ocr_required', 'page_parse_failed', 'missing_slide'].includes(i.code) || !!i.pages?.length && i.pages.every(p => (stats.pages.get(p) ?? 0) > 0)); -} diff --git a/src/shared/assessment-types.ts b/src/shared/assessment-types.ts deleted file mode 100644 index 327c4d5..0000000 --- a/src/shared/assessment-types.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { QualityCheck } from '../ir/schema.js'; -export interface QualityIssue extends QualityCheck { - impact: 'content_missing' | 'structure_loss' | 'visual_loss' | 'delivery' | 'informational'; - evidenceKind: 'parser' | 'heuristic' | 'source_comparison'; - neededCapability?: string; -} -export interface Assessment { - evaluatorVersion: 'rules.v1' | 'rules.v2'; - status: 'no_issue_detected' | 'needs_attention' | 'insufficient_evidence'; - issues: QualityIssue[]; - unassessed: string[]; - scope: { pages: number[]; sourcePageCount?: number }; - summary?: { - sourcePages?: number; sourcePagesEvidence?: 'probe' | 'parser'; parsedPages: number; - missingPages: number[]; missingPageCount: number; missingPagesTruncated?: boolean; failedPages: number[]; - searchableTextCharacters: number; textlessPages: number[]; ocrSuspectedPages: number[]; - visualRiskPages: number[]; controlCharacters: number; - }; - recommendation?: { engine: 'cloud'; paid: true; uploadScope: 'entire_document'; reasonCodes: string[]; message: string }; -} diff --git a/src/shared/policy-types.ts b/src/shared/policy-types.ts deleted file mode 100644 index 3fb587c..0000000 --- a/src/shared/policy-types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface ExecutionPolicy { - version: 1; engine: 'local' | 'auto' | 'cloud'; upload: 'deny' | 'allow'; acceptance: 'best-effort' | 'no-degraded'; - maxParseSubmissions: 1; source: Record; -} -export interface RouteDecision { - action: 'keep_local' | 'upgrade' | 'use_cloud'; reason: string; policy: ExecutionPolicy; - alternatives: Array<{ engine: 'cloud'; capability: string; verification: string; uploadScope: 'entire_document'; requires: string[] }>; - attempts: Array<{ engine: 'cloud'; status: string; taskId?: string }>; - next?: { argv: string[]; message?: string }; -} diff --git a/src/shared/preflight.ts b/src/shared/preflight.ts deleted file mode 100644 index 5027ffa..0000000 --- a/src/shared/preflight.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { - Evidence, - ProbeCallOptions, - ProbeReport, - ProbeResult, -} from '@deckflow/deckprobe'; -import { DeckOpsError } from '../errors/index.js'; - -export type PreflightMode = 'off' | 'validate' | 'strict'; - -/** Safe-by-default: definite input failures block upload; probe runtime failures only warn. */ -export const DEFAULT_PREFLIGHT_MODE: PreflightMode = 'validate'; - -export type PreflightFormat = 'pdf' | 'pptx' | 'docx' | 'keynote'; - -export type DeckProbeReport = ProbeReport; -export type DeckProbeResult = ProbeResult; - -export interface PreflightSummary { - profile: string; - encrypted?: boolean; - hasMacros?: boolean; - hasExternalRelationships?: boolean; - hasEmbeddedFiles?: boolean; - pageCount?: number; - slideCount?: number; - smartartDataPartCount?: number; - chartPartCount?: number; - slideSize?: Record; -} - -export interface PreflightOutcome { - report?: DeckProbeReport; - summary?: PreflightSummary; - warnings: string[]; -} - -export interface AssessPreflightOptions { - mode: Exclude; - format: PreflightFormat; - passwordProvided: boolean; -} - -export const PREFLIGHT_REQUEST_VERSION = 2; - -const REQUIRED_TARGETS = [ - 'document.format_profile', - 'document.extension_matches', - 'security.encrypted', -] as const; - -const SECURITY_FACT_TARGETS: Record = { - pdf: ['security.has_macros', 'security.has_external_relationships', 'security.has_embedded_files'], - pptx: ['security.has_macros', 'security.has_external_relationships', 'security.has_embedded_files'], - docx: ['security.has_macros', 'security.has_external_relationships', 'security.has_embedded_files'], - // Modern iWork can resolve macro capability, but has no OOXML relationship or embedded-file targets. - keynote: ['security.has_macros'], -}; - -const FORMAT_FACT_TARGETS: Record = { - pdf: ['pdf.page_count'], - pptx: ['powerpoint.slide_count', 'powerpoint.slide_size', 'powerpoint.chart_part_count', 'powerpoint.smartart_data_part_count', 'powerpoint.unique_image_asset_count'], - docx: ['word.page_count'], - keynote: ['keynote.slide_count'], -}; - -const EXPECTED_PROFILE: Record = { - pdf: 'pdf', - pptx: 'pptx', - docx: 'docx', - keynote: 'key', -}; - -/** - * A stable, explicit probe request. Do not replace this with @summary: missing - * optional metadata such as a title would turn a healthy file into `partial`. - */ -export function preflightProbeOptions(format: PreflightFormat): ProbeCallOptions { - return { - level: 'metadata', - minimumConfidence: 'high', - targets: [...REQUIRED_TARGETS, ...SECURITY_FACT_TARGETS[format], ...FORMAT_FACT_TARGETS[format]], - ...(format === 'pptx' ? { targetConfidence: { 'powerpoint.slide_count': 'exact' as const }, formatOptions: { 'powerpoint.slide_count_path': 'presentation-xml' } } : {}), - allowPiggyback: true, - telemetry: false, - budget: { - maxPhysicalBytes: 16 * 1024 * 1024, - maxExpandedBytes: 8 * 1024 * 1024, - maxArchiveEntries: 10_000, - timeoutMs: 1_000, - }, - }; -} - -export function assessPreflight(result: DeckProbeResult, options: AssessPreflightOptions): PreflightOutcome { - if (result.status === 'error') { - return assessProbeError(result, options.mode); - } - - const expected = EXPECTED_PROFILE[options.format]; - if (result.driver.profile !== expected) { - throw DeckOpsError.input( - `The file content is ${result.driver.profile}, but its name selects ${expected}.`, - { hint: 'Use the correct filename extension and try again.' } - ); - } - - const extensionMatches = valueOf(result, 'document.extension_matches'); - if (extensionMatches === false) { - throw DeckOpsError.input('The filename extension does not match the document content.', { - hint: 'Use the correct filename extension and try again.', - }); - } - - const warnings: string[] = []; - const unresolved = REQUIRED_TARGETS.filter((target) => !usable(result.results[target])); - if (unresolved.length > 0) { - const message = `DeckProbe could not resolve required preflight facts: ${unresolved.join(', ')}.`; - if (options.mode === 'strict') { - throw DeckOpsError.input(message, { hint: 'Retry with preflight validate/off, or inspect the file with DeckProbe.' }); - } - warnings.push(`${message} Parsing will continue with preflight uncertainty.`); - } - - const encrypted = booleanValue(result, 'security.encrypted'); - if (encrypted === true) { - if (options.format === 'pdf' && options.passwordProvided) { - warnings.push('DeckProbe found an encrypted PDF; continuing with the provided password.'); - } else { - throw DeckOpsError.input('The document is encrypted or password-protected.', { - hint: options.format === 'pdf' - ? 'Provide the PDF password, or save an unencrypted copy.' - : 'Save an unencrypted copy before parsing.', - }); - } - } - - warnIfTrue(result, 'security.has_macros', 'DeckProbe found macros or macro-capable content.', warnings); - warnIfTrue(result, 'security.has_external_relationships', 'DeckProbe found external relationships.', warnings); - warnIfTrue(result, 'security.has_embedded_files', 'DeckProbe found embedded files or objects.', warnings); - - return { report: result, summary: summarize(result), warnings }; -} - -export function summarize(report: DeckProbeReport): PreflightSummary { - const encrypted = booleanValue(report, 'security.encrypted'); - const hasMacros = booleanValue(report, 'security.has_macros'); - const hasExternalRelationships = booleanValue(report, 'security.has_external_relationships'); - const hasEmbeddedFiles = booleanValue(report, 'security.has_embedded_files'); - const pageCount = numberValue(report, report.driver.profile === 'pdf' ? 'pdf.page_count' : 'word.page_count'); - const slideCount = numberValue(report, report.driver.profile === 'key' ? 'keynote.slide_count' : 'powerpoint.slide_count'); - const slideSize = objectValue(report, 'powerpoint.slide_size'); - return { - ...(numberValue(report, 'powerpoint.smartart_data_part_count') !== undefined ? { smartartDataPartCount: numberValue(report, 'powerpoint.smartart_data_part_count')! } : {}), - ...(numberValue(report, 'powerpoint.chart_part_count') !== undefined ? { chartPartCount: numberValue(report, 'powerpoint.chart_part_count')! } : {}), - profile: report.driver.profile, - ...(encrypted !== undefined ? { encrypted } : {}), - ...(hasMacros !== undefined ? { hasMacros } : {}), - ...(hasExternalRelationships !== undefined ? { hasExternalRelationships } : {}), - ...(hasEmbeddedFiles !== undefined ? { hasEmbeddedFiles } : {}), - ...(pageCount !== undefined ? { pageCount } : {}), - ...(slideCount !== undefined ? { slideCount } : {}), - ...(slideSize !== undefined ? { slideSize } : {}), - }; -} - -function assessProbeError( - result: Extract, - mode: Exclude -): PreflightOutcome { - const { code, message } = result.error; - switch (code) { - case 'MALFORMED_INPUT': - throw DeckOpsError.input(`DeckProbe rejected the document container: ${message}`, { - hint: 'Check that the file is complete and its extension matches its content.', - }); - case 'UNSUPPORTED_FORMAT': - throw DeckOpsError.unsupported(`DeckProbe cannot inspect this document generation: ${message}`); - case 'SOURCE_IO': - throw DeckOpsError.input(`DeckProbe could not read the input: ${message}`); - case 'INVALID_REQUEST': - case 'UNSUPPORTED_TARGET': - throw DeckOpsError.backend(`DeckOps sent an invalid DeckProbe request: ${message}`); - default: { - const explanation = `DeckProbe preflight did not complete (${code}): ${message}`; - if (mode === 'strict') { - throw DeckOpsError.input(explanation, { - hint: 'Retry with preflight validate/off, or inspect the file with DeckProbe.', - }); - } - return { warnings: [`${explanation}. Parsing will continue with preflight uncertainty.`] }; - } - } -} - -function usable(evidence: Evidence | undefined): boolean { - return evidence !== undefined && - (evidence.status === 'resolved' || evidence.status === 'estimated') && - ['high', 'exact'].includes(evidence.confidence); -} - -function valueOf(report: DeckProbeReport, target: string): unknown { - const evidence = report.results[target]; - return usable(evidence) ? evidence?.value : undefined; -} - -function booleanValue(report: DeckProbeReport, target: string): boolean | undefined { - const value = valueOf(report, target); - return typeof value === 'boolean' ? value : undefined; -} - -function numberValue(report: DeckProbeReport, target: string): number | undefined { - const value = valueOf(report, target); - return typeof value === 'number' && Number.isFinite(value) ? value : undefined; -} - -function objectValue(report: DeckProbeReport, target: string): Record | undefined { - const value = valueOf(report, target); - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? value as Record - : undefined; -} - -function warnIfTrue(report: DeckProbeReport, target: string, message: string, warnings: string[]): void { - if (booleanValue(report, target) === true) warnings.push(message); -} diff --git a/src/shared/text-quality.ts b/src/shared/text-quality.ts deleted file mode 100644 index 498ed4a..0000000 --- a/src/shared/text-quality.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { DeckIR, DeckIrNode } from '../ir/schema.js'; - -/** C0 controls other than tab/CR/LF, plus DEL. Do not strip Unicode formatting. */ -export function countControlCharacters(text: string): number { return text.match(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g)?.length ?? 0; } -export function cleanControlCharacters(text: string): string { return text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '\uFFFD'); } -const NON_BODY = new Set(['image', 'figure', 'picture', 'chart', 'annotation', 'comment', 'header', 'footer', 'footnote', 'endnote', 'notes', 'table', 'table_row']); -export function bodyTextStats(ir: DeckIR): { text: string; pages: Map; controlCharacters: number; characters: number } { - const byId = new Map(ir.document.nodes.map(n => [n.id, n])); - const textOf = (n: DeckIrNode): string => n.runs?.length ? n.runs.map(r => r.text).join('') : n.text ?? ''; - const accepted = (n: DeckIrNode): boolean => !NON_BODY.has(n.type) && !/\/(?:header|footer|comments|footnotes|endnotes|notesSlides)[^/]*[/.]/i.test(n.sourceRef.part ?? ''); - const hidden = new Map(); - // Iterative memoization keeps deeply nested tables linear and avoids double-counting cell text. - const suppressed = (n: DeckIrNode): boolean => { - const chain: string[] = []; const seen = new Set(); let parent = n.parentId; let result = false; - while (parent) { - if (hidden.has(parent)) { result = hidden.get(parent)!; break; } - if (seen.has(parent)) break; - seen.add(parent); chain.push(parent); - const ancestor = byId.get(parent); if (!ancestor) break; - if (!accepted(ancestor) && !['table', 'table_row'].includes(ancestor.type) || ancestor.type === 'table_cell' && !!textOf(ancestor).trim()) { result = true; break; } - parent = ancestor.parentId; - } - for (const id of chain) hidden.set(id, result); - return result; - }; - const parts: string[] = []; const pages = new Map(); let controlCharacters = 0; let characters = 0; - for (const node of ir.document.nodes) { - if (!accepted(node) || suppressed(node)) continue; - const raw = textOf(node); controlCharacters += countControlCharacters(raw); - const text = cleanControlCharacters(raw).replace(/\uFFFD/g, '').trim(); - parts.push(raw); characters += text.length; - if (node.page !== undefined) pages.set(node.page, (pages.get(node.page) ?? 0) + text.length); - } - return { text: parts.join('\n'), pages, controlCharacters, characters }; -} diff --git a/src/types-legacy.ts b/src/types-legacy.ts deleted file mode 100644 index a987139..0000000 --- a/src/types-legacy.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** Public artifact, option and machine-readable envelope contracts. */ - -import type { IrFormat } from './cloud/parse/types.js'; -export type { ConvertImage } from './cloud/parse/types.js'; -import type { DeckIR, DocumentFormat, QualityReport } from './ir/schema.js'; -import type { LocalLimits } from './local/limits.js'; -import type { PreflightMode, PreflightSummary } from './shared/preflight.js'; - -export type ParseTaskType = 'pdf.pdfParse' | 'pptx.parse' | 'docx.parseTextAndImage' | 'keynote.parseTextAndImage'; -export type EngineMode = 'local' | 'cloud' | 'auto'; -export type OutputEngine = 'local' | 'cloud' | 'artifact-cache'; - -export interface ManifestView { - engine: 'local' | 'cloud'; - rendererVersion: string; - taskId?: string; - params: Record; - files: string[]; - createdAt: string; -} - -export interface ManifestAsset { key: string; hash: string; bytes: number; mediaType?: string } - -export interface ManifestInspection { - file: string; schemaVersion: 2; toolVersion: string; requestVersion: number; - status: 'ok' | 'partial'; summary: PreflightSummary; createdAt: string; -} - -export interface ManifestV1View { - taskId: string; params: Record; files: string[]; createdAt: string; -} - -export interface ManifestV1 { - manifestVersion: 1; - source: { sha256?: string; name: string; bytes?: number }; - parse: { - taskId: string; type: ParseTaskType | 'html.getByURL'; irKey: string; irSchemaVersion: string; - params: Record; createdAt: string; - }; - inspection?: ManifestInspection; - views: Partial>; - assets: Record; - producer: { deckparse: string; sdk: string }; -} - -export interface ManifestV2 { - manifestVersion: 2; - source: { sha256: string; name: string; bytes: number }; - parse: { - engine: 'local' | 'cloud'; format: DocumentFormat; schemaVersion: 'deckir.v1'; - parser: { name: string; version: string }; params: Record; createdAt: string; - remote: { taskId: string; irKey: string; expiresAt?: string | undefined } | null; - }; - inspection?: ManifestInspection; - quality: QualityReport; - views: Partial>; - assets: Record; - producer: { deckparse: string; sdk?: string }; -} - -export type Manifest = ManifestV1 | ManifestV2; - -export interface ParseFlags { - profile?: 'fast' | 'balanced' | 'quality'; - password?: string; - includeImages?: boolean; - pageFurniture?: 'off' | 'drop' | 'extract'; - overlaidText?: 'auto' | 'keep' | 'drop'; - trackedChanges?: 'final' | 'original' | 'all'; - stayImageAreaRate?: number; - mode?: 'source' | 'runtime'; -} - -export interface ConvertFlags { - to?: 'markdown'; anchors?: boolean; splitPages?: boolean; strict?: boolean; keepRemoteImages?: boolean; -} - -export interface CommonFlags { - signal?: AbortSignal; - policySource?: Record; - cloudLimits?: { maxSourceBytes?: number; maxSourcePages?: number; maxCost?: { amount: string; currency: string } }; - spaceId?: string; timeout?: number; force?: boolean; - engine?: EngineMode; allowUpload?: boolean; failOnDegraded?: boolean; - limits?: Partial; -} - -export interface OutputFile { file: string; bytes: number } - -export interface ParseEnvelope { - assessment?: import('./shared/assessment-types.js').Assessment; - decision?: import('./shared/policy-types.js').RouteDecision; - ok: true; op: 'parse'; input: string; engine: OutputEngine; type: ParseTaskType | 'html.getByURL'; - format: DocumentFormat; quality: QualityReport; taskId: string | null; reusedParse: boolean; - irKey?: string; irSchemaVersion: 'deckir.v1'; artifact: string; inspection?: PreflightSummary; - outputs: OutputFile[]; warnings: string[]; durationMs: number; -} - -export interface ConvertEnvelope { - assessment?: import('./shared/assessment-types.js').Assessment; - decision?: import('./shared/policy-types.js').RouteDecision; - content?: string; - ok: true; op: 'convert'; input: string; to: 'markdown'; engine: OutputEngine; format: IrFormat; - taskId: string | null; parseTaskId?: string; inspection?: PreflightSummary; reusedParse: boolean; - quality?: QualityReport; outputs: OutputFile[]; warnings: string[]; durationMs: number; -} - -export interface ErrorEnvelope { - ok: false; op: 'parse' | 'convert' | 'install' | 'read'; error: { code: string; message: string; hint?: string; taskId?: string }; -} - -export type Envelope = ParseEnvelope | ConvertEnvelope | ErrorEnvelope; - -export type { DeckIR, DocumentFormat, IrFormat, LocalLimits, PreflightMode, PreflightSummary, QualityReport }; diff --git a/src/version.ts b/src/version.ts index d29ec3e..76999bd 100644 --- a/src/version.ts +++ b/src/version.ts @@ -3,5 +3,3 @@ import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); export const VERSION: string = (require('../package.json') as { version: string }).version; -/** Exact dependency pin, read without loading PDF.js. */ -export const PDF_PARSER_VERSION: string = (require('../package.json') as { dependencies: Record }).dependencies['@deckflow/pdf-lite-parse']!; diff --git a/src/views/markdown.ts b/src/views/markdown.ts deleted file mode 100644 index 8d1c79f..0000000 --- a/src/views/markdown.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { ListKind, TextParagraph } from '../ir/pptx-lists.js'; -import type { DeckIR, DeckIrNode, DeckIrRun } from '../ir/schema.js'; -import { cleanControlCharacters, countControlCharacters } from '../shared/text-quality.js'; - -export const MARKDOWN_RENDERER_VERSION = '1.4.0'; - -export interface RenderedMarkdown { - markdown: string; - pages?: string[]; - warnings: string[]; -} - -export function renderMarkdown(ir: DeckIR, options: { anchors?: boolean | undefined; splitPages?: boolean | undefined; assetPrefix?: string | undefined } = {}): RenderedMarkdown { - const byId = new Map(ir.document.nodes.map((node) => [node.id, node])); - const roots = ir.document.nodes.filter((node) => node.parentId === null).sort((a, b) => a.order - b.order); - const renderSet = (nodes: DeckIrNode[]): string => normalize(nodes.map((node) => renderNode(node, byId, options)).filter(Boolean).join('\n\n')); - const markdown = renderSet(roots); - const pages = options.splitPages && ir.document.pages.length > 0 - ? ir.document.pages.map((page) => renderSet(page.nodeIds.map((id) => byId.get(id)).filter((node): node is DeckIrNode => Boolean(node) && node!.parentId === null))) - : undefined; - const controlCharacters = countControlCharacters(markdown); - return { markdown: cleanControlCharacters(markdown), ...(pages ? { pages: pages.map(cleanControlCharacters) } : {}), warnings: controlCharacters ? [`Replaced ${controlCharacters} unsupported control characters with U+FFFD in Markdown; original IR text is preserved.`] : [] }; -} - -function renderNode(node: DeckIrNode, byId: Map, options: { anchors?: boolean | undefined; assetPrefix?: string | undefined }): string { - const anchor = options.anchors ? `\n` : ''; - const children = node.children.map((id) => byId.get(id)).filter((item): item is DeckIrNode => Boolean(item)).sort((a, b) => a.order - b.order); - const text = node.runs?.length ? renderRuns(node.runs) : escapeMarkdown(node.text ?? ''); - let own = ''; - if (node.type === 'heading') own = renderHeading(node, text); - else if (node.type === 'list_item') own = `${listMarker(node)} ${indentLines(text, 2)}`; - else if (node.type === 'blockquote') own = text.split('\n').map((line) => `> ${line}`).join('\n'); - else if (node.type === 'code' || node.type === 'code_block') own = fencedCode(node.text ?? '', String(node.extensions?.language ?? '')); - else if (node.type === 'image' || node.type === 'figure' || node.type === 'picture' || node.type === 'chart') own = renderImage(node, options.assetPrefix ?? ''); - else if (node.type === 'formula') own = node.extensions?.formula && typeof node.extensions.formula === 'object' && 'latex' in node.extensions.formula - ? `$$\n${String((node.extensions.formula as { latex?: unknown }).latex ?? node.text ?? '')}\n$$` : text; - else if (node.type === 'table') own = renderTable(node, byId); - else if (node.type === 'speaker_note') own = renderSpeakerNote(node, text); - // 页眉、页脚、页码是版式家具,文字留在 IR 里,不进阅读视图(pdf-parse 的 Markdown 同样只以注释保留)。 - else if (!['table_row', 'table_cell', 'group', 'section', 'article', 'main', 'header', 'footer', 'page_number', 'nav', 'aside', 'list'].includes(node.type)) { - const paragraphs = paragraphsOf(node); - own = paragraphs ? renderParagraphs(node, paragraphs) : text; - } - const childBody = node.type === 'table' ? '' : children.map((child) => renderNode(child, byId, options)).filter(Boolean).join('\n\n'); - return `${anchor}${[own, childBody].filter(Boolean).join('\n\n')}`.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, ''); -} - -function renderTable(table: DeckIrNode, byId: Map): string { - const rows = table.children.map((id) => byId.get(id)).filter((node): node is DeckIrNode => node?.type === 'table_row'); - const declaredColumns = Number((table.extensions?.table as { cols?: unknown } | undefined)?.cols ?? table.extensions?.columns ?? 0); - const matrix = rows.map((row) => { - const cells = row.children.map((id) => byId.get(id)).filter((cell): cell is DeckIrNode => Boolean(cell)); - const width = Math.max(declaredColumns, ...cells.map((cell, index) => Number(cell.extensions?.column ?? index) + Number(cell.extensions?.gridSpan ?? 1)), 0); - const values = Array(width).fill(''); - for (const [index, cell] of cells.entries()) { - const content = cell.runs?.length ? renderRuns(cell.runs).replace(/\|/g, '\\|').replace(/\r?\n/g, '
') : escapeTable(cell.text ?? childText(cell, byId)); - values[Number(cell.extensions?.column ?? index)] = content; - } - return values; - }); - if (!matrix.length) return ''; - const columns = Math.max(...matrix.map((row) => row.length)); - const padded = matrix.map((row) => [...row, ...Array(Math.max(0, columns - row.length)).fill('')]); - const header = padded[0] ?? []; - const separator = Array(columns).fill('---'); - const body = [header, separator, ...padded.slice(1)].map((row) => `| ${row.join(' | ')} |`).join('\n'); - const caption = (table.extensions?.table as { caption?: unknown } | undefined)?.caption; - return typeof caption === 'string' && caption.trim() ? `${escapeMarkdown(caption.trim())}\n\n${body}` : body; -} - -function childText(node: DeckIrNode, byId: Map): string { - return node.children.map((id) => byId.get(id)).filter((item): item is DeckIrNode => Boolean(item)).map((item) => item.text ?? childText(item, byId)).filter(Boolean).join(' '); -} - -function renderImage(node: DeckIrNode, prefix: string): string { - const alt = escapeMarkdown(String(node.extensions?.alt ?? node.text ?? '')); - const assetPath = typeof node.extensions?.assetPath === 'string' ? node.extensions.assetPath.replace(/^assets\//, '') : undefined; - const external = typeof node.extensions?.externalUrl === 'string' ? node.extensions.externalUrl : undefined; - const target = assetPath ? `${prefix}${assetPath}` : external; - return target ? `![${alt}](${escapeLink(target)})` : alt ? `*${alt}*` : ''; -} - -/** 标题只占一行:多段标题的第二段原先会跑出 `#` 行,成了一段正文。没有字的标题不输出。 */ -function renderHeading(node: DeckIrNode, text: string): string { - const line = text.split('\n').map((part) => part.trim()).filter(Boolean).join(' '); - return line ? `${'#'.repeat(clamp(Number(node.extensions?.level ?? 2), 1, 6))} ${line}` : ''; -} - -/** - * 带段落结构的文本框(pptx 解析器写在 `extensions.paragraphs`):每个普通段落自成一段,列表项 - * 按大纲级别嵌套,段落与列表之间都空一行;段内换行(``)仍是换行。级别跳级(0 直接到 2) - * 只缩进一层——Markdown 的嵌套按父项内容列对齐,多缩进一层就成了代码块。 - */ -function renderParagraphs(node: DeckIrNode, paragraphs: readonly TextParagraph[]): string { - const blocks: string[] = []; - let items: string[] = []; - const open: Array<{ level: number; kind: ListKind; indent: number; width: number; count: number }> = []; - const flushItems = (): void => { if (items.length) blocks.push(items.join('\n')); items = []; open.length = 0; }; - for (const paragraph of paragraphs) { - const body = node.runs?.length - ? renderRuns(sliceRuns(node.runs, paragraph.start, paragraph.end)) - : escapeMarkdown((node.text ?? '').slice(paragraph.start, paragraph.end)); - // 行首空白要去掉:列表标记后跟五个以上空格,CommonMark 就把这一项读成代码块。 - const lines = body.split('\n').map((line) => line.trim()).filter(Boolean); - if (!lines.length) continue; - if (!paragraph.list) { flushItems(); blocks.push(lines.join('\n')); continue; } - while (open.length && open.at(-1)!.level > paragraph.level) open.pop(); - if (open.at(-1)?.level === paragraph.level && open.at(-1)!.kind !== paragraph.list) open.pop(); - let list = open.at(-1); - if (!list || list.level < paragraph.level) { - list = { level: paragraph.level, kind: paragraph.list, indent: list ? list.indent + list.width : 0, width: 0, count: 0 }; - open.push(list); - } - list.count += 1; - const marker = list.kind === 'number' ? `${list.count}.` : '-'; - list.width = marker.length + 1; - const pad = ' '.repeat(list.indent); - items.push(`${pad}${marker} ${lines.join(`\n${pad}${' '.repeat(list.width)}`)}`); - } - flushItems(); - return blocks.join('\n\n'); -} - -/** - * 讲者备注:引用块,并标明是备注。原先备注作为普通段落跟在幻灯片内容后面,读的人分不清哪句 - * 在幻灯片上、哪句是讲者要说的。 - */ -function renderSpeakerNote(node: DeckIrNode, text: string): string { - const paragraphs = paragraphsOf(node); - const body = (paragraphs ? renderParagraphs(node, paragraphs) : text).trim(); - if (!body) return ''; - // 以列表开头的备注,标签单独成行,免得「- 」接在标签后面不再是列表。 - const labelled = /^(?:[-*+]|\d+\.)\s/.test(body) ? `**Speaker notes:**\n\n${body}` : `**Speaker notes:** ${body}`; - return labelled.split('\n').map((line) => (line ? `> ${line}` : '>')).join('\n'); -} - -/** 读 IR 里的段落结构;缺字段或对不上就当没有,按纯文本渲染。 */ -function paragraphsOf(node: DeckIrNode): TextParagraph[] | undefined { - const raw = node.extensions?.paragraphs; - if (!Array.isArray(raw)) return undefined; - const paragraphs = raw.flatMap((item): TextParagraph[] => { - if (!item || typeof item !== 'object') return []; - const { start, end, level, list } = item as Record; - if (typeof start !== 'number' || typeof end !== 'number' || typeof level !== 'number') return []; - return [{ start, end, level, ...(list === 'bullet' || list === 'number' ? { list } : {}) }]; - }); - return paragraphs.length > 0 && paragraphs.length === raw.length ? paragraphs : undefined; -} - -function sliceRuns(runs: readonly DeckIrRun[], start: number, end: number): DeckIrRun[] { - const sliced: DeckIrRun[] = []; - let offset = 0; - for (const run of runs) { - const from = Math.max(start, offset); - const to = Math.min(end, offset + run.text.length); - if (from < to) sliced.push({ ...run, text: run.text.slice(from - offset, to - offset) }); - offset += run.text.length; - } - return sliced; -} - -interface InlinePiece { text: string; bold: boolean; italic: boolean; strike: boolean; code: boolean; href: string | undefined } - -/** 由外到内的行内格式层:链接包着粗体,粗体包着斜体,斜体包着删除线。 */ -const INLINE_LAYERS = ['href', 'bold', 'italic', 'strike'] as const; -const EMPHASIS_MARKERS = { bold: '**', italic: '*', strike: '~~' } as const; - -/** - * 文本运行 → 行内 Markdown。 - * - * 原先逐个运行各包一层标记:同一个粗体短语被 PowerPoint 切成几个运行(实测「50」「% 」「的」 - * 「 」四个),输出 `**50****% ****的**** **`,四个星号挨在一起,渲染出来满屏星号。现在 - * 同样式的相邻运行先并起来,按格式层嵌套;每段强调两头的空白挪到标记外,只有空白的不加 - * 标记;标记按行闭合,不跨换行。 - */ -function renderRuns(runs: DeckIrRun[]): string { - const pieces: InlinePiece[] = []; - for (const run of runs) { - const piece: InlinePiece = { text: run.text, bold: Boolean(run.bold), italic: Boolean(run.italic), strike: Boolean(run.strike), - code: Boolean(run.code), href: run.href || undefined }; - const last = pieces.at(-1); - if (last && sameInlineStyle(last, piece)) last.text += piece.text; - else pieces.push(piece); - } - const lines: InlinePiece[][] = [[]]; - for (const piece of pieces) { - piece.text.split('\n').forEach((part, index) => { - if (index > 0) lines.push([]); - if (part) lines.at(-1)!.push({ ...piece, text: part }); - }); - } - return lines.map((line) => renderInline(line, 0, '', '')).join('\n'); -} - -function sameInlineStyle(left: InlinePiece, right: InlinePiece): boolean { - return left.bold === right.bold && left.italic === right.italic && left.strike === right.strike && left.code === right.code && left.href === right.href; -} - -function renderInline(pieces: readonly InlinePiece[], depth: number, before: string, after: string): string { - const layer = INLINE_LAYERS[depth]; - if (!layer) return pieces.map((piece) => piece.code ? inlineCode(piece.text) : escapeMarkdown(piece.text)).join(''); - const groups: InlinePiece[][] = []; - for (const piece of pieces) { - const group = groups.at(-1); - if (group && group[0]![layer] === piece[layer]) group.push(piece); - else groups.push([piece]); - } - let out = ''; - groups.forEach((group, index) => { - const previous = out ? [...out.slice(-2)].at(-1)! : before; - const next = index + 1 < groups.length ? firstCharacter(groups[index + 1]!) : after; - const value = group[0]![layer]; - if (!value) { out += renderInline(group, depth + 1, previous, next); return; } - if (layer === 'href') { out += `[${renderInline(group, depth + 1, '[', ']')}](${escapeLink(String(value))})`; return; } - const marker = EMPHASIS_MARKERS[layer]; - const { lead, core, trail } = peelForEmphasis(group, previous, next); - if (!core.length) { out += renderInline(group, depth + 1, previous, next); return; } - out += renderInline(lead, depth + 1, previous, marker[0]!) - + marker + renderInline(core, depth + 1, marker[0]!, marker[0]!) + marker - + renderInline(trail, depth + 1, marker[0]!, next); - }); - return out; -} - -/** - * 把强调两头放不进标记的字符挪到外面。CommonMark 要求开标记后、闭标记前不是空白;紧挨着 - * 文字的标记内侧若是标点也不成立——中文讲义里「**粗体:**正文」就这样整段露出星号。空白 - * 一律挪出;标点只在外侧紧挨文字时挪出,那个标点因此不再加粗,字一个不少。代码片段不拆。 - */ -function peelForEmphasis(pieces: readonly InlinePiece[], before: string, after: string): { lead: InlinePiece[]; core: InlinePiece[]; trail: InlinePiece[] } { - const characters = pieces.flatMap((piece) => [...piece.text].map((character) => ({ character, piece }))); - let start = 0; - let previous = before; - while (start < characters.length && movable(characters[start]!, previous)) previous = characters[start++]!.character; - let end = characters.length; - let next = after; - while (end > start && movable(characters[end - 1]!, next)) next = characters[--end]!.character; - const regroup = (slice: typeof characters): InlinePiece[] => { - const grouped: InlinePiece[] = []; - let owner: InlinePiece | undefined; - for (const { character, piece } of slice) { - if (piece === owner) grouped.at(-1)!.text += character; - else { grouped.push({ ...piece, text: character }); owner = piece; } - } - return grouped; - }; - return { lead: regroup(characters.slice(0, start)), core: regroup(characters.slice(start, end)), trail: regroup(characters.slice(end)) }; -} - -function movable(item: { character: string; piece: InlinePiece }, outside: string): boolean { - if (item.piece.code) return false; - return isWhitespace(item.character) || (isPunctuation(item.character) && isWordCharacter(outside)); -} - -function firstCharacter(pieces: readonly InlinePiece[]): string { return [...(pieces[0]?.text ?? '')][0] ?? ''; } -function isWhitespace(character: string): boolean { return /\s/u.test(character); } -function isPunctuation(character: string): boolean { return /[\p{P}\p{S}]/u.test(character); } -/** 行首行尾(空字符串)按空白算,与 CommonMark 一致。 */ -function isWordCharacter(character: string): boolean { return character !== '' && !isWhitespace(character) && !isPunctuation(character); } - -function listMarker(node: DeckIrNode): string { - const numbering = node.extensions?.numbering as { format?: string } | undefined; - return numbering?.format && numbering.format !== 'bullet' ? '1.' : '-'; -} - -function escapeMarkdown(value: string): string { - return value.replace(/&(?=(?:#\d+|#x[\da-f]+|[a-z][\da-z]*);)/gi, '&').replace(//g, '>').replace(/([\\`*_[\]{}])/g, '\\$1'); -} - -function escapeTable(value: string): string { return escapeMarkdown(value).replace(/\|/g, '\\|').replace(/\r?\n/g, '
'); } -function escapeLink(value: string): string { return value.replace(/[()\s]/g, (character) => encodeURIComponent(character)); } -function escapeComment(value: string): string { return value.replace(/--/g, '—'); } -function indentLines(value: string, spaces: number): string { return value.replace(/\n/g, `\n${' '.repeat(spaces)}`); } -function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, Number.isFinite(value) ? value : min)); } -function normalize(value: string): string { return `${value.replace(/^[ \t\r\n]+|[ \t\r\n]+$/g, '')}\n`; } - -function inlineCode(text: string): string { - const delimiter = '`'.repeat((text.match(/`+/g) ?? []).reduce((max, s) => Math.max(max, s.length), 0) + 1); - const padding = /^`|`$/.test(text) || /^ .* $/s.test(text) && /[^ ]/.test(text) ? ' ' : ''; - return `${delimiter}${padding}${text}${padding}${delimiter}`; -} -function fencedCode(text: string, language: string): string { - const delimiter = '`'.repeat((text.match(/`+/g) ?? []).reduce((max, s) => Math.max(max, s.length), 2) + 1); - return `${delimiter}${language.replace(/[`\r\n]/g, '')}\n${text}\n${delimiter}`; -} diff --git a/tests/browser/fake-cloud.mjs b/tests/browser/fake-cloud.mjs index 949c846..180586c 100644 --- a/tests/browser/fake-cloud.mjs +++ b/tests/browser/fake-cloud.mjs @@ -47,7 +47,7 @@ export async function createFakeCloud({ staticRoot, port = 0 } = {}) { const file = pathname === '/' ? 'tests/browser/smoke.html' : pathname === '/smoke.js' ? 'tests/browser/smoke.js' : pathname === '/tests/generated/test.pdf' ? pathname.slice(1) - : /^\/dist\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+\.(?:js|wasm)$/.test(pathname) ? pathname.slice(1) : undefined; + : /^\/dist\/(?:[a-zA-Z0-9_-]+\/)*[a-zA-Z0-9_-]+\.js$/.test(pathname) ? pathname.slice(1) : undefined; if (!file) return false; try { const body = await fs.readFile(path.join(staticRoot, file)); diff --git a/tests/browser/smoke.js b/tests/browser/smoke.js index e48c2e8..e27211d 100644 --- a/tests/browser/smoke.js +++ b/tests/browser/smoke.js @@ -25,15 +25,14 @@ button.addEventListener('click', async () => { assert(typeof window.process === 'undefined' && typeof window.Buffer === 'undefined', 'Unexpected Node polyfills'); assert(typeof createClient === 'function', 'Missing browser SDK export'); }], - ['DeckProbe Worker validates a real PDF before upload', async () => { + ['A real PDF File parses to in-memory IR with task progress', async () => { const bytes = await fetch('/tests/generated/test.pdf').then((response) => response.blob()); const events = []; - const doc = await client('preflight').parse(new File([bytes], 'test.pdf'), { + const doc = await client('pdf').parse(new File([bytes], 'test.pdf'), { onProgress: (event) => events.push(event), }); - const report = await doc.inspectionReport(); - assert(doc.inspection?.profile === 'pdf' && report?.schema_version === 2, 'Missing DeckProbe facts'); - assert(events.some((event) => event.phase === 'preflight' && event.status === 'completed'), 'Missing preflight progress'); + assert(doc.type === 'pdf.pdfParse' && (await doc.ir()).document, 'Missing in-memory IR'); + assert(events.some((event) => event.phase === 'parse' && event.status === 'completed'), 'Missing parse progress'); }], ['Same-origin proxy accepts a relative API root without a browser token', async () => { await createClient({ apiBase: `/api/proxy_${runNumber}/v1` }).parse({ url: 'https://example.com/' }); @@ -42,7 +41,7 @@ button.addEventListener('click', async () => { }], ['File → IR → repeated conversion, pages and image descriptors', async () => { const api = client('file'); - const doc = await api.parse(new File(['PPTX local fixture'], 'browser.pptx'), { preflight: 'off' }); + const doc = await api.parse(new File(['PPTX local fixture'], 'browser.pptx')); assert(doc.irKey && (await doc.ir()).document, 'Missing in-memory IR'); const view = await doc.convert({ splitPages: true, strict: true }); assert(view.reusedParse === true && view.markdownPages.length === 2 && view.images.length === 1, 'Missing view metadata'); @@ -52,8 +51,8 @@ button.addEventListener('click', async () => { assert(recorded.tasks[1].params.markdownStrict === true && recorded.tasks[1].params.markdownPages === true, 'View options missing'); }], ['Named Blob and typed-array input', async () => { - await client('blob').parse({ file: new Blob(['keynote']), name: 'slides.key' }, { stayImageAreaRate: 0.2, preflight: 'off' }); - await client('binary').parse({ file: new Uint8Array([1, 2, 3]), name: 'notes.docx' }, { preflight: 'off' }); + await client('blob').parse({ file: new Blob(['keynote']), name: 'slides.key' }, { stayImageAreaRate: 0.2 }); + await client('binary').parse({ file: new Uint8Array([1, 2, 3]), name: 'notes.docx' }); const recorded = await state('blob'); const submission = recorded.requests.find((req) => req.path === '/tools/tasks'); assert(submission.body.files[0].name === 'slides.key' && recorded.tasks[0].params.stayImageAreaRate === 0.2, 'Blob filename/options lost'); @@ -86,13 +85,13 @@ button.addEventListener('click', async () => { assert(recorded.tasks.length === 1, 'Fallback resubmitted the task'); }], ['≥4 MiB Blob uses signed pre-upload, not inline task body', async () => { - await client('large').parse({ file: new Blob([new Uint8Array(4 * 1024 * 1024)]), name: 'large.pdf' }, { preflight: 'off' }); + await client('large').parse({ file: new Blob([new Uint8Array(4 * 1024 * 1024)]), name: 'large.pdf' }); const recorded = await state('large'); assert(recorded.uploads.length === 1 && recorded.tasks[0].fileIds[0] === 'file-1', 'Large file was submitted inline'); assert(recorded.requests.find((req) => req.path === '/storage/file-1').bytes === 4 * 1024 * 1024, 'Upload bytes differ'); }], ['Multipart pre-upload reads exposed ETag headers', async () => { - await client('multipart').parse({ file: new Blob([new Uint8Array(4 * 1024 * 1024 + 1)]), name: 'large.pptx' }, { preflight: 'off' }); + await client('multipart').parse({ file: new Blob([new Uint8Array(4 * 1024 * 1024 + 1)]), name: 'large.pptx' }); const recorded = await state('multipart'); assert(recorded.requests.filter((req) => req.path.includes('/part-')).length === 3, 'Missing multipart requests'); assert(recorded.requests.find((req) => req.path.endsWith('/complete')).body.includes('fixture-etag'), 'ETag missing'); diff --git a/tests/cloud/sdk.test.ts b/tests/cloud/sdk.test.ts index 904e000..30440c0 100644 --- a/tests/cloud/sdk.test.ts +++ b/tests/cloud/sdk.test.ts @@ -5,7 +5,8 @@ import MockAdapter from 'axios-mock-adapter'; import { resetAuthUuidCacheForTests } from '../../src/cloud/auth-uuid.js'; import { resetRetryDelaysForTests, setRetryDelaysForTests } from '../../src/cloud/errors.js'; import { createNodeTransport as createDeck } from '../../src/cloud/node.js'; -import { createCloudClient } from '../../src/cloud/client.js'; +import { createClientWith } from '../../src/client/index.js'; +import { nodeRuntime } from '../../src/cloud/node-runtime.js'; import { DEFAULT_ROOT } from '../../src/cloud/contracts.js'; import { isValidAuthUuid } from '../../src/cloud/auth-uuid.js'; import { APIError } from '../../src/cloud/errors.js'; @@ -960,12 +961,12 @@ describe('Node cloud transport', () => { it('fails with auth_error when saved credentials are rejected instead of switching to a guest', async () => { // 实测:生产环境的 token 失效后,CLI 悄悄换成访客建任务,结果取不回来,白等 600 秒。 - const client = createCloudClient({ apiBase: 'http://localhost:3000/api', token: 'expired-token', spaceId: 'space-1' } as never); + const client = createClientWith({ apiBase: 'http://localhost:3000/api', token: 'expired-token', spaceId: 'space-1' }, nodeRuntime, 'node'); const guestLookup = vi.fn(() => [200, { id: 'guest-space' }]); mock.onGet('http://localhost:3000/api/user').reply(guestLookup); mock.onPost('http://localhost:3000/api/tools/tasks').reply(401, { message: 'Not authentication' }); - const error = await client.parse({ file: { input: new Uint8Array([1, 2, 3]), name: 'deck.pptx' } }).catch((caught: unknown) => caught); + const error = await client.parse({ file: new Uint8Array([1, 2, 3]), name: 'deck.pptx' }).catch((caught: unknown) => caught); expect(error).toMatchObject({ code: 'auth_error', hint: expect.stringContaining('deckops auth login') }); expect(guestLookup).not.toHaveBeenCalled(); }); diff --git a/tests/integration/local.test.ts b/tests/integration/local.test.ts deleted file mode 100644 index 3f464eb..0000000 --- a/tests/integration/local.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { describe, expect, it, vi } from 'vitest'; -import { readManifest } from '../../src/artifact/manifest.js'; -import { runConvert } from '../../src/core/convert-op.js'; -import { resolveInput } from '../../src/core/input.js'; -import { runParse } from '../../src/core/parse-op.js'; -import { validateDeckIR } from '../../src/ir/validate.js'; -import { createClient } from '../../src/index.js'; -import { PDF_PARSER_VERSION } from '../../src/version.js'; - -const tmp = (): string => fs.mkdtempSync(path.join(os.tmpdir(), 'deckops-local-')); - -describe('local community engine', () => { - it.each([ - ['test.pdf', 'pdf'], ['test.pptx', 'pptx'], ['test.docx', 'docx'], - ] as const)('parses and converts %s without a cloud client', async (fixture, format) => { - const root = tmp(); const source = path.resolve('tests/generated', fixture); const out = path.join(root, 'artifact'); - const parsed = await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local' }, preflight: 'off' }); - expect(parsed).toMatchObject({ engine: 'local', format, reusedParse: false, irSchemaVersion: 'deckir.v1' }); - expect(parsed.irKey).toBeUndefined(); - const manifest = await readManifest(out); - expect(manifest).toMatchObject({ manifestVersion: 2, parse: { engine: 'local', format, remote: null } }); - const ir = validateDeckIR(JSON.parse(fs.readFileSync(path.join(out, 'ir.json'), 'utf-8'))); - expect(ir.document.nodes.length).toBeGreaterThan(0); - if (format === 'pptx' || format === 'pdf') expect(ir.document.pages.length).toBeGreaterThan(0); - const converted = await runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: { engine: 'local' } }); - expect(converted).toMatchObject({ engine: 'local', format, taskId: null, reusedParse: true }); - expect(fs.readFileSync(path.join(out, 'views/markdown/index.md'), 'utf-8').length).toBeGreaterThan(20); - const reused = await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local' }, preflight: 'off' }); - expect(reused).toMatchObject({ engine: 'artifact-cache', reusedParse: true, taskId: null }); - if (format === 'pdf') { - await expect(runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: { engine: 'local', failOnDegraded: true } })) - .rejects.toMatchObject({ code: 'input_error' }); - const irFile = path.join(out, 'ir.json'); const manifestFile = path.join(out, 'manifest.json'); - const cachedIr = JSON.parse(fs.readFileSync(irFile, 'utf-8')); const cachedManifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); - const quality = { status: 'degraded', checks: [{ code: 'fixture_degraded', severity: 'warning', message: 'fixture degraded' }], coverage: {}, recommendation: 'cloud' }; - cachedIr.quality = quality; cachedManifest.quality = quality; - fs.writeFileSync(irFile, JSON.stringify(cachedIr)); fs.writeFileSync(manifestFile, JSON.stringify(cachedManifest)); - await expect(runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local', failOnDegraded: true }, preflight: 'off' })) - .rejects.toMatchObject({ code: 'input_error' }); - } - }, 30_000); - - it('invalidates auto cache entries when the recorded parser major changes', async () => { - const root = tmp(); const source = path.resolve('tests/generated/test.docx'); const out = path.join(root, 'artifact'); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local' }, preflight: 'off' }); - const irFile = path.join(out, 'ir.json'); const manifestFile = path.join(out, 'manifest.json'); - const ir = JSON.parse(fs.readFileSync(irFile, 'utf-8')); const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); - ir.producer.version = '9.0.0'; manifest.parse.parser.version = '9.0.0'; - fs.writeFileSync(irFile, JSON.stringify(ir)); fs.writeFileSync(manifestFile, JSON.stringify(manifest)); - let cloudCalls = 0; - const reparsed = await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'auto', allowUpload: true }, preflight: 'off', - cloud: async () => { cloudCalls += 1; throw new Error('cloud should not be needed'); } }); - expect(reparsed).toMatchObject({ engine: 'local', reusedParse: false }); expect(cloudCalls).toBe(0); - }, 30_000); - - it.each(['local', 'auto'] as const)('invalidates pre-0.2 PDF parse caches in %s mode', async (engine) => { - const root = tmp(); const source = path.resolve('tests/generated/test.pdf'); const out = path.join(root, 'artifact'); - try { - const options = { input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine }, preflight: 'off' as const }; - await runParse(options); - const manifestFile = path.join(out, 'manifest.json'); - const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); - expect(manifest.parse.parser.version).toBe(PDF_PARSER_VERSION); - manifest.parse.parser.version = '0.1.2'; - fs.writeFileSync(manifestFile, JSON.stringify(manifest)); - expect(await runParse(options)).toMatchObject({ engine: 'local', reusedParse: false }); - expect(await runParse(options)).toMatchObject({ engine: 'artifact-cache', reusedParse: true }); - const current = await readManifest(out); - expect(current).toMatchObject({ parse: { parser: { version: PDF_PARSER_VERSION } } }); - } finally { fs.rmSync(root, { recursive: true, force: true }); } - }, 30_000); - - it('invalidates old local renderer views and removes stale split-page files', async () => { - const root = tmp(); const source = path.resolve('tests/generated/test.pptx'); const out = path.join(root, 'artifact'); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local' }, preflight: 'off' }); - await runConvert({ input: await resolveInput(out), inputLabel: out, flags: { splitPages: true }, common: { engine: 'local' } }); - const page = path.join(out, 'views/markdown/001.md'); expect(fs.existsSync(page)).toBe(true); - const manifestFile = path.join(out, 'manifest.json'); const manifest = JSON.parse(fs.readFileSync(manifestFile, 'utf-8')); - manifest.views.markdown.rendererVersion = '0.0.0'; fs.writeFileSync(manifestFile, JSON.stringify(manifest)); - const rerendered = await runConvert({ input: await resolveInput(out), inputLabel: out, flags: { splitPages: true }, common: { engine: 'local' } }); - expect(rerendered.engine).toBe('local'); - await runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: { engine: 'local', force: true } }); - expect(fs.existsSync(page)).toBe(false); - }, 30_000); - - it('does not silently upload unsupported Keynote input', async () => { - const source = path.resolve('tests/generated/test.key'); let cloudCalls = 0; - await expect(runParse({ input: await resolveInput(source), inputLabel: source, out: path.join(tmp(), 'artifact'), flags: {}, - common: { engine: 'auto' }, preflight: 'off', cloud: async () => { cloudCalls += 1; throw new Error('must not upload'); } })).rejects.toMatchObject({ code: 'unsupported' }); - expect(cloudCalls).toBe(0); - }); - - it('does not silently ignore cloud-only PDF profiles', async () => { - const source = path.resolve('tests/generated/test.pdf'); let cloudCalls = 0; - await expect(runParse({ input: await resolveInput(source), inputLabel: source, out: path.join(tmp(), 'artifact'), flags: { profile: 'quality' }, - common: { engine: 'local' }, preflight: 'off', cloud: async () => { cloudCalls += 1; throw new Error('must not upload'); } })).rejects.toMatchObject({ code: 'unsupported' }); - expect(cloudCalls).toBe(0); - }); - - it('uses cloud for unsupported input only after auto upload authorization', async () => { - const source = path.resolve('tests/generated/test.key'); let cloudCalls = 0; - const client = { - parse: async () => { cloudCalls += 1; return { taskId: 'cloud-1', type: 'keynote.parseTextAndImage', irKey: 'remote/key.json', irSchemaVersion: 'keynote.v1', ir: { slides: [{ text: 'Cloud result' }] } }; }, - convert: async () => { throw new Error('not used'); }, - }; - const result = await runParse({ input: await resolveInput(source), inputLabel: source, out: path.join(tmp(), 'artifact'), flags: {}, - common: { engine: 'auto', allowUpload: true }, preflight: 'off', cloud: async () => client as never }); - expect(result).toMatchObject({ engine: 'cloud', taskId: 'cloud-1', irKey: 'remote/key.json' }); - expect(cloudCalls).toBe(1); - }); - - it('makes zero network requests for local file parsing and conversion', async () => { - const network = vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('network forbidden')); - try { - const source = path.resolve('tests/generated/test.docx'); const out = path.join(tmp(), 'artifact'); - await runParse({ input: await resolveInput(source), inputLabel: source, out, flags: {}, common: { engine: 'local' }, preflight: 'off' }); - await runConvert({ input: await resolveInput(out), inputLabel: out, flags: {}, common: { engine: 'local' } }); - expect(network).not.toHaveBeenCalled(); - } finally { network.mockRestore(); } - }); - - it('validates Node SDK flags before starting local or cloud work', async () => { - const source = path.resolve('tests/generated/test.docx'); const client = createClient(); - await expect(client.parseEnvelope(source, { trackedChanges: 'invalid' as never })).rejects.toMatchObject({ code: 'usage_error' }); - await expect(client.parseEnvelope(source, { allowUpload: true })).rejects.toMatchObject({ code: 'usage_error' }); - }); -}); diff --git a/tests/integration/read.test.ts b/tests/integration/read.test.ts index a18f9a9..4c0fbb9 100644 --- a/tests/integration/read.test.ts +++ b/tests/integration/read.test.ts @@ -1,40 +1,78 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createClient } from '../../src/index.js'; -import { runRead } from '../../src/core/read-op.js'; -import { resolveInput } from '../../src/core/input.js'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { createClient, ParsedDocument } from '../../src/index.js'; +import { createFakeCloud, type FakeCloud } from '../browser/fake-cloud.mjs'; + +let cloud: FakeCloud; +beforeAll(async () => { cloud = await createFakeCloud(); }); +afterAll(async () => { await cloud.close(); }); const dirs: string[] = []; -async function temp() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-read-')); dirs.push(dir); return dir; } -afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true }))); vi.restoreAllMocks(); }); -describe('Markdown-first content API', () => { - it('returns Markdown and structured reports without writing stdout, then reuses the candidate', async () => { - const cacheDir = await temp(); const client = createClient(); const file = path.resolve('tests/generated/test.docx'); +async function temp() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-sdk-')); dirs.push(dir); return dir; } +afterEach(async () => { await Promise.all(dirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); vi.restoreAllMocks(); }); + +const clientFor = (name: string) => createClient({ apiBase: cloud.apiBase(name), token: 'test-token' }); +const source = async (dir: string, name = 'doc.pdf') => { const file = path.join(dir, name); await fs.writeFile(file, 'fake document bytes'); return file; }; + +describe('Node SDK', () => { + it('reads Markdown without writing stdout and reuses the cache', async () => { + const dir = await temp(); + const file = await source(dir); const stdout = vi.spyOn(process.stdout, 'write'); - const result = await client.read(file, { cacheDir, preflight: 'off' }); - expect(result.schemaVersion).toBe('deckops.read.v1'); expect(typeof result.content).toBe('string'); expect(result.content?.length).toBeGreaterThan(0); - expect(result.report.decision?.policy.upload).toBe('deny'); expect(stdout).not.toHaveBeenCalled(); - const second = await client.read(file, { cacheDir, preflight: 'off', engine: 'auto' }); - expect(second.report.selected.cacheHit).toBe(true); - const ir = await client.read(file, { cacheDir, preflight: 'off', format: 'ir' }); - expect(ir.content?.schemaVersion).toBe('deckir.v1'); + const client = clientFor('sdk-read'); + const result = await client.read(file, { cacheDir: path.join(dir, 'cache') }); + expect(result).toMatchObject({ schemaVersion: 'deckops.read.v2', format: 'markdown', report: { cacheHit: false } }); + expect(result.content).toContain('# Page one'); + expect(stdout).not.toHaveBeenCalled(); + expect((await client.read(file, { cacheDir: path.join(dir, 'cache') })).report.cacheHit).toBe(true); + const ir = await client.read(file, { cacheDir: path.join(dir, 'cache'), format: 'ir' }); + expect(ir.content).toMatchObject({ irSchemaVersion: 'result.v3' }); }); - it('writes file output without duplicating content and rejects collisions before parsing', async () => { - const dir = await temp(); const client = createClient(); const file = path.resolve('tests/generated/test.docx'); + + it('parses to an artifact handle that returns the stored IR and converts by irKey', async () => { + const dir = await temp(); + const client = clientFor('sdk-handle'); + const doc = await client.parse(await source(dir, 'slides.pptx'), { out: path.join(dir, 'slides') }); + expect(doc).toBeInstanceOf(ParsedDocument); + expect(doc).toMatchObject({ dir: path.join(dir, 'slides'), taskId: 'task-1', irKey: 'fixture/task-1/ir.json', irSchemaVersion: 'result.v3' }); + expect(Date.parse(doc.expiresAt)).toBeGreaterThan(Date.now()); + expect(await doc.ir()).toMatchObject({ document: { elements: [{ text: 'Hello browser' }] } }); + + const view = await doc.convert({ splitPages: true }); + expect(view).toMatchObject({ op: 'convert', format: 'pptx', taskId: 'task-2', parseTaskId: 'task-1', reusedParse: true }); + const reopened = await client.openArtifact(doc.dir); + expect(reopened.manifest.views.markdown?.files).toHaveLength(3); + expect((await reopened.convert({ splitPages: true })).taskId).toBeNull(); + + const envelope = await client.parseEnvelope(path.join(dir, 'slides.pptx'), { out: doc.dir }); + expect(envelope).toMatchObject({ reusedParse: true, taskId: 'task-1' }); + expect(cloud.state('sdk-handle').tasks).toHaveLength(2); + }); + + it('rejects removed and unknown options instead of ignoring them', async () => { + const dir = await temp(); + const file = await source(dir); + const client = clientFor('sdk-options'); + await expect(client.read(file, { engine: 'local' } as never)).rejects.toMatchObject({ code: 'usage_error', message: 'Unknown read option: engine.' }); + await expect(client.parse(file, { preflight: 'off' } as never)).rejects.toMatchObject({ code: 'usage_error' }); + await expect(client.convert(file, { allowUpload: false } as never)).rejects.toMatchObject({ code: 'usage_error' }); + expect(() => createClient({ inspector: {} } as never)).toThrow('Unknown client option: inspector.'); + await expect(client.convert(file, { anchors: true, splitPages: true })).rejects.toMatchObject({ code: 'usage_error' }); + expect(cloud.state('sdk-options').requests).toHaveLength(0); + }); + + it('writes file output and a report, and rejects colliding paths before any request', async () => { + const dir = await temp(); + const file = await source(dir); + const client = clientFor('sdk-out'); const out = path.join(dir, 'report.md'), reportFile = path.join(dir, 'report.json'); - const result = await client.read(file, { cacheDir: path.join(dir, 'cache'), preflight: 'off', out, reportFile }); - expect(result.content).toBeNull(); expect(await fs.readFile(out, 'utf8')).toContain('DeckOps deterministic'); - expect(JSON.parse(await fs.readFile(reportFile, 'utf8')).schemaVersion).toBe('deckops.run.v1'); + const result = await client.read(file, { cacheDir: path.join(dir, 'cache'), out, reportFile }); + expect(result.content).toBeNull(); + expect(await fs.readFile(out, 'utf8')).toContain('# Page one'); + expect(JSON.parse(await fs.readFile(reportFile, 'utf8'))).toMatchObject({ schemaVersion: 'deckops.run.v2', parseTaskId: 'task-1' }); await expect(client.read(file, { out: file })).rejects.toMatchObject({ code: 'usage_error' }); await expect(client.read(file, { reportFile: '-' })).rejects.toMatchObject({ code: 'usage_error' }); - await expect(client.read(file, { format: 'ir', out: path.join(dir, 'ir.json') })).rejects.toMatchObject({ code: 'usage_error' }); - }); - it('does not upload existing artifacts or fake source completeness', async () => { - const dir = await temp(); const source = path.resolve('tests/generated/test.docx'); - const result = await createClient().read(source, { cacheDir: dir, preflight: 'off' }); - const cloud = vi.fn(); - const read = await runRead({ input: await resolveInput(result.report.artifactBase), inputLabel: result.report.artifactBase, flags: {}, common: { engine: 'auto', allowUpload: true }, cloud }); - expect(cloud).not.toHaveBeenCalled(); expect(read.report.completeness).toBe('unknown'); + expect(cloud.state('sdk-out').tasks).toHaveLength(2); }); }); diff --git a/tests/integration/review.test.ts b/tests/integration/review.test.ts deleted file mode 100644 index ff0ef15..0000000 --- a/tests/integration/review.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import fs from 'node:fs/promises'; -import path from 'node:path'; -import os from 'node:os'; -import { afterEach, describe, expect, it } from 'vitest'; -import { strToU8, zipSync } from 'fflate'; -import { createClient } from '../../src/index.js'; -import { storeAsset } from '../../src/artifact/store-asset.js'; -const dirs: string[] = []; -async function temp() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deckops-review-')); dirs.push(dir); return dir; } -afterEach(async () => { await Promise.all(dirs.splice(0).map(d => fs.rm(d, { recursive: true, force: true }))); }); -describe('review: artifact delivery', () => { - it('reports real output bytes both on initial conversion and cache hit', async () => { - const dir = await temp(); const client = createClient(); - const parsed = await client.parseEnvelope(path.resolve('tests/generated/test.docx'), { out: path.join(dir, 'artifact'), preflight: 'off' }); - for (const out of parsed.outputs) expect(out.bytes).toBe((await fs.stat(out.file)).size); - const first = await client.convert(parsed.artifact); const second = await client.convert(parsed.artifact); - expect(second.engine).toBe('artifact-cache'); - expect(second.outputs).toEqual(first.outputs); - for (const out of second.outputs) expect(out.bytes).toBeGreaterThan(0); - }); - it('shares immutable image bytes between candidate and final artifact, including cached runs', async () => { - const dir = await temp(); const source = path.join(dir, 'image.docx'); - const parts = { '[Content_Types].xml': '', 'word/document.xml': 'body', 'word/_rels/document.xml.rels': '' }; - await fs.writeFile(source, zipSync({ ...Object.fromEntries(Object.entries(parts).map(([k,v]) => [k,strToU8(v)])), 'word/media/a.png': new Uint8Array([137,80,78,71,13,10,26,10]) })); - const client = createClient(); const opts = { cacheDir: path.join(dir, 'cache'), preflight: 'off' as const }; - for (let i = 0; i < 2; i++) { - const result = await client.read(source, opts); const asset = result.assets[0]!; - const candidate = path.join(result.report.artifactBase, 'candidates/local/assets', path.basename(asset.path)); - const a = await fs.stat(asset.path), b = await fs.stat(candidate); - expect(a.ino).toBe(b.ino); expect(a.nlink).toBeGreaterThanOrEqual(2); - expect(await fs.readFile(asset.path)).toEqual(await fs.readFile(candidate)); - } - }); - it('never mutates a shared inode when repairing or replacing an asset', async () => { - const dir = await temp(); const a = path.join(dir, 'a'), b = path.join(dir, 'b'); - await storeAsset(a, new Uint8Array([1]), []); await storeAsset(b, new Uint8Array([1]), [a]); - await storeAsset(a, new Uint8Array([2]), [b]); - expect([...await fs.readFile(a)]).toEqual([2]); expect([...await fs.readFile(b)]).toEqual([1]); - }); -}); diff --git a/tests/unit/alt-text.test.ts b/tests/unit/alt-text.test.ts deleted file mode 100644 index 43937e5..0000000 --- a/tests/unit/alt-text.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { imageAltText } from '../../src/ir/alt-text.js'; - -/** UTF-8 字节逐个当成 Latin-1 字符:实测替代文字里的乱码就是这样来的(含看不见的 C1 控制字符)。 */ -const garbled = (text: string): string => String.fromCharCode(...new TextEncoder().encode(text)); - -describe('image alt text', () => { - it('keeps descriptions written by the author', () => { - expect(imageAltText('Alice')).toBe('Alice'); - expect(imageAltText('kw_skype_logo')).toBe('kw_skype_logo'); - expect(imageAltText(' DNS message ')).toBe('DNS message'); - expect(imageAltText('A picture containing screenshot\n\nDescription automatically generated')).toBe('A picture containing screenshot'); - }); - - it('drops links, file paths, file names and clip-art cache names', () => { - for (const junk of [ - 'https://d30y9cdsu7xlg0.cloudfront.net/png/219186-200.png', - 'C:\\Users\\Administrator\\Desktop\\埃森哲\\day 1\\QQ截图20161011111029.pngQQ截图20161011111029', - 'timg.jpeg', - '屏幕快照 2017-02-15 下午12.58.49.png', - 'imgyjavg[1]', - '屏幕剪辑', - 'See the source image', - 'Picture 3', - '', - undefined, - ]) expect(imageAltText(junk)).toBeUndefined(); - }); - - it('strips search boilerplate and repairs UTF-8 read as Latin-1', () => { - expect(imageAltText(`Image result for ${garbled('中信集团')} logo`)).toBe('中信集团 logo'); - expect(imageAltText('ARTIFICIAL INTELLIGENCE 的图像结果')).toBe('ARTIFICIAL INTELLIGENCE'); - // 真正的 Latin-1 文字不是合法 UTF-8,保持原样。 - expect(imageAltText('café')).toBe('café'); - }); -}); diff --git a/tests/unit/chart-data.test.ts b/tests/unit/chart-data.test.ts deleted file mode 100644 index e689c07..0000000 --- a/tests/unit/chart-data.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { chartRows, formatChartValue } from '../../src/ir/chart-data.js'; - -describe('chart data as a table', () => { - it('puts categories down the side and one column per series', () => { - expect(chartRows({ kind: 'bar', categories: ['Q1', 'Q2'], series: [{ name: 'Sales', values: [8.2, 3.2] }, { values: [1, null] }] })) - .toEqual([['', 'Sales', 'Series 2'], ['Q1', '8.2', '1'], ['Q2', '3.2', '']]); - }); - - it('numbers the rows when there are no categories and drops unused trailing points', () => { - // 实测:环形图没有类别,缓存预留四个点位,只有两个有值。 - expect(chartRows({ kind: 'doughnut', categories: [], series: [{ values: [4000, 18000, null, null] }] })) - .toEqual([['', 'Series 1'], ['1', '4000'], ['2', '18000']]); - }); - - it('formats percentages and strips floating-point noise', () => { - expect(formatChartValue(0.25, '0%')).toBe('25%'); - expect(formatChartValue(0.1234, '0.0%')).toBe('12.3%'); - expect(formatChartValue(12, '#,##0"%"')).toBe('12'); - expect(formatChartValue(0.1 + 0.2, 'General')).toBe('0.3'); - expect(formatChartValue(null)).toBe(''); - }); -}); diff --git a/tests/unit/cloud-adapter.test.ts b/tests/unit/cloud-adapter.test.ts deleted file mode 100644 index a7aa1a5..0000000 --- a/tests/unit/cloud-adapter.test.ts +++ /dev/null @@ -1,394 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { cloudResultToCandidate } from '../../src/ir/cloud-adapter.js'; -import { renderMarkdown } from '../../src/views/markdown.js'; -import type { ParseResult } from '../../src/cloud/parse-facade.js'; - -const source = { sha256: 'a'.repeat(64), name: 'fixture.pptx', bytes: 1 }; - -const parse = (spTree: unknown[]): Promise> => - cloudResultToCandidate( - { - taskId: 'task', type: 'pptx.parse', irKey: 'ir/fixture', irSchemaVersion: 'pptx.v1', - ir: { slides: [{ _ref: 'slide1', spTree }], files: {}, images: [] }, - } as ParseResult, - source, - ) as never; - -const cell = (text: string, extra: Record = {}) => ({ - text, txBody: { children: [{ children: [{ t: text }] }] }, ...extra, -}); - -describe('cloud IR adapter', () => { - it('expands tables into table_row and table_cell so Markdown can render them', async () => { - const candidate = await parse([ - { - id: 1, name: 'Table 1', type: 'Table', xfrm: { x: 0, y: 0, cx: 400, cy: 100 }, - table: { - grid: { cols: [] }, - trs: [ - { cells: [cell('Name'), cell('Qty')] }, - { cells: [cell('Widget'), cell('12')] }, - ], - }, - }, - ]); - - const types = candidate.ir.document.nodes.map((node) => node.type); - expect(types).toEqual(['table', 'table_row', 'table_cell', 'table_cell', 'table_row', 'table_cell', 'table_cell']); - - const table = candidate.ir.document.nodes[0]!; - expect(table.extensions).toMatchObject({ rows: 2, columns: 2 }); - // 单元格摊成 table 的直接子节点时渲染器一个字都取不到,这里锁住层级。 - const rows = candidate.ir.document.nodes.filter((node) => node.type === 'table_row'); - expect(rows.map((row) => row.parentId)).toEqual([table.id, table.id]); - expect(rows.every((row) => row.children.length === 2)).toBe(true); - - expect(renderMarkdown(candidate.ir).markdown).toContain('| Name | Qty |\n| --- | --- |\n| Widget | 12 |'); - }); - - it('carries merge spans through to the cells', async () => { - const candidate = await parse([ - { - id: 1, name: 'Table 1', type: 'Table', - table: { grid: { cols: [] }, trs: [{ cells: [cell('Wide', { colSpan: 2 }), cell('', { hMerge: true })] }] }, - }, - ]); - - const cells = candidate.ir.document.nodes.filter((node) => node.type === 'table_cell'); - expect(cells[0]!.extensions).toMatchObject({ row: 0, column: 0, gridSpan: 2, rowSpan: 1 }); - expect(cells[1]!.extensions).toMatchObject({ column: 1, hMerge: true }); - }); - - it('leaves records without a recognizable grid to the generic walk', async () => { - const candidate = await parse([ - { id: 1, name: 'Empty', type: 'Table', table: { grid: { cols: [] }, trs: [] } }, - ]); - - expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['table']); - }); - - it('types title placeholders as headings and text shapes as text', async () => { - const candidate = await parse([ - { id: 1, name: 'Title 1', type: 'Shape', ph: { type: 'title' }, - txBody: { children: [{ children: [{ t: 'Application architectures' }] }] }, text: 'Application architectures' }, - { id: 2, name: 'Body 1', type: 'Shape', ph: { type: 'body' }, - txBody: { children: [{ children: [{ t: 'Client-server' }] }] }, text: 'Client-server' }, - // 空的标题占位符靠类型词才建得出节点,改判 heading 会让它整个消失。 - { id: 3, name: 'Empty title', type: 'Shape', ph: { type: 'ctrTitle' }, xfrm: { x: 0, y: 0, cx: 10, cy: 10 } }, - ]); - - expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['heading', 'text', 'shape']); - expect(renderMarkdown(candidate.ir).markdown).toContain('## Application architectures'); - }); - - it('does not mistake a freeform geometry path for an asset pointer', async () => { - const candidate = await parse([ - { id: 1, name: 'Freeform 9', type: 'Shape', path: 'M 0 0 L 10 10 Z', xfrm: { x: 0, y: 0, cx: 10, cy: 10 } }, - ]); - - const [shape] = candidate.ir.document.nodes; - expect(shape!.extensions?.assetPath).toBeUndefined(); - expect(shape!.issues ?? []).toEqual([]); - // 几何路径被当成资产时会凭空产出这条告警,并把整份产物顶成 degraded。 - expect(candidate.ir.quality.checks.map((check) => check.code)).not.toContain('cloud_asset_unavailable'); - expect(candidate.ir.quality.status).toBe('pass'); - }); - - it('still reports a real asset that the cloud could not materialize', async () => { - const candidate = await parse([ - { id: 1, name: 'Picture 1', type: 'Picture', assetPath: 'assets/missing.png' }, - ]); - - expect(candidate.ir.document.nodes[0]!.issues).toEqual(['missing_media']); - expect(candidate.ir.quality.checks.map((check) => check.code)).toContain('cloud_asset_unavailable'); - }); -}); - -describe('cloud IR adapter: pptx slide layout', () => { - const EMU = 12700; - const box = (x: number, y: number, cx: number, cy: number) => ({ x: x * EMU, y: y * EMU, cx: cx * EMU, cy: cy * EMU }); - const textShape = (id: number, name: string, xfrm: Record, paragraphs: Array>>, extra: Record = {}) => ({ - id, name, type: 'Shape', xfrm, - txBody: { children: paragraphs.map((runs) => ({ children: runs })) }, - text: paragraphs.map((runs) => runs.map((run) => run.t).join('')).filter(Boolean).join('\n'), - ...extra, - }); - - it('keeps bold and italic from the cloud text runs', async () => { - const candidate = await parse([ - textShape(1, 'Body', box(50, 100, 300, 100), [[{ t: 'server: ' }, { t: 'always-on', style: { b: true } }, { t: ' host' }], [{ t: 'note', style: { i: true } }]]), - ]); - const [node] = candidate.ir.document.nodes; - expect(node!.runs?.map((run) => run.text).join('')).toBe(node!.text); - // 两个段落各成一段:原先只隔一个换行,Markdown 把它们并成了一段。 - expect(renderMarkdown(candidate.ir).markdown).toContain('server: **always-on** host\n\n*note*'); - }); - - it('drops runs that would not reproduce the node text', async () => { - const candidate = await parse([ - { ...textShape(1, 'Body', box(0, 0, 10, 10), [[{ t: 'abc', style: { b: true } }]]), text: 'something else' }, - ]); - expect(candidate.ir.document.nodes[0]!.runs).toBeUndefined(); - }); - - it('maps footer and slide-number placeholders to furniture that Markdown does not render', async () => { - const candidate = await parse([ - textShape(1, 'Footer Placeholder 2', box(20, 500, 300, 20), [[{ t: '2: Application Layer' }]], { ph: { type: 'ftr', idx: 11 } }), - textShape(2, 'Slide Number Placeholder 3', box(600, 500, 100, 20), [[{ t: '7', field: 'slidenum' }]], { ph: { type: 'sldNum', idx: 12 } }), - textShape(3, 'Body', box(50, 100, 300, 100), [[{ t: 'content' }]]), - ]); - expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['footer', 'page_number', 'text']); - expect(renderMarkdown(candidate.ir).markdown).toBe('content\n'); - }); - - it('places group children on the slide in points and reads top-to-bottom, left-to-right', async () => { - const candidate = await parse([ - textShape(4, 'Right', box(400, 100, 250, 150), [[{ t: 'right column' }]]), - textShape(5, 'Left', box(50, 100, 250, 150), [[{ t: 'left column' }]]), - { id: 6, name: 'Group 6', type: 'Group', - xfrm: { ...box(100, 300, 200, 100), chX: 0, chY: 0, chCX: 400 * EMU, chCY: 200 * EMU }, - children: [textShape(7, 'Label', box(200, 100, 100, 50), [[{ t: 'group label' }]])] }, - textShape(8, 'Title 1', box(50, 20, 600, 60), [[{ t: 'Slide title' }]], { ph: { type: 'title' } }), - ]); - const label = candidate.ir.document.nodes.find((node) => node.text === 'group label')!; - // 组合外框 (100, 300, 200×100),子画布 400×200:缩放 0.5。与本地解析器给出同一个框。 - expect(label.bbox).toEqual([200, 350, 250, 375]); - expect(renderMarkdown(candidate.ir).markdown).toBe('## Slide title\n\nleft column\n\nright column\n\ngroup label\n'); - }); -}); - -describe('cloud asset retrieval', () => { - const realFetch = globalThis.fetch; - afterEach(() => { globalThis.fetch = realFetch; }); - - const withImages = (count: number) => ({ - taskId: 'task', type: 'pptx.parse', irKey: 'ir/fixture', irSchemaVersion: 'pptx.v1', - ir: { - slides: [{ _ref: 'slide1', spTree: Array.from({ length: count }, (_, index) => ({ - id: index + 1, name: `Picture ${index}`, type: 'Picture', assetPath: `assets/i${index}.png`, - })) }], - files: {}, - images: Array.from({ length: count }, (_, index) => ({ - assetPath: `assets/i${index}.png`, accessURL: `https://assets.test/i${index}.png`, - })), - }, - } as ParseResult); - - const png = () => ({ ok: true, status: 200, arrayBuffer: async () => new Uint8Array([1, 2, 3]).buffer }); - - it('retries a transient failure instead of permanently losing the image', async () => { - let calls = 0; - globalThis.fetch = (async () => { - calls += 1; - return calls === 1 ? { ok: false, status: 503 } : png(); - }) as never; - - const candidate = await cloudResultToCandidate(withImages(1), source); - expect(calls).toBe(2); - expect(candidate.ir.document.assets).toHaveLength(1); - expect(candidate.ir.quality.checks).toEqual([]); - }); - - it('gives up at once on a permanent status and reports the gap', async () => { - let calls = 0; - globalThis.fetch = (async () => { calls += 1; return { ok: false, status: 404 }; }) as never; - - const candidate = await cloudResultToCandidate(withImages(1), source); - // 4xx 重试只是白等;缺图本身仍要如实报给调用方,而不是让整份解析失败。 - expect(calls).toBe(1); - expect(candidate.ir.document.assets).toEqual([]); - expect(candidate.ir.quality.checks.map((check) => check.code)).toEqual(['cloud_asset_unavailable']); - }); - - it('fetches in parallel under a bounded concurrency', async () => { - let inFlight = 0; - let peak = 0; - globalThis.fetch = (async () => { - inFlight += 1; peak = Math.max(peak, inFlight); - await new Promise((resolve) => setTimeout(resolve, 5)); - inFlight -= 1; - return png(); - }) as never; - - const candidate = await cloudResultToCandidate(withImages(12), source); - expect(candidate.ir.document.assets).toHaveLength(1); // 同内容去重后只剩一份 - expect(peak).toBeGreaterThan(1); - expect(peak).toBeLessThanOrEqual(4); - }); - - it('propagates an abort instead of silently returning fewer assets', async () => { - const controller = new AbortController(); - globalThis.fetch = (async () => { controller.abort(); throw new Error('aborted'); }) as never; - - await expect(cloudResultToCandidate(withImages(2), source, controller.signal)).rejects.toThrow(); - }); -}); - -describe('cloud IR adapter: pptx lists', () => { - const EMU = 12700; - const box = (x: number, y: number, cx: number, cy: number) => ({ x: x * EMU, y: y * EMU, cx: cx * EMU, cy: cy * EMU }); - const shape = (name: string, xfrm: Record, paragraphs: Array<[string, Record?]>, extra: Record = {}) => ({ - name, type: 'Shape', xfrm, - txBody: { children: paragraphs.map(([t, style]) => ({ ...(style ? { style } : {}), children: [{ t }] })) }, - text: paragraphs.map(([t]) => t).join('\n'), - ...extra, - }); - const placeholder = (type: string, idx?: number, xfrm?: Record) => - ({ type: 'Shape', ph: { type, ...(idx === undefined ? {} : { idx }) }, ...(xfrm ? { xfrm } : {}), txBody: { children: [] } }); - // 与 tests/unit/pptx-parser.test.ts 的 pptxWithMaster 同一份演示文稿:项目符号来自母版 bodyStyle。 - const deck = (spTree: unknown[]) => cloudResultToCandidate({ - taskId: 'task', type: 'pptx.parse', irKey: 'ir/fixture', irSchemaVersion: 'pptx.v1', - ir: { - slides: [{ _ref: 'slide1', _layoutRef: 'layout1', _masterRef: 'master1', spTree }], - slideMasters: [{ - _ref: 'master1', spTree: [placeholder('title', undefined, box(40, 20, 600, 60)), placeholder('body', 1, box(40, 110, 600, 290))], - slideLayouts: [{ _ref: 'layout1', _masterRef: 'master1', spTree: [placeholder('title'), placeholder('body', 1, box(50, 120, 570, 260))] }], - titleStyle: { lvl1pPr: { buNone: true } }, - bodyStyle: { lvl1pPr: { buChar: '•' }, lvl2pPr: { buChar: '–' }, lvl3pPr: { buChar: '»' } }, - otherStyle: { lvl1pPr: {} }, - }], - defaultTextStyle: { lvl1pPr: { buFont: { typeface: 'Arial' } } }, - files: {}, images: [], - }, - } as ParseResult, source); - - it('nests bullets inherited from the master exactly as the local parser does', async () => { - const candidate = await deck([ - shape('Title 1', box(50, 20, 600, 60), [['Client-server architecture']], { ph: { type: 'title' } }), - shape('Content Placeholder 2', box(50, 100, 600, 300), [ - ['server:', { buFont: { typeface: 'ZapfDingbats' }, buNone: true }], - ['always-on host', { lvl: 1 }], - ['permanent IP address', { lvl: 1 }], - ['clients:', { buNone: true }], - ['communicate with server', { lvl: 1 }], - ], { ph: { type: 'body', idx: 2 } }), - ]); - expect(renderMarkdown(candidate.ir).markdown).toBe([ - '## Client-server architecture', '', - 'server:', '', - '- always-on host', '- permanent IP address', '', - 'clients:', '', - '- communicate with server', '', - ].join('\n')); - expect(candidate.ir.producer.version).toBe('5'); - }); - - it('places placeholders without a frame like the layout and master do, as the local parser does', async () => { - const candidate = await deck([ - shape('Label 1', box(400, 110, 100, 20), [['HTTP request']]), - { ...shape('Title 1', box(0, 0, 0, 0), [['HTTP overview']], { ph: { type: 'title' } }), xfrm: undefined }, - { ...shape('Content Placeholder 2', box(0, 0, 0, 0), [['HTTP: hypertext transfer protocol', { buNone: true }], ['client/server model']], - { ph: { type: 'body', idx: 1 } }), xfrm: undefined }, - ]); - const named = (name: string) => candidate.ir.document.nodes.find((node) => (node.extensions?.cloud as { name?: string }).name === name)!; - expect(named('Content Placeholder 2').bbox).toEqual([50, 120, 620, 380]); - expect(named('Content Placeholder 2').extensions?.bboxInheritedFrom).toBe('layout'); - expect(named('Title 1').bbox).toEqual([40, 20, 640, 80]); - expect(renderMarkdown(candidate.ir).markdown).toBe('## HTTP overview\n\nHTTP: hypertext transfer protocol\n\n- client/server model\n\nHTTP request\n'); - }); - - it('numbers auto-numbered paragraphs and keeps plain paragraphs apart', async () => { - const candidate = await deck([ - shape('TextBox 1', box(50, 100, 600, 200), [ - ['first', { buAutoNum: 'arabicPeriod' }], ['second', { buAutoNum: 'arabicPeriod' }], - ['detail', { lvl: 1, buChar: '•' }], ['third', { buAutoNum: 'arabicPeriod' }], - ]), - shape('TextBox 2', box(50, 400, 600, 100), [['caption line one'], ['caption line two']]), - ]); - expect(renderMarkdown(candidate.ir).markdown).toBe('1. first\n2. second\n - detail\n3. third\n\ncaption line one\n\ncaption line two\n'); - expect(candidate.ir.document.nodes.find((node) => node.text?.startsWith('caption'))!.extensions?.paragraphs) - .toEqual([{ start: 0, end: 16, level: 0 }, { start: 17, end: 33, level: 0 }]); - }); -}); - -describe('Markdown emphasis from text runs', () => { - const EMU = 12700; - const render = async (runs: Array>) => { - const text = runs.map((run) => run.t).join(''); - const candidate = await parse([{ type: 'Shape', xfrm: { x: 0, y: 0, cx: EMU, cy: EMU }, txBody: { children: [{ children: runs }] }, text }]); - return renderMarkdown(candidate.ir).markdown.trimEnd(); - }; - - it('merges a phrase PowerPoint split into several bold runs', async () => { - // 实测:「50% 的」被切成四个粗体运行,原先输出 **50****% ****的**** **。 - expect(await render([{ t: '50', style: { b: true } }, { t: '% ', style: { b: true } }, { t: '的', style: { b: true } }, { t: ' ', style: { b: true } }, { t: '市场' }])) - .toBe('**50% 的** 市场'); - }); - - it('keeps whitespace and edge punctuation outside the markers so they still parse', async () => { - expect(await render([{ t: '中文' }, { t: '粗体:', style: { b: true } }, { t: '正文' }])).toBe('中文**粗体**:正文'); - expect(await render([{ t: 'a' }, { t: ' ', style: { b: true } }, { t: 'b' }])).toBe('a b'); - expect(await render([{ t: 'see ' }, { t: ' this ', style: { i: true } }, { t: 'now' }])).toBe('see *this* now'); - }); - - it('nests italic inside bold instead of butting the markers together', async () => { - expect(await render([{ t: 'abc', style: { b: true } }, { t: 'def', style: { b: true, i: true } }])).toBe('**abc*def***'); - }); -}); - -describe('cloud IR adapter: pictures', () => { - const EMU = 12700; - const picture = (extra: Record) => ({ type: 'Picture', name: 'Picture 1', xfrm: { x: 0, y: 0, cx: 10 * EMU, cy: 10 * EMU }, picture: { blip: 'ppt/media/image92.tmp' }, ...extra }); - - it('reads alt text from descr with the same cleanup as the local parser', async () => { - const candidate = await parse([ - picture({ descr: `Image result for ${String.fromCharCode(...new TextEncoder().encode('中信集团'))} logo`, assetPath: 'assets/a.png' }), - picture({ descr: 'https://timgsa.baidu.com/timg?image&quality=80', assetPath: 'assets/b.png' }), - ]); - const [kept, dropped] = candidate.ir.document.nodes; - expect(kept!.extensions?.alt).toBe('中信集团 logo'); - expect(dropped!.extensions?.alt).toBeUndefined(); - expect(dropped!.extensions?.descr).toMatch(/^https:/); - }); - - it('reports a picture whose image the cloud result left out instead of dropping it silently', async () => { - // 实测:后端按扩展名筛图片,两张存成 .tmp 的 PNG 没有 assetPath,Markdown 里就少了两张图。 - const candidate = await parse([picture({})]); - const [node] = candidate.ir.document.nodes; - expect(node!.issues).toEqual(['missing_media']); - expect(candidate.ir.quality.checks.map((check) => check.code)).toEqual(['cloud_asset_missing']); - expect(candidate.ir.quality.checks[0]!.message).toContain('ppt/media/image92.tmp'); - }); -}); - -describe('cloud IR adapter: notes, embedded objects and charts', () => { - const EMU = 12700; - const box = (x: number, y: number, cx: number, cy: number) => ({ x: x * EMU, y: y * EMU, cx: Math.round(cx * EMU), cy: Math.round(cy * EMU) }); - const textShape = (name: string, paragraphs: string[], extra: Record = {}) => ({ - name, type: 'Shape', txBody: { children: paragraphs.map((t) => ({ children: [{ t }] })) }, text: paragraphs.join('\n'), ...extra, - }); - const deck = (slide: Record) => cloudResultToCandidate({ - taskId: 'task', type: 'pptx.parse', irKey: 'ir/fixture', irSchemaVersion: 'pptx.v1', - ir: { slides: [{ _ref: 'slide1', ...slide }], files: {}, images: [] }, - } as ParseResult, source); - - it('reads speaker notes from the slide and renders them like the local parser', async () => { - const candidate = await deck({ spTree: [textShape('TextBox 1', ['slide body'], { xfrm: box(50, 100, 600, 100) })], - notes: [textShape('Notes', ['见ppt。解释三多云', '智慧城市云'], { ph: { type: 'body', idx: 1 } })] }); - expect(renderMarkdown(candidate.ir).markdown).toBe('slide body\n\n> **Speaker notes:** 见ppt。解释三多云\n>\n> 智慧城市云\n'); - expect(candidate.ir.document.nodes.filter((node) => node.type === 'speaker_note')).toHaveLength(1); - }); - - it('shows embedded objects as previews, leaves out the ones that draw nothing, and reports SmartArt', async () => { - const candidate = await deck({ spTree: [ - { type: 'Graphic', name: 'Object 2', graphicType: 'ole', xfrm: box(50, 100, 200, 150), ole: { progId: 'Excel.Sheet.12', name: 'Worksheet', preview: 'ppt/media/image1.png' }, assetPath: 'assets/a.png' }, - { type: 'Graphic', name: 'Object 3', graphicType: 'ole', xfrm: box(300, 230, 0.125, 0.125), ole: { name: 'think-cell Slide', preview: 'ppt/media/image18.emf' }, assetPath: 'assets/b.emf' }, - { type: 'Graphic', name: 'Object 4', graphicType: 'ole', xfrm: box(120, 110, 480, 320), ole: { name: 'Clip', imgW: 0, imgH: 0 } }, - { type: 'Graphic', name: 'Diagram 5', graphicType: 'diagram', xfrm: box(50, 300, 400, 200) }, - ] }); - expect(candidate.ir.document.nodes.map((node) => node.type)).toEqual(['picture', 'embedded_object', 'embedded_object', 'smartart']); - expect(candidate.ir.document.nodes[0]!.extensions?.embeddedObject).toEqual({ progId: 'Excel.Sheet.12', name: 'Worksheet' }); - const codes = candidate.ir.quality.checks.map((check) => [check.code, check.severity]); - expect(codes).toContainEqual(['smartart_partial', 'warning']); - expect(codes).toContainEqual(['embedded_object_preview', 'info']); - expect(codes).toContainEqual(['embedded_object_hidden', 'info']); - }); - - it('renders cached chart data as a table, and reports a chart that came without data', async () => { - const chart = { title: 'Share by quarter', kind: 'bar', categories: ['Q1', 'Q2'], series: [{ name: 'Share', values: [0.25, 0.305], formatCode: '0%' }] }; - const withData = await deck({ spTree: [{ type: 'Chart', name: 'Chart 3', xfrm: box(50, 100, 400, 300), chart }] }); - expect(renderMarkdown(withData.ir).markdown).toBe('*Share by quarter*\n\n| | Share |\n| --- | --- |\n| Q1 | 25% |\n| Q2 | 31% |\n'); - const withoutData = await deck({ spTree: [{ type: 'Chart', name: 'Chart 3', xfrm: box(50, 100, 400, 300) }] }); - expect(withoutData.ir.quality.checks.map((check) => check.code)).toEqual(['chart_partial']); - }); -}); diff --git a/tests/unit/errors.test.ts b/tests/unit/errors.test.ts index 5f2ce2c..efbbebb 100644 --- a/tests/unit/errors.test.ts +++ b/tests/unit/errors.test.ts @@ -1,6 +1,6 @@ import { APIError } from '../../src/cloud/errors.js'; import { describe, expect, it } from 'vitest'; -import { translate } from '../../src/cloud/client.js'; +import { translateError as translate } from '../../src/shared/errors.js'; import { EXIT_CODES } from '../../src/errors/index.js'; describe('error translation', () => { diff --git a/tests/unit/local-parsers.test.ts b/tests/unit/local-parsers.test.ts deleted file mode 100644 index 551ab05..0000000 --- a/tests/unit/local-parsers.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { strToU8, zipSync } from 'fflate'; -import { parseHtmlSource } from '../../src/local/html/parser.js'; -import { DEFAULT_LOCAL_LIMITS } from '../../src/local/limits.js'; -import { OpcPackage, resolvePart } from '../../src/local/opc/package.js'; -import { parseXml } from '../../src/local/xml.js'; -import { renderMarkdown } from '../../src/views/markdown.js'; -import { result3ToDeckIr } from '../../src/ir/result3-adapter.js'; -import { finalizeAssets } from '../../src/ir/assets.js'; - -const source = { sha256: 'a'.repeat(64), name: 'page.html', bytes: 1 }; - -describe('local parser safety and rendering', () => { - it('extracts static HTML and flags hydration shells', () => { - const article = parseHtmlSource('T

Hello

World

', source, 'https://example.com'); - expect(article.ir.document.nodes.map((node) => node.text)).toEqual(['Hello', 'World']); - expect(article.quality.status).toBe('pass'); - const inline = parseHtmlSource('
Hello world!
', source, 'https://example.com'); - expect(inline.ir.document.nodes).toHaveLength(1); - expect(inline.ir.document.nodes[0]).toMatchObject({ text: 'Hello world!', links: [{ href: 'https://example.com/world', text: 'world' }] }); - expect(renderMarkdown(inline.ir).markdown).toContain('[world](https://example.com/world)'); - const image = parseHtmlSource('
A
', source, 'https://example.com/page'); - expect(renderMarkdown(image.ir).markdown).toContain('![A](https://example.com/a.png)'); - expect(image.quality.checks.map((check) => check.code)).toContain('remote_assets_not_localized'); - const shell = parseHtmlSource('
', source); - expect(shell.quality.checks.map((check) => check.code)).toContain('runtime_required'); - }); - - it('rejects traversal paths and XML entity declarations', () => { - const unsafe = zipSync({ '../escape.xml': strToU8('') }); - expect(() => new OpcPackage(unsafe, DEFAULT_LOCAL_LIMITS)).toThrow('Unsafe ZIP path'); - expect(() => parseXml(']>&y;', DEFAULT_LOCAL_LIMITS, 'bad.xml')).toThrow('forbidden DTD'); - const withAsset = new OpcPackage(zipSync({ 'word/media/a.png': new Uint8Array([1, 2, 3]) }), { ...DEFAULT_LOCAL_LIMITS, assetBytes: 2 }); - expect(() => withAsset.readAsset('word/media/a.png')).toThrow('asset-size limit'); - expect(resolvePart('ppt/slides/slide1.xml', '/ppt/media/image%201.png')).toBe('ppt/media/image%201.png'); - expect(() => resolvePart('word/document.xml', '%2e%2e/%2e%2e/escape.bin')).toThrow('Unsafe ZIP path'); - }); - - it('escapes raw HTML when deriving Markdown', () => { - const parsed = parseHtmlSource('

<script>alert(1)</script>

', source); - const markdown = renderMarkdown(parsed.ir).markdown; - expect(markdown).toContain('<script>'); - expect(markdown).not.toContain('