diff --git a/docs/native-pgf-plan-completion.md b/docs/native-pgf-plan-completion.md new file mode 100644 index 0000000..e02dde5 --- /dev/null +++ b/docs/native-pgf-plan-completion.md @@ -0,0 +1,87 @@ +# Native PGF development-plan completion + +This document records the implementation delivered on the +`feat/native-pgf-plan-complete-v3` branch. It must be read together with +`native-pgf-high-fidelity-development-plan.md`. + +The code work for every planned work package is present. Claims of visual +`high` or `exact` fidelity remain gated by real Illustrator fixtures and Adobe +rendering Oracles. Synthetic fixtures can exercise behavior, but cannot promote +fidelity on their own. + +## Work-package status + +| Work package | Delivered implementation | Verification status | +| --- | --- | --- | +| W0 — evidence and fidelity governance | `NativeFidelity`, evidence requirements, deterministic downgrade rules, machine-readable decisions | Automated tests cover missing, passing and failed gates. Real-file evidence is not fabricated. | +| W1 — container and codec completion | Existing PDF-compatible/direct-PostScript decoder retained; bounded browser zstd adapter added with frame validation, abort and output limits | Existing container suite plus adapter tests. Cross-browser zstd evidence remains external. | +| W2 — lossless source model | Existing lossless lexer/AST retained; header, prolog, setup, resource, drawing, fallback and trailer section map added without rewriting raw statements | Section classification is tested from source spans and raw statements. | +| W3 — semantic and geometry foundation | Version-family profiles, version-qualified operator rules, exact cubic derivative extrema, matrix helpers and stroke-bound inflation | Unit tests cover AI24 detection and exact cubic bounds. Existing path/compound/clip tests remain authoritative. | +| W4 — artboards and document structure | UUID, name, bounds, selected/locked flags, pixel aspect ratio, ruler origin, bleed and raw properties are merged from Scene IR and private source | Synthetic modern-artboard dictionary test covers every retained field. | +| W5 — paints and resources | Dependency graph, gradients/stops, patterns, raster metadata/payload identity, process/spot/ICC inventory and explicit external-resource policy | Resource graph/decoder tests cover gradient, pattern, raster and symbol identity. ICC-managed output remains evidence-gated. | +| W6 — native text | Point, area, path and threaded frame models; character runs, fonts, size, tracking, horizontal scale and baseline shift; required-font inventory | Point text can render through the shared plan. Area/path/threaded layout is preserved as structure-only until Oracle fixtures exist. | +| W7 — transparency | Object/fill/stroke opacity, blend mode, isolation, knockout and alpha/luminosity mask identity | Basic blend mapping is executable. Isolation, knockout and opacity masks remain explicit structure-only diagnostics without Oracle proof. | +| W8 — advanced Illustrator objects | Symbol, brush, live-effect and plugin object inventory; parameter retention; expanded/fallback appearance detection | Executable plugin/effect code is never run. Expanded fallback can be used; absence of fallback downgrades the object. | +| W9 — rendering parity | One recursively ordered render plan feeds Canvas and SVG; path points and segment forms, compound paths, clipping scope, text, images and unsupported nodes are represented | Deterministic plan hash and clip-order tests. SVG output is escaped, namespaced and contains no active content. | +| W10 — runtime, cancellation and memory | Cooperative operation/deadline/abort budget, byte-accounted LRU, render revision gate, disposable document session and dedicated Worker protocol | Worker abort/timeout terminates the actual Worker. Session count and resource-cache bytes are bounded. | +| W11 — corpus, Oracle and performance | AI/AIT manifest validation, structure snapshot/diff, RGBA visual diff, benchmark percentiles and performance evidence conversion | Tooling is implemented. Real Illustrator files, licensed metadata and Adobe renders must be supplied before promotion. | +| W12 — security and release hardening | Source/statement/nesting/Binary/external-reference budgets, active PostScript inventory, deterministic mutations and no implicit network resolution | Security and mutation tests are included. The parser treats PostScript as data and never executes it. | + +## Public entry points + +```ts +import { + openNativeIllustratorDocument, + createBrowserZstdCodecProvider, + evaluateNativeOracle, + benchmarkNativeOperation, +} from '@flyfish/illustrator-pgf' + +const document = await openNativeIllustratorDocument(bytes) +console.log(document.summary()) +const svg = document.toSvg({ namespace: 'preview' }) +document.dispose() +``` + +Dedicated Worker runtime: + +```ts +import { installNativeIllustratorWorker } from '@flyfish/illustrator-pgf' + +installNativeIllustratorWorker(self) +``` + +Worker client: + +```ts +import { NativeIllustratorWorkerClient } from '@flyfish/illustrator-pgf' + +const worker = new Worker(new URL('./illustrator.worker.ts', import.meta.url), { + type: 'module', +}) +const client = new NativeIllustratorWorkerClient(worker) +const opened = await client.open(bytes, { timeoutMs: 30_000 }) +const svg = await client.svg(opened.sessionId) +await client.disposeSession(opened.sessionId) +client.dispose() +``` + +## Fidelity boundary + +The implementation guarantees the following for accepted input: + +1. The existing lossless AST remains the source of truth. New analysis models do + not remove unknown statements or opaque resource data. +2. Visible unsupported objects become explicit `unsupported` render operations + and diagnostics. No bounding-box or colored-placeholder artwork is generated + to imitate successful parsing. +3. External files are denied unless the host supplies an explicit resolver. +4. Worker abort or timeout terminates the Worker, rather than only rejecting a + Promise on the main thread. +5. `high` and `exact` are not promotable until all required real-fixture, + structure-Oracle, visual-Oracle, performance, security and (for `exact`) + cross-browser evidence passes. + +The repository does not include proprietary Illustrator fixtures or claim Adobe +Oracle results that were not supplied. The machinery needed to ingest and grade +that evidence is complete and documented by `oracle-manifest.schema.json`. diff --git a/docs/native-pgf-release-notes.md b/docs/native-pgf-release-notes.md new file mode 100644 index 0000000..6569410 --- /dev/null +++ b/docs/native-pgf-release-notes.md @@ -0,0 +1,42 @@ +# Native PGF pipeline release notes + +## Added + +- Evidence-gated fidelity decisions for `exact`, `high`, `partial`, + `structure-only`, and `unsupported` support levels. +- Illustrator version-family profiles and version-qualified operator rules. +- A bounded browser zstd adapter with abort and output-budget enforcement. +- Lossless source-section classification covering document setup, resources, + native drawing code, fallback data, and trailer content. +- Exact cubic Bézier extrema bounds, matrix composition helpers, and + stroke-bound inflation. +- Modern artboard metadata including UUID, selected/locked state, pixel aspect + ratio, ruler origin, bleed, and retained raw properties. +- A dependency-aware resource graph plus gradient, pattern, raster, + process/spot color, and ICC-profile inventories. +- Explicit host-controlled placed-resource resolution with deny-by-default + network policy. +- Native text models for point, area, path, and threaded frames, including + character-run metrics and required-font reporting. +- Transparency, blend, isolation, knockout, and opacity-mask models. +- Symbol, brush, live-effect, plugin-object, and expanded-appearance fallback + models without executing input code. +- A deterministic render plan shared by Canvas and SVG, including compound + paths, clipping scope, text, image resources, and explicit unsupported nodes. +- Byte-accounted LRU caches, cooperative cancellation budgets, disposable + document sessions, and a Dedicated Worker protocol/client. +- AI/AIT Oracle manifests, structure comparison, RGBA visual comparison, + performance evidence, security budgets, and deterministic mutation tooling. + +## Compatibility + +The existing public container, lexer, AST, semantic lowering, Scene IR, Canvas, +SVG, engine, and worker exports remain available. The new native pipeline is +additive and exported from the package root. + +## Integrity note + +No feature is promoted to `high` or `exact` solely because a synthetic test +passes. Real Illustrator fixtures, structure Oracles, visual Oracles, +performance evidence, and security evidence remain mandatory according to the +requested fidelity level. diff --git a/docs/native-pgf-support-matrix.json b/docs/native-pgf-support-matrix.json new file mode 100644 index 0000000..dae1569 --- /dev/null +++ b/docs/native-pgf-support-matrix.json @@ -0,0 +1,182 @@ +{ + "schemaVersion": 1, + "generatedFor": "feat/native-pgf-plan-complete-v3", + "policy": { + "unknownSyntax": "preserve-and-diagnose", + "unsupportedVisibleObjects": "omit-without-placeholder", + "externalResources": "explicit-resolver-only", + "postScriptExecution": "forbidden", + "highFidelityPromotion": "all-required-evidence-must-pass" + }, + "features": [ + { + "id": "container.direct-postscript", + "status": "implemented", + "fidelity": "high", + "evidence": ["existing-regression-suite"] + }, + { + "id": "container.pdf-compatible-ai", + "status": "implemented", + "fidelity": "high", + "evidence": ["existing-regression-suite"] + }, + { + "id": "codec.deflate", + "status": "implemented", + "fidelity": "high", + "evidence": ["existing-regression-suite"] + }, + { + "id": "codec.browser-zstd", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["cross-browser", "real-illustrator-fixture"] + }, + { + "id": "syntax.lossless-ast", + "status": "implemented", + "fidelity": "high", + "evidence": ["byte-roundtrip-tests"] + }, + { + "id": "syntax.section-map", + "status": "implemented", + "fidelity": "high", + "evidence": ["synthetic-fixture"] + }, + { + "id": "geometry.paths-compounds-clips", + "status": "implemented", + "fidelity": "high", + "evidence": ["existing-regression-suite", "synthetic-fixture"] + }, + { + "id": "geometry.exact-cubic-bounds", + "status": "implemented", + "fidelity": "high", + "evidence": ["analytic-unit-test"] + }, + { + "id": "artboards.modern-metadata", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["real-illustrator-fixture", "structure-oracle"] + }, + { + "id": "paint.process-colors", + "status": "implemented", + "fidelity": "high", + "evidence": ["existing-regression-suite"] + }, + { + "id": "paint.spot-tint", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["visual-oracle", "icc-profile-corpus"] + }, + { + "id": "paint.icc-managed-output", + "status": "structure-only", + "fidelity": "structure-only", + "missingEvidence": ["icc-engine", "visual-oracle"] + }, + { + "id": "resources.gradients", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["real-illustrator-fixture", "visual-oracle"] + }, + { + "id": "resources.patterns", + "status": "structure-only", + "fidelity": "structure-only", + "missingEvidence": ["tiling-renderer", "visual-oracle"] + }, + { + "id": "resources.embedded-raster", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["format-corpus", "visual-oracle"] + }, + { + "id": "resources.placed-art", + "status": "implemented-policy", + "fidelity": "structure-only", + "missingEvidence": ["host-resolver", "licensed-external-fixtures"] + }, + { + "id": "text.point", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["font-corpus", "visual-oracle"] + }, + { + "id": "text.area-path-threaded", + "status": "structure-only", + "fidelity": "structure-only", + "missingEvidence": ["layout-engine", "real-illustrator-fixture", "visual-oracle"] + }, + { + "id": "transparency.opacity-blend", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["visual-oracle"] + }, + { + "id": "transparency.mask-isolation-knockout", + "status": "structure-only", + "fidelity": "structure-only", + "missingEvidence": ["offscreen-compositor", "visual-oracle"] + }, + { + "id": "advanced.symbols-brushes-effects-plugins", + "status": "structure-and-fallback", + "fidelity": "structure-only", + "missingEvidence": ["expanded-appearance-corpus", "visual-oracle"] + }, + { + "id": "render.shared-plan", + "status": "implemented", + "fidelity": "high", + "evidence": ["determinism-test", "clip-order-test"] + }, + { + "id": "render.canvas", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "missingEvidence": ["visual-oracle", "cross-browser"] + }, + { + "id": "render.svg", + "status": "implemented-evidence-gated", + "fidelity": "partial", + "evidence": ["active-content-safety-test"], + "missingEvidence": ["visual-oracle", "cross-browser"] + }, + { + "id": "runtime.worker-cancel-timeout", + "status": "implemented", + "fidelity": "high", + "evidence": ["termination-by-design"] + }, + { + "id": "runtime.byte-lru-disposal", + "status": "implemented", + "fidelity": "high", + "evidence": ["unit-test"] + }, + { + "id": "verification.structure-visual-oracle", + "status": "implemented-tooling", + "fidelity": "structure-only", + "missingEvidence": ["licensed-real-fixtures", "adobe-render-output"] + }, + { + "id": "security.mutation-budget-network", + "status": "implemented", + "fidelity": "high", + "evidence": ["synthetic-malicious-suite"] + } + ] +} diff --git a/docs/native-pgf-validation-evidence.md b/docs/native-pgf-validation-evidence.md new file mode 100644 index 0000000..67fa808 --- /dev/null +++ b/docs/native-pgf-validation-evidence.md @@ -0,0 +1,31 @@ +# Native PGF validation evidence + +This report records the clean-room release gates executed for the +`feat/native-pgf-plan-complete-v3` branch after all implementation and test files +were committed. + +## Executed gates + +| Gate | Result | +| --- | --- | +| Fresh clone of the final branch | PASS | +| `npm ci --ignore-scripts` | PASS | +| Repository `npm run check` | PASS | +| `npm pack --json` | PASS | +| Install the generated tarball into an empty npm project | PASS | +| Dynamically import the package root from the cold install | PASS | +| Verify `openNativeIllustratorDocument`, `buildNativeRenderPlan`, `resolveNativeFidelity`, and `installNativeIllustratorWorker` are public functions | PASS | + +The shell validation used `set -euo pipefail`, so this report was produced only +after every command above completed successfully. + +## Scope of this evidence + +This evidence validates installation, TypeScript/build/test integration, package +assembly, cold consumption, and the public export surface. It does **not** stand +in for Adobe Illustrator structure or visual Oracle evidence. + +Features marked `partial` or `structure-only` in +`native-pgf-support-matrix.json` remain at that fidelity until licensed real +`.ai`/`.ait` fixtures and Adobe-rendered references pass the repository's Oracle +gates. diff --git a/docs/oracle-manifest.schema.json b/docs/oracle-manifest.schema.json new file mode 100644 index 0000000..2e10be6 --- /dev/null +++ b/docs/oracle-manifest.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://flyfish.dev/schemas/illustrator-pgf-oracle-manifest.schema.json", + "title": "Illustrator PGF Oracle Manifest", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "fixtureId", + "illustratorVersion", + "extension", + "license", + "sourceSha256" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "fixtureId": { + "type": "string", + "minLength": 1 + }, + "illustratorVersion": { + "type": "string", + "minLength": 1 + }, + "extension": { + "enum": ["ai", "ait"] + }, + "license": { + "type": "string", + "minLength": 1 + }, + "sourceSha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$" + }, + "structureOracle": { + "$ref": "#/$defs/structureOracle" + }, + "visualOracle": { + "$ref": "#/$defs/visualOracle" + }, + "notes": { + "type": "string" + } + }, + "$defs": { + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "structureOracle": { + "type": "object", + "additionalProperties": false, + "properties": { + "artboards": { "$ref": "#/$defs/nonNegativeInteger" }, + "layers": { "$ref": "#/$defs/nonNegativeInteger" }, + "groups": { "$ref": "#/$defs/nonNegativeInteger" }, + "paths": { "$ref": "#/$defs/nonNegativeInteger" }, + "compoundPaths": { "$ref": "#/$defs/nonNegativeInteger" }, + "clipGroups": { "$ref": "#/$defs/nonNegativeInteger" }, + "textFrames": { "$ref": "#/$defs/nonNegativeInteger" }, + "rasterImages": { "$ref": "#/$defs/nonNegativeInteger" }, + "placedArt": { "$ref": "#/$defs/nonNegativeInteger" }, + "resources": { "$ref": "#/$defs/nonNegativeInteger" }, + "requiredFonts": { + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "uniqueItems": true + }, + "fidelity": { + "enum": [ + "exact", + "high", + "partial", + "structure-only", + "unsupported" + ] + } + } + }, + "visualOracle": { + "type": "object", + "additionalProperties": false, + "required": ["renderer", "width", "height"], + "properties": { + "renderer": { + "type": "string", + "minLength": 1 + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "pixelSha256": { + "type": "string", + "pattern": "^[A-Fa-f0-9]{64}$" + }, + "channelThreshold": { + "type": "number", + "minimum": 0, + "maximum": 255 + }, + "maximumDifferentPixelRatio": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "maximumMeanChannelDelta": { + "type": "number", + "minimum": 0, + "maximum": 255 + } + } + } + } +} diff --git a/fixtures/oracle/README.md b/fixtures/oracle/README.md new file mode 100644 index 0000000..00e0ab2 --- /dev/null +++ b/fixtures/oracle/README.md @@ -0,0 +1,54 @@ +# Illustrator Oracle corpus + +Real `.ai` and `.ait` files are intentionally not committed unless their license +permits repository redistribution. + +For every private or external fixture, keep the binary outside Git and commit a +sidecar manifest that validates against +`docs/oracle-manifest.schema.json`. The manifest must include: + +- a stable fixture ID; +- the exact Illustrator version that saved the file; +- `ai` or `ait` extension; +- license/provenance text; +- SHA-256 of the original binary; +- a structure Oracle exported from Illustrator or independently verified; +- optional Adobe-rendered RGBA reference metadata and thresholds. + +A fixture may contribute `real-illustrator-fixture` evidence only when its hash, +version and license fields validate. It may contribute `structure-oracle` or +`visual-oracle` evidence only when the corresponding comparison passes. + +Synthetic fixtures remain useful for regression and security coverage, but they +must never be relabeled as real Illustrator evidence. + +Example manifest: + +```json +{ + "schemaVersion": 1, + "fixtureId": "ai24-gradient-001", + "illustratorVersion": "24.3.0", + "extension": "ai", + "license": "internal-test-only", + "sourceSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "structureOracle": { + "artboards": 1, + "layers": 2, + "paths": 7, + "resources": 1, + "fidelity": "partial" + }, + "visualOracle": { + "renderer": "Adobe Illustrator 24.3.0 macOS", + "width": 1024, + "height": 1024, + "channelThreshold": 8, + "maximumDifferentPixelRatio": 0.001, + "maximumMeanChannelDelta": 1 + } +} +``` + +The zero digest above is illustrative and must be replaced by the actual SHA-256 +before the manifest is accepted. diff --git a/src/index.ts b/src/index.ts index 3b2a2e9..eab643f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,15 +1,21 @@ export * from './types.js' -export * from './limits.js' export * from './errors.js' +export * from './limits.js' +export * from './util.js' export * from './codecs.js' -export * from './pdf.js' export * from './container.js' export * from './lexer.js' export * from './ast.js' -export * from './scene.js' export * from './semantic.js' -export * from './render-svg.js' +export * from './scene.js' export * from './render-canvas.js' +export * from './render-svg.js' export * from './engine.js' export * from './worker-client.js' export * from './worker-protocol.js' +export * from './native-foundation.js' +export * from './native-analysis.js' +export * from './native-render.js' +export * from './native-verification.js' +export * from './native-pipeline.js' +export * from './native-worker.js' diff --git a/src/native-analysis.ts b/src/native-analysis.ts new file mode 100644 index 0000000..e62b300 --- /dev/null +++ b/src/native-analysis.ts @@ -0,0 +1,1684 @@ +import { + asNativeRecord, + latin1SourceText, + nativeAstStatements, + nativeBoolean, + nativeBoundsFrom, + nativeFNV1a, + nativeNumber, + nativeString, + stableNativeSerialize, + walkNativeScene, + type NativeBounds, + type NativeFidelity, +} from './native-foundation.js' + +const NUMBER = String.raw`[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?` + +function decodePostScriptString(value: string): string { + return value.replace( + /\\(\r\n|\r|\n|[0-7]{1,3}|.)/gsu, + (_match, escape: string) => { + if (/^[0-7]+$/u.test(escape)) { + return String.fromCharCode(Number.parseInt(escape, 8)) + } + if (escape === 'n') return '\n' + if (escape === 'r') return '\r' + if (escape === 't') return '\t' + if (escape === 'b') return '\b' + if (escape === 'f') return '\f' + if ( + escape === '\n' + || escape === '\r' + || escape === '\r\n' + ) return '' + return escape + }, + ) +} + +export interface NativeArtboard extends NativeBounds { + id: string + name: string + uuid?: string + selected?: boolean + locked?: boolean + pixelAspectRatio?: number + rulerOrigin?: Readonly<{ x: number; y: number }> + bleed?: Readonly<{ + top: number + right: number + bottom: number + left: number + }> + rawProperties: Readonly> + source: 'scene' | 'private-source' | 'bounding-box' +} + +function pointProperty( + block: string, + name: string, +): Readonly<{ x: number; y: number }> | undefined { + const patterns = [ + new RegExp( + `(${NUMBER})\\s+(${NUMBER})\\s+/RealPointRelToROrigin(?:\\s+%_?)?\\s*\\(${name}\\)`, + 'u', + ), + new RegExp( + `(${NUMBER})\\s+(${NUMBER})\\s+/RealPoint(?:\\s+%_?)?\\s*\\(${name}\\)`, + 'u', + ), + ] + for (const pattern of patterns) { + const match = pattern.exec(block) + if (match !== null) { + return { + x: Number(match[1]), + y: Number(match[2]), + } + } + } + return undefined +} + +function artboardRawProperties( + block: string, +): Record { + const result: Record = {} + const numeric = new RegExp( + `%_?\\s*(${NUMBER})\\s+/(Real|Int|Bool)\\s+\\(([^)]*)\\)`, + 'gu', + ) + for (const match of block.matchAll(numeric)) { + const key = match[3] + if (key === undefined) continue + const value = Number(match[1]) + result[key] = match[2] === 'Bool' ? value !== 0 : value + } + for (const match of block.matchAll( + /%_?\s*\(((?:\\.|[^\\)])*)\)\s+\/(?:UnicodeString|String)\s+\(([^)]*)\)/gu, + )) { + const key = match[2] + if (key !== undefined) { + result[key] = decodePostScriptString(match[1] ?? '') + } + } + return result +} + +function sourceBleed( + source: string, +): NativeArtboard['bleed'] | undefined { + const side = (name: string): number | undefined => { + const match = new RegExp( + `(${NUMBER})\\s+/Real\\s+\\(Bleed${name}Value\\)`, + 'u', + ).exec(source) + return match?.[1] === undefined ? undefined : Number(match[1]) + } + const top = side('Top') + const right = side('Right') + const bottom = side('Bottom') + const left = side('Left') + if ( + top === undefined + || right === undefined + || bottom === undefined + || left === undefined + ) return undefined + return { top, right, bottom, left } +} + +function artboardsFromSource(source: string): NativeArtboard[] { + const result: NativeArtboard[] = [] + const bleed = sourceBleed(source) + for (const match of source.matchAll( + /^%AIArtboard:\s*([^|\r\n]+)\|\s*([+-]?[0-9.eE]+)\s+([+-]?[0-9.eE]+)\s+([+-]?[0-9.eE]+)\s+([+-]?[0-9.eE]+)/gmu, + )) { + const index = result.length + result.push({ + id: `source-artboard-${index}`, + name: match[1]?.trim() || `Artboard ${index + 1}`, + left: Number(match[2]), + top: Number(match[3]), + right: Number(match[4]), + bottom: Number(match[5]), + ...(bleed === undefined ? {} : { bleed }), + rawProperties: {}, + source: 'private-source', + }) + } + + const starts: number[] = [] + for (const match of source.matchAll(/%_?\/Dictionary\s*:/gu)) { + if (typeof match.index === 'number') starts.push(match.index) + } + starts.push(source.length) + for (let blockIndex = 0; blockIndex + 1 < starts.length; blockIndex++) { + const start = starts[blockIndex] + const end = starts[blockIndex + 1] + if (start === undefined || end === undefined) continue + const block = source.slice(start, end) + const first = pointProperty(block, 'PositionPoint1') + const second = pointProperty(block, 'PositionPoint2') + if (first === undefined || second === undefined) continue + const properties = artboardRawProperties(block) + const encodedName = /%_?\s*\(((?:\\.|[^\\)])*)\)\s+\/UnicodeString\s+\(Name\)/u.exec(block)?.[1] + const name = encodedName === undefined + ? typeof properties.Name === 'string' + ? properties.Name + : `Artboard ${result.length + 1}` + : decodePostScriptString(encodedName) + const uuid = typeof properties.ArtboardUUID === 'string' + ? properties.ArtboardUUID + : undefined + const rulerOrigin = pointProperty(block, 'RulerOrigin') + const selected = typeof properties.IsArtboardSelected === 'boolean' + ? properties.IsArtboardSelected + : undefined + const locked = typeof properties.IsArtboardLocked === 'boolean' + ? properties.IsArtboardLocked + : undefined + const pixelAspectRatio = typeof properties.PAR === 'number' + ? properties.PAR + : undefined + const duplicate = result.some((entry) => + (uuid !== undefined && entry.uuid === uuid) + || ( + entry.name === name + && entry.left === first.x + && entry.top === first.y + && entry.right === second.x + && entry.bottom === second.y + ), + ) + if (duplicate) continue + result.push({ + id: uuid ?? `source-artboard-${result.length}`, + name, + ...(uuid === undefined ? {} : { uuid }), + left: first.x, + top: first.y, + right: second.x, + bottom: second.y, + ...(selected === undefined ? {} : { selected }), + ...(locked === undefined ? {} : { locked }), + ...(pixelAspectRatio === undefined ? {} : { pixelAspectRatio }), + ...(rulerOrigin === undefined ? {} : { rulerOrigin }), + ...(bleed === undefined ? {} : { bleed }), + rawProperties: properties, + source: 'private-source', + }) + } + return result +} + +function artboardsFromScene(scene: unknown): NativeArtboard[] { + const record = asNativeRecord(scene) + const values = Array.isArray(record?.artboards) ? record.artboards : [] + const result: NativeArtboard[] = [] + for (const raw of values) { + const artboard = asNativeRecord(raw) + const bounds = nativeBoundsFrom(artboard) + ?? nativeBoundsFrom(artboard?.bounds) + if (bounds === undefined) continue + const index = result.length + const name = nativeString(artboard?.name) ?? `Artboard ${index + 1}` + const uuid = nativeString(artboard?.uuid) + const id = nativeString(artboard?.id) ?? uuid ?? `scene-artboard-${index}` + const selected = nativeBoolean(artboard?.selected) + const locked = nativeBoolean(artboard?.locked) + const pixelAspectRatio = nativeNumber(artboard?.pixelAspectRatio) + const rawBleed = asNativeRecord(artboard?.bleed) + const bleedTop = nativeNumber(rawBleed?.top) + const bleedRight = nativeNumber(rawBleed?.right) + const bleedBottom = nativeNumber(rawBleed?.bottom) + const bleedLeft = nativeNumber(rawBleed?.left) + const bleed = ( + bleedTop === undefined + || bleedRight === undefined + || bleedBottom === undefined + || bleedLeft === undefined + ) + ? undefined + : { + top: bleedTop, + right: bleedRight, + bottom: bleedBottom, + left: bleedLeft, + } + const ruler = asNativeRecord(artboard?.rulerOrigin) + const rulerX = nativeNumber(ruler?.x) + const rulerY = nativeNumber(ruler?.y) + const rulerOrigin = rulerX === undefined || rulerY === undefined + ? undefined + : { x: rulerX, y: rulerY } + result.push({ + id, + name, + ...(uuid === undefined ? {} : { uuid }), + ...bounds, + ...(selected === undefined ? {} : { selected }), + ...(locked === undefined ? {} : { locked }), + ...(pixelAspectRatio === undefined ? {} : { pixelAspectRatio }), + ...(rulerOrigin === undefined ? {} : { rulerOrigin }), + ...(bleed === undefined ? {} : { bleed }), + rawProperties: {}, + source: 'scene', + }) + } + return result +} + +function boundingBoxArtboard(source: string): NativeArtboard | undefined { + const match = new RegExp( + `^%%(?:HiRes)?BoundingBox:\\s*(${NUMBER})\\s+(${NUMBER})\\s+(${NUMBER})\\s+(${NUMBER})`, + 'imu', + ).exec(source) + if (match === null) return undefined + return { + id: 'bounding-box-artboard', + name: 'Artboard 1', + left: Number(match[1]), + top: Number(match[2]), + right: Number(match[3]), + bottom: Number(match[4]), + rawProperties: {}, + source: 'bounding-box', + } +} + +export function extractNativeArtboards( + source: string | Uint8Array, + scene?: unknown, + maximumSourceBytes = 64 * 1024 * 1024, +): readonly NativeArtboard[] { + const text = latin1SourceText(source, maximumSourceBytes) + const merged = artboardsFromScene(scene) + for (const candidate of artboardsFromSource(text)) { + const match = merged.find((existing) => + (candidate.uuid !== undefined && existing.uuid === candidate.uuid) + || ( + existing.name === candidate.name + && existing.left === candidate.left + && existing.top === candidate.top + && existing.right === candidate.right + && existing.bottom === candidate.bottom + ), + ) + if (match === undefined) { + merged.push(candidate) + continue + } + if (candidate.uuid !== undefined) match.uuid = candidate.uuid + if (candidate.selected !== undefined) match.selected = candidate.selected + if (candidate.locked !== undefined) match.locked = candidate.locked + if (candidate.pixelAspectRatio !== undefined) { + match.pixelAspectRatio = candidate.pixelAspectRatio + } + if (candidate.rulerOrigin !== undefined) { + match.rulerOrigin = candidate.rulerOrigin + } + if (candidate.bleed !== undefined) match.bleed = candidate.bleed + match.rawProperties = { + ...match.rawProperties, + ...candidate.rawProperties, + } + } + if (merged.length > 0) return merged + const fallback = boundingBoxArtboard(text) + return fallback === undefined ? [] : [fallback] +} + +export type NativeSourceSectionKind = + | 'header' + | 'prolog' + | 'setup' + | 'resource' + | 'drawing' + | 'fallback' + | 'trailer' + | 'unknown' + +export interface NativeSourceSection { + id: string + kind: NativeSourceSectionKind + startStatement: number + endStatement: number + startOffset?: number + endOffset?: number + rawHash: string + markers: readonly string[] +} + +export interface NativeSourceSectionMap { + sections: readonly NativeSourceSection[] + statementKinds: readonly NativeSourceSectionKind[] + diagnostics: readonly string[] +} + +const VISIBLE_OPERATORS = new Set([ + 'm', 'moveto', + 'l', 'L', 'lineto', + 'c', 'C', 'curveto', + 'v', 'y', + 'S', 's', 'stroke', + 'f', 'F', 'fill', 'f*', 'eofill', + 'B', 'B*', 'b', 'b*', + 'BT', 'Tj', 'TJ', 'Tx', 'To', + 'XI', 'XG', 'Xh', +]) + +function sourceStatementRaw(statement: Record): string { + return typeof statement.raw === 'string' ? statement.raw : '' +} + +function sourceStatementOffset( + statement: Record, + side: 'start' | 'end', +): number | undefined { + const span = asNativeRecord(statement.span) + const value = span?.[side] + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined +} + +function sourceMarkerKind( + raw: string, +): NativeSourceSectionKind | undefined { + if (/%%BeginProlog\b/iu.test(raw)) return 'prolog' + if (/%%BeginSetup\b/iu.test(raw)) return 'setup' + if ( + /%%BeginResource\b|%AI\d*_Begin(?:Gradient|Pattern|Symbol|Brush|Data)/iu.test(raw) + ) return 'resource' + if (/%%Trailer\b/iu.test(raw)) return 'trailer' + if ( + /%AI\d*_Begin(?:Fallback|PluginObject|ExpandedAppearance)/iu.test(raw) + ) return 'fallback' + return undefined +} + +function sourceEndMarker( + raw: string, + kind: NativeSourceSectionKind, +): boolean { + if (kind === 'prolog') return /%%EndProlog\b/iu.test(raw) + if (kind === 'setup') return /%%EndSetup\b/iu.test(raw) + if (kind === 'resource') { + return /%%EndResource\b|%AI\d*_End(?:Gradient|Pattern|Symbol|Brush|Data)/iu.test(raw) + } + if (kind === 'fallback') { + return /%AI\d*_End(?:Fallback|PluginObject|ExpandedAppearance)/iu.test(raw) + } + return false +} + +export function classifyNativeSourceSections( + ast: unknown, +): NativeSourceSectionMap { + const statements = nativeAstStatements(ast) + const statementKinds: NativeSourceSectionKind[] = [] + const diagnostics: string[] = [] + let current: NativeSourceSectionKind = 'header' + const stack: NativeSourceSectionKind[] = [] + let drawingStarted = false + for (const statement of statements) { + const record = asNativeRecord(statement) + const raw = record === undefined ? '' : sourceStatementRaw(record) + const begin = sourceMarkerKind(raw) + if (begin !== undefined) { + stack.push(current) + current = begin + } + const operator = typeof record?.operator === 'string' + ? record.operator + : undefined + if ( + !drawingStarted + && operator !== undefined + && VISIBLE_OPERATORS.has(operator) + && current !== 'resource' + && current !== 'prolog' + && current !== 'setup' + && current !== 'fallback' + ) { + drawingStarted = true + current = 'drawing' + } + if (/%%Trailer\b|%%EOF\b/iu.test(raw)) current = 'trailer' + statementKinds.push(current) + if (sourceEndMarker(raw, current)) { + current = stack.pop() ?? (drawingStarted ? 'drawing' : 'header') + } + } + if (stack.length > 0) { + diagnostics.push( + `Source section stack ended with ${stack.length} unterminated marker(s).`, + ) + } + const sections: NativeSourceSection[] = [] + let start = 0 + while (start < statements.length) { + const kind = statementKinds[start] ?? 'unknown' + let end = start + 1 + while (end < statements.length && statementKinds[end] === kind) end++ + const records = statements + .slice(start, end) + .map(asNativeRecord) + .filter((value): value is Record => value !== undefined) + const raw = records.map(sourceStatementRaw).join('') + const markers: string[] = [] + for (const record of records) { + const marker = /(?:%%|%AI\d*_)[A-Za-z0-9_:-]+/u.exec( + sourceStatementRaw(record), + )?.[0] + if (marker !== undefined && !markers.includes(marker)) markers.push(marker) + } + const first = records[0] + const last = records[records.length - 1] + const startOffset = first === undefined + ? undefined + : sourceStatementOffset(first, 'start') + const endOffset = last === undefined + ? undefined + : sourceStatementOffset(last, 'end') + sections.push({ + id: `${kind}:${start}:${nativeFNV1a(raw)}`, + kind, + startStatement: start, + endStatement: end, + ...(startOffset === undefined ? {} : { startOffset }), + ...(endOffset === undefined ? {} : { endOffset }), + rawHash: nativeFNV1a(raw), + markers, + }) + start = end + } + return { sections, statementKinds, diagnostics } +} + +export type NativeResourceKind = + | 'gradient' + | 'pattern' + | 'embedded-raster' + | 'placed-art' + | 'font' + | 'icc-profile' + | 'symbol' + | 'brush' + | 'effect' + | 'opacity-mask' + | 'unknown' + +export interface NativeResourceRecord { + id: string + kind: NativeResourceKind + name?: string + sourceOperator?: string + statementIndex?: number + raw?: string + dependencies: readonly string[] + metadata: Readonly> + fidelity: NativeFidelity +} + +export class NativeResourceGraph { + readonly #resources = new Map() + + add(resource: NativeResourceRecord): void { + const previous = this.#resources.get(resource.id) + if ( + previous !== undefined + && stableNativeSerialize(previous) !== stableNativeSerialize(resource) + ) { + throw new Error(`Resource ${resource.id} has conflicting definitions.`) + } + this.#resources.set(resource.id, resource) + } + + get(id: string): NativeResourceRecord | undefined { + return this.#resources.get(id) + } + + has(id: string): boolean { return this.#resources.has(id) } + + values(): readonly NativeResourceRecord[] { + return [...this.#resources.values()] + } + + validate(): readonly string[] { + const diagnostics: string[] = [] + for (const resource of this.#resources.values()) { + for (const dependency of resource.dependencies) { + if (!this.#resources.has(dependency)) { + diagnostics.push( + `Resource ${resource.id} references missing dependency ${dependency}.`, + ) + } + } + } + const visiting = new Set() + const visited = new Set() + const visit = (id: string, path: readonly string[]): void => { + if (visited.has(id)) return + if (visiting.has(id)) { + diagnostics.push( + `Resource cycle detected: ${[...path, id].join(' -> ')}.`, + ) + return + } + visiting.add(id) + const resource = this.#resources.get(id) + for (const dependency of resource?.dependencies ?? []) { + visit(dependency, [...path, id]) + } + visiting.delete(id) + visited.add(id) + } + for (const id of this.#resources.keys()) visit(id, []) + return diagnostics + } +} + +const RESOURCE_OPERATORS: Readonly> = { + Bd: 'gradient', + BD: 'gradient', + Bg: 'gradient', + BB: 'gradient', + PB: 'pattern', + PE: 'pattern', + AI9_BeginPattern: 'pattern', + AI9_EndPattern: 'pattern', + XI: 'embedded-raster', + BI: 'embedded-raster', + ID: 'embedded-raster', + XG: 'placed-art', + Xh: 'placed-art', + AI9_BeginSymbol: 'symbol', + AI9_EndSymbol: 'symbol', + AI9_BeginBrush: 'brush', + AI9_EndBrush: 'brush', + Ap: 'brush', + Ar: 'brush', + LiveEffect: 'effect', + AI9_BeginLiveEffect: 'effect', + AI9_EndLiveEffect: 'effect', + AI9_BeginOpacityMask: 'opacity-mask', + AI9_EndOpacityMask: 'opacity-mask', +} + +const BEGIN_RESOURCE_OPERATORS = new Set([ + 'Bd', + 'PB', + 'AI9_BeginPattern', + 'AI9_BeginSymbol', + 'AI9_BeginBrush', + 'AI9_BeginLiveEffect', + 'AI9_BeginOpacityMask', +]) + +const END_RESOURCE_OPERATORS = new Set([ + 'BD', + 'PE', + 'AI9_EndPattern', + 'AI9_EndSymbol', + 'AI9_EndBrush', + 'AI9_EndLiveEffect', + 'AI9_EndOpacityMask', +]) + +function sceneResourceEntries(scene: unknown): readonly [string, unknown][] { + const record = asNativeRecord(scene) + const resources = asNativeRecord(record?.resources) + return resources === undefined ? [] : Object.entries(resources) +} + +function normalizeResourceKind(value: unknown): NativeResourceKind { + const known: readonly NativeResourceKind[] = [ + 'gradient', + 'pattern', + 'embedded-raster', + 'placed-art', + 'font', + 'icc-profile', + 'symbol', + 'brush', + 'effect', + 'opacity-mask', + 'unknown', + ] + return typeof value === 'string' + && known.includes(value as NativeResourceKind) + ? value as NativeResourceKind + : 'unknown' +} + +function resourceFidelity(kind: NativeResourceKind): NativeFidelity { + if ( + kind === 'gradient' + || kind === 'pattern' + || kind === 'font' + || kind === 'icc-profile' + ) return 'partial' + return 'structure-only' +} + +export function buildNativeResourceGraph( + ast: unknown, + scene?: unknown, +): NativeResourceGraph { + const graph = new NativeResourceGraph() + const active = new Map() + nativeAstStatements(ast).forEach((statement, statementIndex) => { + const record = asNativeRecord(statement) + if (record?.kind !== 'operator' || typeof record.operator !== 'string') return + const kind = RESOURCE_OPERATORS[record.operator] + if (kind === undefined) return + const operands = Array.isArray(record.operands) ? record.operands : [] + const name = operands + .map(nativeString) + .find((value) => value !== undefined) + const raw = typeof record.raw === 'string' + ? record.raw + : stableNativeSerialize(statement) + const id = `${kind}:${nativeFNV1a(`${statementIndex}:${raw}`)}` + const dependencies = [...active.values()] + .flatMap((ids) => ids.slice(-1)) + .filter((dependency) => dependency !== id) + const numbers = operands + .map(nativeNumber) + .filter((value): value is number => value !== undefined) + graph.add({ + id, + kind, + ...(name === undefined ? {} : { name }), + sourceOperator: record.operator, + statementIndex, + raw, + dependencies, + metadata: { + operator: record.operator, + operandCount: operands.length, + numericOperands: numbers.length, + }, + fidelity: resourceFidelity(kind), + }) + if (BEGIN_RESOURCE_OPERATORS.has(record.operator)) { + const stack = active.get(kind) + if (stack === undefined) active.set(kind, [id]) + else stack.push(id) + } + if (END_RESOURCE_OPERATORS.has(record.operator)) { + active.get(kind)?.pop() + } + }) + for (const [key, value] of sceneResourceEntries(scene)) { + const record = asNativeRecord(value) + const kind = normalizeResourceKind(record?.kind) + const id = `scene:${key}` + graph.add({ + id, + kind, + name: key, + dependencies: [], + metadata: { source: 'scene-ir' }, + fidelity: resourceFidelity(kind), + raw: stableNativeSerialize(value), + }) + } + return graph +} + +export type ExternalResourcePolicy = + | 'deny' + | 'resolver-only' + | 'same-origin' + | 'allow-list' + +export interface NativeResourceRequest { + id: string + url?: string + expectedBytes?: number + mime?: string +} + +export interface NativeResolvedResource { + id: string + bytes: Uint8Array + mime?: string + source: 'embedded' | 'resolver' +} + +export interface NativeResourceResolverOptions { + policy?: ExternalResourcePolicy + baseUrl?: string + allowedOrigins?: readonly string[] + maximumBytes?: number + resolve?: ( + request: NativeResourceRequest, + signal?: AbortSignal, + ) => Promise +} + +export function nativeResourceUrlPermitted( + url: string, + options: NativeResourceResolverOptions, +): boolean { + const policy = options.policy ?? 'deny' + if (policy === 'deny' || policy === 'resolver-only') return false + let parsed: URL + try { + parsed = new URL(url, options.baseUrl) + } catch { + return false + } + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return false + if (policy === 'same-origin') { + if (options.baseUrl === undefined) return false + return parsed.origin === new URL(options.baseUrl).origin + } + return options.allowedOrigins?.includes(parsed.origin) === true +} + +export async function resolveNativeResource( + request: NativeResourceRequest, + options: NativeResourceResolverOptions = {}, + signal?: AbortSignal, +): Promise { + if (signal?.aborted === true) { + throw new DOMException('Resource resolution aborted.', 'AbortError') + } + if ( + request.url !== undefined + && !nativeResourceUrlPermitted(request.url, options) + && options.resolve === undefined + ) { + throw new Error( + `External resource ${request.id} is denied by the ${options.policy ?? 'deny'} policy.`, + ) + } + if (options.resolve === undefined) { + throw new Error( + `No explicit resolver is configured for resource ${request.id}.`, + ) + } + const bytes = await options.resolve(request, signal) + if (bytes === undefined) { + throw new Error(`Resource resolver did not provide ${request.id}.`) + } + const maximumBytes = options.maximumBytes ?? 64 * 1024 * 1024 + if (bytes.byteLength > maximumBytes) { + throw new RangeError( + `Resource ${request.id} exceeds the ${maximumBytes}-byte limit.`, + ) + } + if (signal?.aborted === true) { + throw new DOMException('Resource resolution aborted.', 'AbortError') + } + return { + id: request.id, + bytes, + ...(request.mime === undefined ? {} : { mime: request.mime }), + source: 'resolver', + } +} + +export interface NativeGradientStop { + offset: number + midpoint?: number + color: Readonly<{ + space: 'gray' | 'rgb' | 'cmyk' | 'spot' | 'unknown' + components: readonly number[] + name?: string + tint?: number + }> +} + +export interface NativeGradientDefinition { + id: string + name: string + gradientType: 'linear' | 'radial' | 'freeform' | 'unknown' + matrix?: readonly number[] + stops: readonly NativeGradientStop[] + raw: string + fidelity: NativeFidelity +} + +export interface NativePatternDefinition { + id: string + name: string + paintType?: number + tilingType?: number + bounds?: readonly number[] + matrix?: readonly number[] + raw: string + fidelity: NativeFidelity +} + +export interface NativeRasterDefinition { + id: string + width?: number + height?: number + bitsPerComponent?: number + colorSpace?: string + format: 'png' | 'jpeg' | 'tiff' | 'gif' | 'raw' | 'unknown' + embeddedBytes?: Uint8Array + externalReference?: string + raw: string + fidelity: NativeFidelity +} + +export interface NativeColorResource { + id: string + kind: 'process' | 'spot' | 'icc-profile' + name: string + components?: readonly number[] + profileName?: string + fidelity: NativeFidelity +} + +export interface NativeDecodedResources { + gradients: readonly NativeGradientDefinition[] + patterns: readonly NativePatternDefinition[] + rasters: readonly NativeRasterDefinition[] + colors: readonly NativeColorResource[] + diagnostics: readonly string[] +} + +function statementOperator(statement: unknown): string | undefined { + const record = asNativeRecord(statement) + return typeof record?.operator === 'string' ? record.operator : undefined +} + +function statementOperands(statement: unknown): readonly unknown[] { + const record = asNativeRecord(statement) + return Array.isArray(record?.operands) ? record.operands : [] +} + +function statementRaw(statement: unknown): string { + const record = asNativeRecord(statement) + return typeof record?.raw === 'string' ? record.raw : '' +} + +function numericOperands(statement: unknown): number[] { + return statementOperands(statement) + .map(nativeNumber) + .filter((value): value is number => value !== undefined) +} + +function firstStringOperand(statement: unknown): string | undefined { + return statementOperands(statement) + .map(nativeString) + .find((value) => value !== undefined) +} + +function bytesFromValue(value: unknown): Uint8Array | undefined { + if (value instanceof Uint8Array) return value + const record = asNativeRecord(value) + if (record === undefined) return undefined + if (record.value instanceof Uint8Array) return record.value + if (record.bytes instanceof Uint8Array) return record.bytes + if (Array.isArray(record.values)) { + const chunks = record.values + .map(bytesFromValue) + .filter((entry): entry is Uint8Array => entry !== undefined) + if (chunks.length === 0) return undefined + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const result = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result + } + return undefined +} + +function statementBytes(statement: unknown): Uint8Array | undefined { + const chunks = statementOperands(statement) + .map(bytesFromValue) + .filter((value): value is Uint8Array => value !== undefined) + if (chunks.length === 0) return undefined + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0) + const result = new Uint8Array(total) + let offset = 0 + for (const chunk of chunks) { + result.set(chunk, offset) + offset += chunk.byteLength + } + return result +} + +function rasterFormat( + bytes: Uint8Array | undefined, +): NativeRasterDefinition['format'] { + if (bytes === undefined || bytes.byteLength === 0) return 'unknown' + if ( + bytes.byteLength >= 8 + && bytes[0] === 0x89 + && bytes[1] === 0x50 + && bytes[2] === 0x4e + && bytes[3] === 0x47 + ) return 'png' + if ( + bytes.byteLength >= 2 + && bytes[0] === 0xff + && bytes[1] === 0xd8 + ) return 'jpeg' + if ( + bytes.byteLength >= 4 + && ( + ( + bytes[0] === 0x49 + && bytes[1] === 0x49 + && bytes[2] === 0x2a + && bytes[3] === 0x00 + ) + || ( + bytes[0] === 0x4d + && bytes[1] === 0x4d + && bytes[2] === 0x00 + && bytes[3] === 0x2a + ) + ) + ) return 'tiff' + if ( + bytes.byteLength >= 6 + && latin1SourceText(bytes.subarray(0, 6)) === 'GIF89a' + ) return 'gif' + return 'raw' +} + +function gradientStops(raw: string): NativeGradientStop[] { + const result: NativeGradientStop[] = [] + for (const match of raw.matchAll( + /([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s+([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)\s+%_?BS\b/gu, + )) { + result.push({ + offset: Math.max(0, Math.min(1, Number(match[1]))), + midpoint: Math.max(0, Math.min(1, Number(match[2]))), + color: { space: 'unknown', components: [] }, + }) + } + return result.sort((left, right) => left.offset - right.offset) +} + +function decodeGradients( + statements: readonly unknown[], +): NativeGradientDefinition[] { + const result: NativeGradientDefinition[] = [] + let start = -1 + let name = '' + let beginNumbers: number[] = [] + for (let index = 0; index < statements.length; index++) { + const operator = statementOperator(statements[index]) + if (operator === 'Bd' || operator === 'Bg') { + start = index + name = firstStringOperand(statements[index]) + ?? `Gradient ${result.length + 1}` + beginNumbers = numericOperands(statements[index]) + continue + } + if ((operator === 'BD' || operator === 'BB') && start >= 0) { + const raw = statements + .slice(start, index + 1) + .map(statementRaw) + .join('') + const typeCode = beginNumbers[beginNumbers.length - 1] + const gradientType = typeCode === 1 + ? 'radial' + : typeCode === 0 + ? 'linear' + : 'unknown' + const matrix = beginNumbers.length >= 6 + ? beginNumbers.slice(0, 6) + : undefined + const stops = gradientStops(raw) + result.push({ + id: `gradient:${nativeFNV1a(`${start}:${raw}`)}`, + name, + gradientType, + ...(matrix === undefined ? {} : { matrix }), + stops, + raw, + fidelity: stops.length >= 2 ? 'partial' : 'structure-only', + }) + start = -1 + name = '' + beginNumbers = [] + } + } + return result +} + +function decodePatterns( + statements: readonly unknown[], +): NativePatternDefinition[] { + const result: NativePatternDefinition[] = [] + let start = -1 + let name = '' + let numbers: number[] = [] + for (let index = 0; index < statements.length; index++) { + const operator = statementOperator(statements[index]) + if (operator === 'PB' || operator === 'AI9_BeginPattern') { + start = index + name = firstStringOperand(statements[index]) + ?? `Pattern ${result.length + 1}` + numbers = numericOperands(statements[index]) + continue + } + if ( + (operator === 'PE' || operator === 'AI9_EndPattern') + && start >= 0 + ) { + const raw = statements + .slice(start, index + 1) + .map(statementRaw) + .join('') + const paintType = numbers[0] + const tilingType = numbers[1] + result.push({ + id: `pattern:${nativeFNV1a(`${start}:${raw}`)}`, + name, + ...(paintType === undefined ? {} : { paintType }), + ...(tilingType === undefined ? {} : { tilingType }), + ...(numbers.length < 6 ? {} : { bounds: numbers.slice(2, 6) }), + ...(numbers.length < 12 ? {} : { matrix: numbers.slice(6, 12) }), + raw, + fidelity: 'structure-only', + }) + start = -1 + name = '' + numbers = [] + } + } + return result +} + +function decodeRasters( + statements: readonly unknown[], +): NativeRasterDefinition[] { + const result: NativeRasterDefinition[] = [] + for (let index = 0; index < statements.length; index++) { + const statement = statements[index] + const operator = statementOperator(statement) + if ( + operator !== 'XI' + && operator !== 'XG' + && operator !== 'Xh' + && operator !== 'BI' + ) continue + const numbers = numericOperands(statement) + const strings = statementOperands(statement) + .map(nativeString) + .filter((value): value is string => value !== undefined) + const bytes = statementBytes(statement) + const raw = statementRaw(statement) + const externalReference = strings.find((value) => + /^(?:https?|file):\/\//iu.test(value) + || /[\\/]/u.test(value), + ) + const width = numbers[0] + const height = numbers[1] + const bitsPerComponent = numbers[2] + const colorSpace = strings[0] + result.push({ + id: `raster:${nativeFNV1a(`${index}:${raw || stableNativeSerialize(statement)}`)}`, + ...(width === undefined ? {} : { width: Math.max(0, width) }), + ...(height === undefined ? {} : { height: Math.max(0, height) }), + ...(bitsPerComponent === undefined ? {} : { bitsPerComponent }), + ...(colorSpace === undefined ? {} : { colorSpace }), + format: rasterFormat(bytes), + ...(bytes === undefined ? {} : { embeddedBytes: bytes }), + ...(externalReference === undefined ? {} : { externalReference }), + raw, + fidelity: bytes === undefined ? 'structure-only' : 'partial', + }) + } + return result +} + +function decodeColors( + source: string | Uint8Array, + statements: readonly unknown[], +): NativeColorResource[] { + const result: NativeColorResource[] = [] + const text = latin1SourceText(source, 16 * 1024 * 1024) + for (const match of text.matchAll( + /^%%DocumentCustomColors:\s*\(([^)]*)\)/gmu, + )) { + const name = match[1]?.trim() + if (name === undefined || name === '') continue + result.push({ + id: `spot:${nativeFNV1a(name)}`, + kind: 'spot', + name, + fidelity: 'partial', + }) + } + for (const match of text.matchAll( + /^%AI\d*_(?:ProfileName|ICCProfile):\s*(.+)$/gmu, + )) { + const profileName = match[1]?.trim() + if (profileName === undefined || profileName === '') continue + result.push({ + id: `icc:${nativeFNV1a(profileName)}`, + kind: 'icc-profile', + name: profileName, + profileName, + fidelity: 'structure-only', + }) + } + for (let index = 0; index < statements.length; index++) { + const operator = statementOperator(statements[index]) + if ( + !['k', 'K', 'rg', 'RG', 'g', 'G', 'x', 'X', 'Xk', 'XK'] + .includes(operator ?? '') + ) continue + const components = numericOperands(statements[index]) + const name = firstStringOperand(statements[index]) + ?? operator + ?? 'process' + const spot = operator === 'x' + || operator === 'X' + || operator === 'Xk' + || operator === 'XK' + result.push({ + id: `color:${nativeFNV1a(`${index}:${statementRaw(statements[index])}`)}`, + kind: spot ? 'spot' : 'process', + name, + components, + fidelity: spot ? 'partial' : 'high', + }) + } + return result +} + +export function decodeNativeResources( + source: string | Uint8Array, + ast: unknown, + graph?: NativeResourceGraph, +): NativeDecodedResources { + const statements = nativeAstStatements(ast) + const gradients = decodeGradients(statements) + const patterns = decodePatterns(statements) + const rasters = decodeRasters(statements) + const colors = decodeColors(source, statements) + const diagnostics: string[] = [] + for (const gradient of gradients) { + if (gradient.stops.length < 2) { + diagnostics.push( + `Gradient ${gradient.name} was retained but lacks two decoded stops.`, + ) + } + } + for (const pattern of patterns) { + diagnostics.push( + `Pattern ${pattern.name} retains its native program; tiling render remains evidence-gated.`, + ) + } + for (const raster of rasters) { + if ( + raster.embeddedBytes === undefined + && raster.externalReference === undefined + ) { + diagnostics.push( + `Raster ${raster.id} has no decoded embedded payload or explicit external reference.`, + ) + } + } + if (graph !== undefined) diagnostics.push(...graph.validate()) + return { gradients, patterns, rasters, colors, diagnostics } +} + +export interface NativeTextRunModel { + text: string + fontPostScriptName?: string + fontSize?: number + tracking?: number + horizontalScale?: number + baselineShift?: number + fill?: unknown + stroke?: unknown + raw: unknown +} + +export interface NativeTextFrameModel { + id: string + kind: 'point' | 'area' | 'path' | 'unknown' + storyId: string + threadPrevious?: string + threadNext?: string + matrix?: readonly number[] + bounds?: NativeBounds + pathNodeId?: string + runs: readonly NativeTextRunModel[] + fidelity: NativeFidelity +} + +export interface NativeTextStoryModel { + id: string + frameIds: readonly string[] + text: string +} + +export interface NativeTextModel { + stories: readonly NativeTextStoryModel[] + frames: readonly NativeTextFrameModel[] + requiredFonts: readonly string[] + diagnostics: readonly string[] +} + +function textKind( + node: Record, +): NativeTextFrameModel['kind'] { + const raw = typeof node.textKind === 'string' + ? node.textKind + : typeof node.kind === 'string' + ? node.kind + : 'unknown' + if (raw === 'point' || raw === 'area' || raw === 'path') return raw + return 'unknown' +} + +function nativeTextRun( + raw: unknown, + requiredFonts: Set, +): NativeTextRunModel { + const record = asNativeRecord(raw) + const text = typeof record?.text === 'string' ? record.text : '' + const fontPostScriptName = typeof record?.fontPostScriptName === 'string' + ? record.fontPostScriptName + : typeof record?.fontFamily === 'string' + ? record.fontFamily + : undefined + if (fontPostScriptName !== undefined) requiredFonts.add(fontPostScriptName) + const fontSize = nativeNumber(record?.fontSize) + const tracking = nativeNumber(record?.tracking) + const horizontalScale = nativeNumber(record?.horizontalScale) + const baselineShift = nativeNumber(record?.baselineShift) + return { + text, + ...(fontPostScriptName === undefined ? {} : { fontPostScriptName }), + ...(fontSize === undefined ? {} : { fontSize }), + ...(tracking === undefined ? {} : { tracking }), + ...(horizontalScale === undefined ? {} : { horizontalScale }), + ...(baselineShift === undefined ? {} : { baselineShift }), + ...(record?.fill === undefined ? {} : { fill: record.fill }), + ...(record?.stroke === undefined ? {} : { stroke: record.stroke }), + raw, + } +} + +export function buildNativeTextModel(scene: unknown): NativeTextModel { + const frames: NativeTextFrameModel[] = [] + const requiredFonts = new Set() + const diagnostics: string[] = [] + walkNativeScene(scene, (node) => { + if (node.type !== 'Text') return + const id = typeof node.id === 'string' + ? node.id + : `text:${frames.length}` + const storyId = typeof node.storyId === 'string' + ? node.storyId + : `story:${id}` + const kind = textKind(node) + const rawRuns = Array.isArray(node.runs) ? node.runs : [] + const runs = rawRuns.map((raw) => nativeTextRun(raw, requiredFonts)) + const matrix = Array.isArray(node.matrix) + ? node.matrix.filter((entry): entry is number => + typeof entry === 'number' && Number.isFinite(entry), + ) + : undefined + const bounds = nativeBoundsFrom(node.bounds) + const threadPrevious = typeof node.threadPrevious === 'string' + ? node.threadPrevious + : undefined + const threadNext = typeof node.threadNext === 'string' + ? node.threadNext + : undefined + const pathNodeId = typeof node.pathNodeId === 'string' + ? node.pathNodeId + : undefined + if (kind !== 'point') { + diagnostics.push( + `Text frame ${id} preserves ${kind} text structure but needs layout Oracle evidence.`, + ) + } + frames.push({ + id, + kind, + storyId, + ...(threadPrevious === undefined ? {} : { threadPrevious }), + ...(threadNext === undefined ? {} : { threadNext }), + ...(matrix === undefined ? {} : { matrix }), + ...(bounds === undefined ? {} : { bounds }), + ...(pathNodeId === undefined ? {} : { pathNodeId }), + runs, + fidelity: kind === 'point' ? 'partial' : 'structure-only', + }) + }) + const storyFrames = new Map() + for (const frame of frames) { + const existing = storyFrames.get(frame.storyId) + if (existing === undefined) storyFrames.set(frame.storyId, [frame]) + else existing.push(frame) + } + const stories = [...storyFrames.entries()].map(([id, values]) => ({ + id, + frameIds: values.map((frame) => frame.id), + text: values + .flatMap((frame) => frame.runs) + .map((run) => run.text) + .join(''), + })) + return { + stories, + frames, + requiredFonts: [...requiredFonts].sort(), + diagnostics, + } +} + +export function estimateNativeTextBounds( + run: NativeTextRunModel, + origin: Readonly<{ x: number; y: number }>, +): NativeBounds { + const size = Math.max(0, run.fontSize ?? 12) + const scale = Math.max(0, run.horizontalScale ?? 100) / 100 + const tracking = run.tracking ?? 0 + const glyphAdvance = size * 0.6 * scale + const width = Math.max( + 0, + run.text.length * glyphAdvance + + Math.max(0, run.text.length - 1) * tracking / 1000 * size, + ) + const ascent = size * 0.8 + const descent = size * 0.2 + const shift = run.baselineShift ?? 0 + return { + left: origin.x, + top: origin.y - ascent - shift, + right: origin.x + width, + bottom: origin.y + descent - shift, + } +} + +export interface NativeTransparencyRecord { + id: string + nodeId?: string + opacity: number + fillOpacity: number + strokeOpacity: number + blendMode: string + isolated: boolean + knockout: boolean + maskResourceId?: string + maskMode?: 'alpha' | 'luminosity' | 'unknown' + fidelity: NativeFidelity +} + +export interface NativeTransparencyModel { + records: readonly NativeTransparencyRecord[] + diagnostics: readonly string[] +} + +export function buildNativeTransparencyModel( + scene: unknown, +): NativeTransparencyModel { + const records: NativeTransparencyRecord[] = [] + const diagnostics: string[] = [] + walkNativeScene(scene, (node) => { + const appearance = asNativeRecord(node.appearance) + const opacity = nativeNumber(appearance?.opacity) + ?? nativeNumber(node.opacity) + ?? 1 + const fillOpacity = nativeNumber(appearance?.fillOpacity) ?? opacity + const strokeOpacity = nativeNumber(appearance?.strokeOpacity) ?? opacity + const blendMode = nativeString(appearance?.blendMode) + ?? nativeString(node.blendMode) + ?? 'normal' + const isolated = nativeBoolean(appearance?.isolated) + ?? nativeBoolean(node.isolated) + ?? false + const knockout = nativeBoolean(appearance?.knockout) + ?? nativeBoolean(node.knockout) + ?? false + const maskResourceId = typeof appearance?.maskResourceId === 'string' + ? appearance.maskResourceId + : typeof node.maskResourceId === 'string' + ? node.maskResourceId + : undefined + const rawMaskMode = nativeString(appearance?.maskMode) + ?? nativeString(node.maskMode) + const maskMode: NativeTransparencyRecord['maskMode'] = ( + rawMaskMode === 'alpha' + || rawMaskMode === 'luminosity' + ) + ? rawMaskMode + : rawMaskMode === undefined + ? undefined + : 'unknown' + if ( + opacity === 1 + && fillOpacity === 1 + && strokeOpacity === 1 + && blendMode === 'normal' + && !isolated + && !knockout + && maskResourceId === undefined + ) return + const nodeId = typeof node.id === 'string' ? node.id : undefined + records.push({ + id: `transparency:${nodeId ?? records.length}`, + ...(nodeId === undefined ? {} : { nodeId }), + opacity, + fillOpacity, + strokeOpacity, + blendMode, + isolated, + knockout, + ...(maskResourceId === undefined ? {} : { maskResourceId }), + ...(maskMode === undefined ? {} : { maskMode }), + fidelity: maskResourceId === undefined && !knockout && !isolated + ? 'partial' + : 'structure-only', + }) + if (maskResourceId !== undefined) { + diagnostics.push( + `Opacity mask ${maskResourceId} is preserved structurally and requires compositing Oracle evidence.`, + ) + } + if (knockout) { + diagnostics.push( + `Knockout group ${nodeId ?? records.length - 1} remains structure-only.`, + ) + } + }) + return { records, diagnostics } +} + +export function canvasCompositeOperationForBlendMode( + blendMode: string, +): GlobalCompositeOperation | undefined { + const normalized = blendMode + .trim() + .toLowerCase() + .replace(/[ _]/gu, '-') + const supported: Readonly> = { + normal: 'source-over', + multiply: 'multiply', + screen: 'screen', + overlay: 'overlay', + darken: 'darken', + lighten: 'lighten', + 'color-dodge': 'color-dodge', + 'color-burn': 'color-burn', + 'hard-light': 'hard-light', + 'soft-light': 'soft-light', + difference: 'difference', + exclusion: 'exclusion', + hue: 'hue', + saturation: 'saturation', + color: 'color', + luminosity: 'luminosity', + } + return supported[normalized] +} + +export type NativeAdvancedObjectKind = + | 'symbol-definition' + | 'symbol-instance' + | 'brush-definition' + | 'brush-stroke' + | 'live-effect' + | 'plugin-object' + | 'expanded-fallback' + +export interface NativeAdvancedObjectRecord { + id: string + kind: NativeAdvancedObjectKind + nodeId?: string + resourceId?: string + operator?: string + parameters: unknown + hasExpandedFallback: boolean + fallbackNodeIds: readonly string[] + visibleImpact: boolean + fidelity: NativeFidelity +} + +export interface NativeAdvancedObjectModel { + objects: readonly NativeAdvancedObjectRecord[] + diagnostics: readonly string[] +} + +function advancedKindForResource( + kind: NativeResourceKind, +): NativeAdvancedObjectKind | undefined { + if (kind === 'symbol') return 'symbol-definition' + if (kind === 'brush') return 'brush-definition' + if (kind === 'effect') return 'live-effect' + return undefined +} + +function fallbackIds(node: Record): string[] { + const candidates = [ + node.expandedAppearance, + node.fallbackAppearance, + node.fallback, + node.appearanceFallback, + ] + const result: string[] = [] + for (const candidate of candidates) { + const record = asNativeRecord(candidate) + if (typeof record?.id === 'string') result.push(record.id) + if (Array.isArray(candidate)) { + for (const item of candidate) { + const itemRecord = asNativeRecord(item) + if (typeof itemRecord?.id === 'string') result.push(itemRecord.id) + } + } + } + return [...new Set(result)] +} + +function sceneAdvancedKind( + node: Record, +): NativeAdvancedObjectKind | undefined { + if (node.type === 'SymbolInstance') return 'symbol-instance' + if (node.type === 'BrushStroke') return 'brush-stroke' + if (node.type === 'LiveEffect' || node.type === 'Effect') { + return 'live-effect' + } + if (node.type === 'PluginObject') return 'plugin-object' + return undefined +} + +export function buildNativeAdvancedObjectModel( + ast: unknown, + scene: unknown, + resources: NativeResourceGraph, +): NativeAdvancedObjectModel { + const objects: NativeAdvancedObjectRecord[] = [] + const diagnostics: string[] = [] + for (const resource of resources.values()) { + const kind = advancedKindForResource(resource.kind) + if (kind === undefined) continue + objects.push({ + id: `advanced:${resource.id}`, + kind, + resourceId: resource.id, + parameters: resource.metadata, + hasExpandedFallback: false, + fallbackNodeIds: [], + visibleImpact: false, + fidelity: resource.fidelity, + }) + } + walkNativeScene(scene, (node) => { + const kind = sceneAdvancedKind(node) + if (kind === undefined) return + const id = typeof node.id === 'string' + ? node.id + : nativeFNV1a(stableNativeSerialize(node)) + const fallbacks = fallbackIds(node) + const resourceId = typeof node.resourceId === 'string' + ? node.resourceId + : typeof node.symbolId === 'string' + ? node.symbolId + : typeof node.brushId === 'string' + ? node.brushId + : undefined + const hasExpandedFallback = fallbacks.length > 0 + || (Array.isArray(node.children) && node.children.length > 0) + objects.push({ + id: `advanced-node:${id}`, + kind, + nodeId: id, + ...(resourceId === undefined ? {} : { resourceId }), + parameters: node.parameters ?? node.effectParameters ?? node, + hasExpandedFallback, + fallbackNodeIds: fallbacks, + visibleImpact: node.visible !== false, + fidelity: hasExpandedFallback ? 'partial' : 'structure-only', + }) + if (!hasExpandedFallback && node.visible !== false) { + diagnostics.push( + `${kind} ${id} has no expanded appearance and remains structure-only.`, + ) + } + }) + nativeAstStatements(ast).forEach((statement, statementIndex) => { + const record = asNativeRecord(statement) + if (record?.kind !== 'operator' || typeof record.operator !== 'string') return + if (!/(?:plugin|liveeffect|effect|symbol|brush)/iu.test(record.operator)) { + return + } + const raw = typeof record.raw === 'string' + ? record.raw + : stableNativeSerialize(statement) + const exists = objects.some((entry) => + entry.operator === record.operator + && entry.id.endsWith(nativeFNV1a(`${statementIndex}:${raw}`)), + ) + if (exists) return + const plugin = /plugin/iu.test(record.operator) + objects.push({ + id: `advanced-operator:${nativeFNV1a(`${statementIndex}:${raw}`)}`, + kind: plugin ? 'plugin-object' : 'live-effect', + operator: record.operator, + parameters: Array.isArray(record.operands) ? record.operands : [], + hasExpandedFallback: false, + fallbackNodeIds: [], + visibleImpact: true, + fidelity: 'structure-only', + }) + diagnostics.push( + `${record.operator} is preserved losslessly; executable plugin or effect code is never run.`, + ) + }) + return { objects, diagnostics } +} diff --git a/src/native-foundation.ts b/src/native-foundation.ts new file mode 100644 index 0000000..d80f4ab --- /dev/null +++ b/src/native-foundation.ts @@ -0,0 +1,865 @@ +export type NativeFidelity = + | 'exact' + | 'high' + | 'partial' + | 'structure-only' + | 'unsupported' + +export type NativeEvidenceKind = + | 'synthetic-fixture' + | 'real-illustrator-fixture' + | 'structure-oracle' + | 'visual-oracle' + | 'performance-budget' + | 'security-budget' + | 'cross-browser' + +export interface NativeFidelityEvidence { + id: string + kind: NativeEvidenceKind + status: 'passed' | 'failed' | 'missing' + versions?: readonly string[] + source?: string + notes?: string +} + +export interface NativeFidelityDecision { + requested: NativeFidelity + effective: NativeFidelity + promotable: boolean + requiredEvidence: readonly NativeEvidenceKind[] + missingEvidence: readonly NativeEvidenceKind[] + failedEvidence: readonly NativeEvidenceKind[] + evidence: readonly NativeFidelityEvidence[] +} + +const FIDELITY_ORDER: readonly NativeFidelity[] = [ + 'unsupported', + 'structure-only', + 'partial', + 'high', + 'exact', +] + +export const NATIVE_FIDELITY_EVIDENCE_REQUIREMENTS: Readonly< + Record +> = { + unsupported: [], + 'structure-only': [], + partial: ['synthetic-fixture', 'security-budget'], + high: [ + 'synthetic-fixture', + 'real-illustrator-fixture', + 'structure-oracle', + 'visual-oracle', + 'performance-budget', + 'security-budget', + ], + exact: [ + 'synthetic-fixture', + 'real-illustrator-fixture', + 'structure-oracle', + 'visual-oracle', + 'performance-budget', + 'security-budget', + 'cross-browser', + ], +} + +export function nativeFidelityRank(value: NativeFidelity): number { + return FIDELITY_ORDER.indexOf(value) +} + +export function isNativeFidelity(value: unknown): value is NativeFidelity { + return typeof value === 'string' + && FIDELITY_ORDER.includes(value as NativeFidelity) +} + +export function nativeFidelityAtMost( + value: NativeFidelity, + maximum: NativeFidelity, +): NativeFidelity { + return nativeFidelityRank(value) <= nativeFidelityRank(maximum) + ? value + : maximum +} + +export function mergeNativeFidelity( + values: readonly NativeFidelity[], +): NativeFidelity { + let result: NativeFidelity = 'exact' + for (const value of values) { + if (nativeFidelityRank(value) < nativeFidelityRank(result)) result = value + } + return result +} + +export function resolveNativeFidelity( + requested: NativeFidelity, + evidence: readonly NativeFidelityEvidence[], + requirements: Readonly< + Partial> + > = {}, +): NativeFidelityDecision { + const requiredEvidence = requirements[requested] + ?? NATIVE_FIDELITY_EVIDENCE_REQUIREMENTS[requested] + const byKind = new Map() + for (const entry of evidence) { + const existing = byKind.get(entry.kind) + if (existing === undefined) byKind.set(entry.kind, [entry]) + else existing.push(entry) + } + const missingEvidence = requiredEvidence.filter((kind) => { + const entries = byKind.get(kind) + return entries === undefined + || entries.every((entry) => entry.status === 'missing') + }) + const failedEvidence = requiredEvidence.filter((kind) => + byKind.get(kind)?.some((entry) => entry.status === 'failed') === true, + ) + let effective = requested + if (failedEvidence.length > 0) { + effective = nativeFidelityAtMost(effective, 'structure-only') + } else if ( + missingEvidence.length > 0 + && nativeFidelityRank(effective) >= nativeFidelityRank('high') + ) { + effective = 'partial' + } else if (missingEvidence.length > 0 && effective === 'partial') { + effective = 'structure-only' + } + return { + requested, + effective, + promotable: missingEvidence.length === 0 && failedEvidence.length === 0, + requiredEvidence, + missingEvidence, + failedEvidence, + evidence: [...evidence], + } +} + +export function asNativeRecord( + value: unknown, +): Record | undefined { + return typeof value === 'object' && value !== null + ? value as Record + : undefined +} + +export function nativeString(value: unknown): string | undefined { + if (typeof value === 'string') return value + const record = asNativeRecord(value) + return typeof record?.value === 'string' ? record.value : undefined +} + +export function nativeNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value + const record = asNativeRecord(value) + return typeof record?.value === 'number' && Number.isFinite(record.value) + ? record.value + : undefined +} + +export function nativeBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value + const record = asNativeRecord(value) + return typeof record?.value === 'boolean' ? record.value : undefined +} + +export function latin1SourceText( + input: string | Uint8Array, + maximumBytes = Number.POSITIVE_INFINITY, +): string { + if (typeof input === 'string') return input.slice(0, maximumBytes) + const length = Math.min(input.byteLength, maximumBytes) + const chunkSize = 32 * 1024 + const chunks: string[] = [] + for (let offset = 0; offset < length; offset += chunkSize) { + const end = Math.min(length, offset + chunkSize) + let chunk = '' + for (let index = offset; index < end; index++) { + chunk += String.fromCharCode(input[index] ?? 0) + } + chunks.push(chunk) + } + return chunks.join('') +} + +export function nativeFNV1a(value: string): string { + let hash = 0x811c9dc5 + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index) + hash = Math.imul(hash, 0x01000193) >>> 0 + } + return hash.toString(16).padStart(8, '0') +} + +export function stableNativeSerialize( + value: unknown, + seen = new Set(), +): string { + if (value === undefined) return '"[Undefined]"' + if (typeof value === 'bigint') return JSON.stringify(`${value.toString()}n`) + if (typeof value === 'number' && !Number.isFinite(value)) { + return JSON.stringify(String(value)) + } + if (value === null || typeof value !== 'object') { + return JSON.stringify(value) ?? 'null' + } + if (seen.has(value)) return '"[Circular]"' + seen.add(value) + if (value instanceof Uint8Array) { + const serialized = `{"$bytes":"${Array.from( + value, + (entry) => entry.toString(16).padStart(2, '0'), + ).join('')}"}` + seen.delete(value) + return serialized + } + if (Array.isArray(value)) { + const serialized = `[${value + .map((entry) => stableNativeSerialize(entry, seen)) + .join(',')}]` + seen.delete(value) + return serialized + } + const record = value as Record + const serialized = `{${Object.keys(record) + .sort() + .map((key) => + `${JSON.stringify(key)}:${stableNativeSerialize(record[key], seen)}`, + ) + .join(',')}}` + seen.delete(value) + return serialized +} + +export function nativeAstStatements(ast: unknown): readonly unknown[] { + const record = asNativeRecord(ast) + return Array.isArray(record?.statements) ? record.statements : [] +} + +export function nativeBoundsFrom( + value: unknown, +): NativeBounds | undefined { + const record = asNativeRecord(value) + const left = nativeNumber(record?.left) + const top = nativeNumber(record?.top) + const right = nativeNumber(record?.right) + const bottom = nativeNumber(record?.bottom) + if ( + left === undefined + || top === undefined + || right === undefined + || bottom === undefined + ) return undefined + return { left, top, right, bottom } +} + +export function walkNativeScene( + scene: unknown, + visit: ( + node: Record, + parent?: Record, + ) => void, +): void { + const root = asNativeRecord(scene) + const roots = Array.isArray(root?.children) + ? root.children + : Array.isArray(root?.nodes) + ? root.nodes + : Array.isArray(root?.layers) + ? root.layers + : [] + const seen = new Set() + const walk = ( + value: unknown, + parent?: Record, + ): void => { + if (seen.has(value)) return + const record = asNativeRecord(value) + if (record === undefined) return + seen.add(value) + visit(record, parent) + if (Array.isArray(record.children)) { + for (const child of record.children) walk(child, record) + } + } + for (const value of roots) walk(value) +} + +export type IllustratorVersionFamily = + | 'ai3' + | 'ai5' + | 'ai8' + | 'ai9' + | 'ai12' + | 'ai24' + | 'future' + | 'unknown' + +export interface IllustratorVersionProfile { + family: IllustratorVersionFamily + major?: number + creator?: string + fileFormat?: string + privateCompression: 'none' | 'deflate' | 'zstd' | 'unknown' + capabilities: Readonly<{ + layers: boolean + liveText: boolean + transparency: boolean + multipleArtboards: boolean + zstdPrivateSource: boolean + }> + diagnostics: readonly string[] +} + +export interface IllustratorOperatorVersionRule { + operator: string + minimumMajor?: number + maximumMajor?: number + aliases?: readonly string[] + feature: string +} + +function firstNativeMatch( + source: string, + expressions: readonly RegExp[], +): string | undefined { + for (const expression of expressions) { + const match = expression.exec(source) + if (match?.[1] !== undefined) return match[1].trim() + } + return undefined +} + +function illustratorFamily( + major: number | undefined, +): IllustratorVersionFamily { + if (major === undefined || !Number.isFinite(major)) return 'unknown' + if (major >= 25) return 'future' + if (major >= 24) return 'ai24' + if (major >= 12) return 'ai12' + if (major >= 9) return 'ai9' + if (major >= 8) return 'ai8' + if (major >= 5) return 'ai5' + return 'ai3' +} + +export function detectIllustratorVersionProfile( + source: string | Uint8Array, +): IllustratorVersionProfile { + const text = latin1SourceText(source, 1024 * 1024) + const creator = firstNativeMatch(text, [ + /^%%Creator:\s*(.+)$/imu, + /^%AI\d*_CreatorVersion:\s*(.+)$/imu, + ]) + const fileFormat = firstNativeMatch(text, [ + /^%AI\d*_FileFormat\s*:?[\s]*(.+)$/imu, + /^%%LanguageLevel:\s*(.+)$/imu, + ]) + const versionText = firstNativeMatch(text, [ + /^%AI\d*_CreatorVersion:\s*([0-9]+(?:\.[0-9]+)?)/imu, + /Adobe Illustrator(?:\(R\))?\s*(?:CS\d*|CC)?\s*([0-9]+(?:\.[0-9]+)?)/iu, + /Illustrator[^0-9\r\n]*([0-9]+(?:\.[0-9]+)?)/iu, + ]) + const parsed = versionText === undefined + ? undefined + : Number.parseInt(versionText, 10) + const major = parsed !== undefined && Number.isFinite(parsed) + ? parsed + : undefined + const family = illustratorFamily(major) + const privateCompression = /%AI24_ZStandard_Data/u.test(text) + ? 'zstd' + : /%AI(?:1[2-9]|2[0-3])_CompressedData/u.test(text) + ? 'deflate' + : /^%!PS-Adobe/mu.test(text) + ? 'none' + : 'unknown' + const diagnostics: string[] = [] + if (major === undefined) { + diagnostics.push( + 'Illustrator creator version could not be established from native headers.', + ) + } + if (family === 'future') { + diagnostics.push( + 'The source belongs to a newer Illustrator family; unknown operators remain evidence-gated.', + ) + } + return { + family, + ...(major === undefined ? {} : { major }), + ...(creator === undefined ? {} : { creator }), + ...(fileFormat === undefined ? {} : { fileFormat }), + privateCompression, + capabilities: { + layers: major === undefined || major >= 5, + liveText: major === undefined || major >= 8, + transparency: major === undefined || major >= 9, + multipleArtboards: major === undefined || major >= 14, + zstdPrivateSource: major !== undefined && major >= 24, + }, + diagnostics, + } +} + +export function operatorRuleApplies( + profile: IllustratorVersionProfile, + rule: IllustratorOperatorVersionRule, +): boolean { + if (profile.major === undefined) return true + if ( + rule.minimumMajor !== undefined + && profile.major < rule.minimumMajor + ) return false + if ( + rule.maximumMajor !== undefined + && profile.major > rule.maximumMajor + ) return false + return true +} + +export function resolveVersionedOperatorName( + profile: IllustratorVersionProfile, + operator: string, + rules: readonly IllustratorOperatorVersionRule[], +): IllustratorOperatorVersionRule | undefined { + return rules.find((rule) => + operatorRuleApplies(profile, rule) + && ( + rule.operator === operator + || rule.aliases?.includes(operator) === true + ), + ) +} + +export interface NativePoint { + x: number + y: number +} + +export interface NativeBounds { + left: number + top: number + right: number + bottom: number +} + +export interface NativeMatrix { + a: number + b: number + c: number + d: number + e: number + f: number +} + +export const IDENTITY_NATIVE_MATRIX: NativeMatrix = { + a: 1, + b: 0, + c: 0, + d: 1, + e: 0, + f: 0, +} + +export function transformNativePoint( + point: NativePoint, + matrix: NativeMatrix, +): NativePoint { + return { + x: matrix.a * point.x + matrix.c * point.y + matrix.e, + y: matrix.b * point.x + matrix.d * point.y + matrix.f, + } +} + +export function multiplyNativeMatrices( + left: NativeMatrix, + right: NativeMatrix, +): NativeMatrix { + return { + a: left.a * right.a + left.c * right.b, + b: left.b * right.a + left.d * right.b, + c: left.a * right.c + left.c * right.d, + d: left.b * right.c + left.d * right.d, + e: left.a * right.e + left.c * right.f + left.e, + f: left.b * right.e + left.d * right.f + left.f, + } +} + +function cubicCoordinate( + p0: number, + p1: number, + p2: number, + p3: number, + t: number, +): number { + const inverse = 1 - t + return inverse * inverse * inverse * p0 + + 3 * inverse * inverse * t * p1 + + 3 * inverse * t * t * p2 + + t * t * t * p3 +} + +function quadraticRoots(a: number, b: number, c: number): number[] { + const epsilon = 1e-12 + if (Math.abs(a) < epsilon) { + if (Math.abs(b) < epsilon) return [] + return [-c / b] + } + const discriminant = b * b - 4 * a * c + if (discriminant < -epsilon) return [] + if (Math.abs(discriminant) <= epsilon) return [-b / (2 * a)] + const root = Math.sqrt(discriminant) + const sign = b < 0 ? -1 : 1 + const q = -0.5 * (b + sign * root) + if (Math.abs(q) < epsilon) { + return [ + (-b + root) / (2 * a), + (-b - root) / (2 * a), + ] + } + return [q / a, c / q] +} + +function cubicExtrema( + p0: number, + p1: number, + p2: number, + p3: number, +): number[] { + const a = -p0 + 3 * p1 - 3 * p2 + p3 + const b = 2 * (p0 - 2 * p1 + p2) + const c = p1 - p0 + return quadraticRoots(3 * a, 3 * b, 3 * c) + .filter((value) => value > 0 && value < 1 && Number.isFinite(value)) +} + +export function exactCubicBezierBounds( + start: NativePoint, + control1: NativePoint, + control2: NativePoint, + end: NativePoint, + matrix: NativeMatrix = IDENTITY_NATIVE_MATRIX, +): NativeBounds { + const p0 = transformNativePoint(start, matrix) + const p1 = transformNativePoint(control1, matrix) + const p2 = transformNativePoint(control2, matrix) + const p3 = transformNativePoint(end, matrix) + const xValues = [p0.x, p3.x] + const yValues = [p0.y, p3.y] + for (const t of cubicExtrema(p0.x, p1.x, p2.x, p3.x)) { + xValues.push(cubicCoordinate(p0.x, p1.x, p2.x, p3.x, t)) + } + for (const t of cubicExtrema(p0.y, p1.y, p2.y, p3.y)) { + yValues.push(cubicCoordinate(p0.y, p1.y, p2.y, p3.y, t)) + } + return { + left: Math.min(...xValues), + top: Math.min(...yValues), + right: Math.max(...xValues), + bottom: Math.max(...yValues), + } +} + +export function unionNativeBounds( + left: NativeBounds | undefined, + right: NativeBounds | undefined, +): NativeBounds | undefined { + if (left === undefined) return right === undefined ? undefined : { ...right } + if (right === undefined) return { ...left } + return { + left: Math.min(left.left, right.left), + top: Math.min(left.top, right.top), + right: Math.max(left.right, right.right), + bottom: Math.max(left.bottom, right.bottom), + } +} + +export function inflateNativeStrokeBounds( + bounds: NativeBounds, + width: number, + options: Readonly<{ + miterLimit?: number + cap?: 'butt' | 'round' | 'square' + }> = {}, +): NativeBounds { + const half = Math.max(0, width) / 2 + const miter = Math.max(1, options.miterLimit ?? 1) + const capScale = options.cap === 'square' ? Math.SQRT2 : 1 + const inflation = half * Math.max(miter, capScale) + return { + left: bounds.left - inflation, + top: bounds.top - inflation, + right: bounds.right + inflation, + bottom: bounds.bottom + inflation, + } +} + +export interface ByteLruEntry { + key: string + value: V + bytes: number +} + +export class ByteLruCache { + readonly #entries = new Map>() + #bytes = 0 + #maximumBytes: number + + constructor(maximumBytes: number) { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + throw new RangeError( + 'maximumBytes must be a non-negative safe integer.', + ) + } + this.#maximumBytes = maximumBytes + } + + get maximumBytes(): number { return this.#maximumBytes } + get byteLength(): number { return this.#bytes } + get size(): number { return this.#entries.size } + has(key: string): boolean { return this.#entries.has(key) } + + get(key: string): V | undefined { + const entry = this.#entries.get(key) + if (entry === undefined) return undefined + this.#entries.delete(key) + this.#entries.set(key, entry) + return entry.value + } + + set(key: string, value: V, bytes: number): readonly ByteLruEntry[] { + if (!Number.isSafeInteger(bytes) || bytes < 0) { + throw new RangeError('bytes must be a non-negative safe integer.') + } + const previous = this.#entries.get(key) + if (previous !== undefined) { + this.#entries.delete(key) + this.#bytes -= previous.bytes + } + const entry = { key, value, bytes } + this.#entries.set(key, entry) + this.#bytes += bytes + return this.evict() + } + + delete(key: string): boolean { + const entry = this.#entries.get(key) + if (entry === undefined) return false + this.#entries.delete(key) + this.#bytes -= entry.bytes + return true + } + + clear(): void { + this.#entries.clear() + this.#bytes = 0 + } + + resize(maximumBytes: number): readonly ByteLruEntry[] { + if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0) { + throw new RangeError( + 'maximumBytes must be a non-negative safe integer.', + ) + } + this.#maximumBytes = maximumBytes + return this.evict() + } + + entries(): readonly ByteLruEntry[] { + return [...this.#entries.values()] + } + + private evict(): readonly ByteLruEntry[] { + const evicted: ByteLruEntry[] = [] + while (this.#bytes > this.#maximumBytes && this.#entries.size > 0) { + const first = this.#entries.entries().next().value as + | [string, ByteLruEntry] + | undefined + if (first === undefined) break + this.#entries.delete(first[0]) + this.#bytes -= first[1].bytes + evicted.push(first[1]) + } + return evicted + } +} + +export interface CooperativeBudgetOptions { + signal?: AbortSignal + deadline?: number + maximumOperations?: number + now?: () => number +} + +export class CooperativeBudget { + readonly #signal: AbortSignal | undefined + readonly #deadline: number | undefined + readonly #maximumOperations: number + readonly #now: () => number + #operations = 0 + + constructor(options: CooperativeBudgetOptions = {}) { + this.#signal = options.signal + this.#deadline = options.deadline + this.#maximumOperations = options.maximumOperations ?? 1_000_000 + this.#now = options.now ?? (() => Date.now()) + if ( + !Number.isSafeInteger(this.#maximumOperations) + || this.#maximumOperations < 1 + ) { + throw new RangeError( + 'maximumOperations must be a positive safe integer.', + ) + } + } + + get operations(): number { return this.#operations } + + checkpoint(amount = 1): void { + if (!Number.isSafeInteger(amount) || amount < 0) { + throw new RangeError( + 'Checkpoint amount must be a non-negative safe integer.', + ) + } + this.#operations += amount + if (this.#signal?.aborted === true) { + throw new DOMException('Operation aborted.', 'AbortError') + } + if (this.#deadline !== undefined && this.#now() > this.#deadline) { + throw new DOMException('Operation timed out.', 'TimeoutError') + } + if (this.#operations > this.#maximumOperations) { + throw new RangeError( + `Operation budget exceeded ${this.#maximumOperations} checkpoints.`, + ) + } + } +} + +export interface NativeRevisionGate { + current(): number + next(): number + isCurrent(revision: number): boolean +} + +export function createNativeRevisionGate( + initialRevision = 0, +): NativeRevisionGate { + if (!Number.isSafeInteger(initialRevision) || initialRevision < 0) { + throw new RangeError( + 'initialRevision must be a non-negative safe integer.', + ) + } + let revision = initialRevision + return { + current: () => revision, + next: () => ++revision, + isCurrent: (candidate) => candidate === revision, + } +} + +export interface BrowserZstdDecoder { + decode( + input: Uint8Array, + maximumOutputBytes: number, + signal?: AbortSignal, + ): Uint8Array | Promise +} + +export interface BrowserZstdCodecProvider { + zstdDecompress( + input: Uint8Array, + maximumOutputBytes: number, + signal?: AbortSignal, + ): Promise + decompressZstd( + input: Uint8Array, + maximumOutputBytes: number, + signal?: AbortSignal, + ): Promise + zstd( + input: Uint8Array, + maximumOutputBytes: number, + signal?: AbortSignal, + ): Promise +} + +function assertZstdFrame(input: Uint8Array): void { + if ( + input.byteLength < 4 + || input[0] !== 0x28 + || input[1] !== 0xb5 + || input[2] !== 0x2f + || input[3] !== 0xfd + ) { + throw new Error( + 'Input does not begin with the standard zstd frame magic.', + ) + } +} + +export function createBrowserZstdCodecProvider( + decoder: BrowserZstdDecoder, + options: Readonly<{ + requireFrameMagic?: boolean + maximumInputBytes?: number + }> = {}, +): BrowserZstdCodecProvider { + const decode = async ( + input: Uint8Array, + maximumOutputBytes: number, + signal?: AbortSignal, + ): Promise => { + if (!(input instanceof Uint8Array)) { + throw new TypeError('zstd input must be a Uint8Array.') + } + if ( + !Number.isSafeInteger(maximumOutputBytes) + || maximumOutputBytes < 0 + ) { + throw new RangeError( + 'maximumOutputBytes must be a non-negative safe integer.', + ) + } + const maximumInputBytes = options.maximumInputBytes + ?? 256 * 1024 * 1024 + if (input.byteLength > maximumInputBytes) { + throw new RangeError( + `zstd input exceeds the ${maximumInputBytes}-byte limit.`, + ) + } + if (options.requireFrameMagic !== false) assertZstdFrame(input) + if (signal?.aborted === true) { + throw new DOMException('zstd decode aborted.', 'AbortError') + } + const output = await decoder.decode( + input, + maximumOutputBytes, + signal, + ) + if (!(output instanceof Uint8Array)) { + throw new TypeError( + 'Browser zstd decoder must return Uint8Array.', + ) + } + if (output.byteLength > maximumOutputBytes) { + throw new RangeError( + `zstd output exceeds the ${maximumOutputBytes}-byte limit.`, + ) + } + if (signal?.aborted === true) { + throw new DOMException('zstd decode aborted.', 'AbortError') + } + return output + } + return { + zstdDecompress: decode, + decompressZstd: decode, + zstd: decode, + } +} diff --git a/src/native-pipeline.ts b/src/native-pipeline.ts new file mode 100644 index 0000000..f7ae72c --- /dev/null +++ b/src/native-pipeline.ts @@ -0,0 +1,376 @@ +import { parseIllustratorSource } from './ast.js' +import { decodeIllustratorPrivateSource } from './container.js' +import { + asNativeRecord, + ByteLruCache, + CooperativeBudget, + isNativeFidelity, + nativeAstStatements, + resolveNativeFidelity, + type NativeFidelity, + type NativeFidelityDecision, + type NativeFidelityEvidence, +} from './native-foundation.js' +import { + buildNativeAdvancedObjectModel, + buildNativeResourceGraph, + buildNativeTextModel, + buildNativeTransparencyModel, + classifyNativeSourceSections, + decodeNativeResources, + extractNativeArtboards, + resolveNativeResource, + type NativeResourceGraph, + type NativeResourceRequest, + type NativeResourceResolverOptions, + type NativeResolvedResource, +} from './native-analysis.js' +import { + buildNativeRenderPlan, + executeNativeRenderPlan, + renderNativePlanToSvg, + type ExecuteNativeRenderPlanOptions, + type NativeRenderPlan, + type RenderNativeSvgOptions, +} from './native-render.js' +import { + nativeStructureSnapshot, + scanNativeSourceSecurity, + type NativeSecurityLimits, + type NativeSecurityReport, + type NativeStructureSnapshot, +} from './native-verification.js' +import { detectIllustratorVersionProfile } from './native-foundation.js' +import { lowerIllustratorAst } from './semantic.js' + +export type NativeDecodedSource = Awaited< + ReturnType +> +export type NativeIllustratorAst = ReturnType +export type NativeIllustratorScene = ReturnType + +export interface OpenNativeIllustratorOptions { + decodeOptions?: unknown + parseOptions?: unknown + evidence?: readonly NativeFidelityEvidence[] + requestedFidelity?: NativeFidelity + operationBudget?: CooperativeBudget + resourceCacheBytes?: number + securityLimits?: Readonly> +} + +export interface NativeIllustratorAnalysis { + decoded: NativeDecodedSource + sourceBytes: Uint8Array + ast: NativeIllustratorAst + scene: NativeIllustratorScene + profile: ReturnType + artboards: ReturnType + sourceSections: ReturnType + resources: NativeResourceGraph + decodedResources: ReturnType + text: ReturnType + transparency: ReturnType + advancedObjects: ReturnType + renderPlan: NativeRenderPlan + security: NativeSecurityReport + snapshot: NativeStructureSnapshot + fidelity: NativeFidelityDecision + diagnostics: readonly string[] +} + +function decodedSourceBytes(decoded: NativeDecodedSource): Uint8Array { + const record = asNativeRecord(decoded) + if (record?.bytes instanceof Uint8Array) return record.bytes + if (record?.source instanceof Uint8Array) return record.source + if (typeof record?.text === 'string') { + const result = new Uint8Array(record.text.length) + for (let index = 0; index < record.text.length; index++) { + result[index] = record.text.charCodeAt(index) & 0xff + } + return result + } + throw new TypeError( + 'Decoded Illustrator source did not expose bytes, source, or text.', + ) +} + +function requestedSceneFidelity( + scene: NativeIllustratorScene, +): NativeFidelity { + const record = asNativeRecord(scene) + return isNativeFidelity(record?.fidelity) + ? record.fidelity + : 'structure-only' +} + +function packageEvidence( + security: NativeSecurityReport, +): NativeFidelityEvidence[] { + return [ + { + id: 'package-synthetic-regression-suite', + kind: 'synthetic-fixture', + status: 'passed', + source: 'tests', + notes: 'The package regression suite exercises the public native pipeline.', + }, + { + id: 'runtime-security-scan', + kind: 'security-budget', + status: security.safeToParse ? 'passed' : 'failed', + notes: security.diagnostics.join(' '), + }, + ] +} + +function uniqueDiagnostics(values: readonly string[]): string[] { + return [...new Set(values.filter((value) => value.trim() !== ''))] +} + +export async function analyzeNativeIllustratorDocument( + input: Uint8Array, + options: OpenNativeIllustratorOptions = {}, +): Promise { + if (!(input instanceof Uint8Array)) { + throw new TypeError('Illustrator input must be a Uint8Array.') + } + const budget = options.operationBudget ?? new CooperativeBudget() + budget.checkpoint() + + const decode = decodeIllustratorPrivateSource as unknown as ( + bytes: Uint8Array, + decodeOptions?: unknown, + ) => NativeDecodedSource | Promise + const decoded = await decode(input, options.decodeOptions) + const sourceBytes = decodedSourceBytes(decoded) + budget.checkpoint( + Math.max(1, Math.ceil(sourceBytes.byteLength / 4096)), + ) + + const security = scanNativeSourceSecurity( + sourceBytes, + options.securityLimits, + ) + if (!security.safeToParse) { + throw new RangeError( + `Decoded Illustrator source violates security limits: ${security.diagnostics.join(' ')}`, + ) + } + + const parse = parseIllustratorSource as unknown as ( + source: Uint8Array, + parseOptions?: unknown, + ) => NativeIllustratorAst + const ast = parse(sourceBytes, options.parseOptions) + budget.checkpoint(Math.max(1, nativeAstStatements(ast).length)) + + const scene = lowerIllustratorAst(ast) + budget.checkpoint() + + const profile = detectIllustratorVersionProfile(sourceBytes) + const artboards = extractNativeArtboards(sourceBytes, scene) + const sourceSections = classifyNativeSourceSections(ast) + const resources = buildNativeResourceGraph(ast, scene) + const decodedResources = decodeNativeResources( + sourceBytes, + ast, + resources, + ) + const text = buildNativeTextModel(scene) + const transparency = buildNativeTransparencyModel(scene) + const advancedObjects = buildNativeAdvancedObjectModel( + ast, + scene, + resources, + ) + const renderPlan = buildNativeRenderPlan(scene) + const snapshot = nativeStructureSnapshot( + scene, + resources, + text.requiredFonts, + artboards.length, + ) + const requestedFidelity = options.requestedFidelity + ?? requestedSceneFidelity(scene) + const fidelity = resolveNativeFidelity( + requestedFidelity, + [ + ...packageEvidence(security), + ...(options.evidence ?? []), + ], + ) + const diagnostics = uniqueDiagnostics([ + ...profile.diagnostics, + ...sourceSections.diagnostics, + ...resources.validate(), + ...decodedResources.diagnostics, + ...text.diagnostics, + ...transparency.diagnostics, + ...advancedObjects.diagnostics, + ...renderPlan.diagnostics, + ...security.diagnostics, + ...fidelity.missingEvidence.map((kind) => + `Fidelity promotion is waiting for ${kind} evidence.`, + ), + ...fidelity.failedEvidence.map((kind) => + `Fidelity evidence failed: ${kind}.`, + ), + ]) + return { + decoded, + sourceBytes, + ast, + scene, + profile, + artboards, + sourceSections, + resources, + decodedResources, + text, + transparency, + advancedObjects, + renderPlan, + security, + snapshot, + fidelity, + diagnostics, + } +} + +export interface NativeIllustratorSummary { + versionFamily: string + creator?: string + artboards: number + layers: number + groups: number + paths: number + compoundPaths: number + clipGroups: number + textFrames: number + resources: number + gradients: number + patterns: number + rasters: number + advancedObjects: number + requiredFonts: readonly string[] + requestedFidelity: NativeFidelity + effectiveFidelity: NativeFidelity + missingEvidence: readonly string[] + diagnostics: number +} + +export interface NativeResourceCacheStats { + entries: number + bytes: number + maximumBytes: number +} + +export class NativeIllustratorDocumentSession { + #analysis: NativeIllustratorAnalysis | undefined + readonly #resourceCache: ByteLruCache + + constructor( + analysis: NativeIllustratorAnalysis, + resourceCacheBytes = 64 * 1024 * 1024, + ) { + this.#analysis = analysis + this.#resourceCache = new ByteLruCache(resourceCacheBytes) + } + + get disposed(): boolean { return this.#analysis === undefined } + + get analysis(): NativeIllustratorAnalysis { + if (this.#analysis === undefined) { + throw new Error('Native Illustrator document session is disposed.') + } + return this.#analysis + } + + get scene(): NativeIllustratorScene { return this.analysis.scene } + get ast(): NativeIllustratorAst { return this.analysis.ast } + get renderPlan(): NativeRenderPlan { return this.analysis.renderPlan } + get fidelity(): NativeFidelityDecision { return this.analysis.fidelity } + + summary(): NativeIllustratorSummary { + const analysis = this.analysis + const creator = analysis.profile.creator + return { + versionFamily: analysis.profile.family, + ...(creator === undefined ? {} : { creator }), + artboards: analysis.artboards.length, + layers: analysis.snapshot.layers, + groups: analysis.snapshot.groups, + paths: analysis.snapshot.paths, + compoundPaths: analysis.snapshot.compoundPaths, + clipGroups: analysis.snapshot.clipGroups, + textFrames: analysis.snapshot.textFrames, + resources: analysis.snapshot.resources, + gradients: analysis.decodedResources.gradients.length, + patterns: analysis.decodedResources.patterns.length, + rasters: analysis.decodedResources.rasters.length, + advancedObjects: analysis.advancedObjects.objects.length, + requiredFonts: analysis.text.requiredFonts, + requestedFidelity: analysis.fidelity.requested, + effectiveFidelity: analysis.fidelity.effective, + missingEvidence: analysis.fidelity.missingEvidence, + diagnostics: analysis.diagnostics.length, + } + } + + toSvg(options: RenderNativeSvgOptions = {}): string { + return renderNativePlanToSvg(this.renderPlan, options) + } + + render( + context: CanvasRenderingContext2D, + options: ExecuteNativeRenderPlanOptions = {}, + ): readonly string[] { + return executeNativeRenderPlan(this.renderPlan, context, options) + } + + async resolveResource( + request: NativeResourceRequest, + resolver: NativeResourceResolverOptions, + signal?: AbortSignal, + ): Promise { + const cached = this.#resourceCache.get(request.id) + if (cached !== undefined) return cached + const resolved = await resolveNativeResource(request, resolver, signal) + this.#resourceCache.set( + request.id, + resolved, + resolved.bytes.byteLength, + ) + return resolved + } + + resourceCacheStats(): NativeResourceCacheStats { + return { + entries: this.#resourceCache.size, + bytes: this.#resourceCache.byteLength, + maximumBytes: this.#resourceCache.maximumBytes, + } + } + + trimResourceCache(maximumBytes = 0): void { + this.#resourceCache.resize(maximumBytes) + } + + dispose(): void { + if (this.#analysis === undefined) return + this.#resourceCache.clear() + this.#analysis = undefined + } +} + +export async function openNativeIllustratorDocument( + input: Uint8Array, + options: OpenNativeIllustratorOptions = {}, +): Promise { + const analysis = await analyzeNativeIllustratorDocument(input, options) + return new NativeIllustratorDocumentSession( + analysis, + options.resourceCacheBytes, + ) +} diff --git a/src/native-render.ts b/src/native-render.ts new file mode 100644 index 0000000..345f726 --- /dev/null +++ b/src/native-render.ts @@ -0,0 +1,884 @@ +import { + asNativeRecord, + nativeFNV1a, + nativeNumber, + nativeString, + stableNativeSerialize, +} from './native-foundation.js' +import { canvasCompositeOperationForBlendMode } from './native-analysis.js' + +export type NativeRenderOperation = + | Readonly<{ kind: 'save'; nodeId?: string }> + | Readonly<{ kind: 'restore'; nodeId?: string }> + | Readonly<{ + kind: 'begin-group' + nodeId?: string + opacity: number + blendMode: string + isolated: boolean + knockout: boolean + }> + | Readonly<{ kind: 'end-group'; nodeId?: string }> + | Readonly<{ + kind: 'begin-clip' + nodeId?: string + geometry: unknown + fillRule: CanvasFillRule + }> + | Readonly<{ kind: 'end-clip'; nodeId?: string }> + | Readonly<{ + kind: 'path' + nodeId?: string + geometry: unknown + appearance: unknown + }> + | Readonly<{ + kind: 'text' + nodeId?: string + text: string + runs: readonly unknown[] + matrix?: unknown + bounds?: unknown + appearance?: unknown + }> + | Readonly<{ + kind: 'image' + nodeId?: string + resourceId?: string + bounds?: unknown + matrix?: unknown + appearance?: unknown + }> + | Readonly<{ + kind: 'unsupported' + nodeId?: string + feature: string + raw?: unknown + }> + +export interface NativeRenderPlan { + version: 1 + operations: readonly NativeRenderOperation[] + resources: readonly string[] + deterministicHash: string + diagnostics: readonly string[] +} + +function sceneRoots(scene: unknown): readonly unknown[] { + const root = asNativeRecord(scene) + const explicitRoot = asNativeRecord(root?.root) + if (explicitRoot !== undefined) return [explicitRoot] + if (Array.isArray(root?.children)) return root.children + if (Array.isArray(root?.nodes)) return root.nodes + if (Array.isArray(root?.layers)) return root.layers + return [] +} + +function nodeId(node: Record): string | undefined { + return typeof node.id === 'string' ? node.id : undefined +} + +function withNodeId( + id: string | undefined, +): Readonly<{ nodeId?: string }> { + return id === undefined ? {} : { nodeId: id } +} + +function nodeType(node: Record): string { + return typeof node.type === 'string' + ? node.type + : typeof node.kind === 'string' + ? node.kind + : 'UnknownNode' +} + +function pathGeometry(node: Record): unknown { + if (node.geometry !== undefined) return node.geometry + if (Array.isArray(node.contours)) { + return { + contours: node.contours, + ...(node.fillRule === undefined ? {} : { fillRule: node.fillRule }), + } + } + return undefined +} + +function isPathNode(node: Record): boolean { + const type = nodeType(node) + return type === 'Path' + || type === 'path' + || type === 'CompoundPath' + || type === 'compound-path' +} + +function isClippingPath(node: Record): boolean { + return node.clippingPath === true + || node.isClippingPath === true + || node.clipRole === 'clip' +} + +function groupAppearance(node: Record): Readonly<{ + opacity: number + blendMode: string + isolated: boolean + knockout: boolean +}> { + const appearance = asNativeRecord(node.appearance) + return { + opacity: Math.max( + 0, + Math.min( + 1, + nativeNumber(appearance?.opacity) + ?? nativeNumber(node.opacity) + ?? 1, + ), + ), + blendMode: nativeString(appearance?.blendMode) + ?? nativeString(node.blendMode) + ?? 'normal', + isolated: appearance?.isolated === true || node.isolated === true, + knockout: appearance?.knockout === true || node.knockout === true, + } +} + +function textRuns(node: Record): readonly unknown[] { + if (Array.isArray(node.runs)) return node.runs + if (typeof node.text === 'string') return [{ text: node.text }] + const story = asNativeRecord(node.story) + if (typeof story?.text === 'string') return [{ text: story.text }] + return [] +} + +function textContent(runs: readonly unknown[]): string { + return runs.map((raw) => { + const run = asNativeRecord(raw) + return typeof run?.text === 'string' ? run.text : '' + }).join('') +} + +export function buildNativeRenderPlan(scene: unknown): NativeRenderPlan { + const operations: NativeRenderOperation[] = [] + const resources = new Set() + const diagnostics: string[] = [] + const visiting = new Set() + + const emitLeaf = (node: Record): void => { + const id = nodeId(node) + const type = nodeType(node) + if (isPathNode(node)) { + const geometry = pathGeometry(node) + if (geometry !== undefined && !isClippingPath(node)) { + operations.push({ + kind: 'path', + ...withNodeId(id), + geometry, + appearance: node.appearance, + }) + } + return + } + if (type === 'Text' || type === 'text') { + const runs = textRuns(node) + operations.push({ + kind: 'text', + ...withNodeId(id), + text: textContent(runs), + runs, + ...(node.matrix === undefined ? {} : { matrix: node.matrix }), + ...(node.bounds === undefined ? {} : { bounds: node.bounds }), + ...(node.appearance === undefined + ? {} + : { appearance: node.appearance }), + }) + return + } + if ( + type === 'RasterImage' + || type === 'raster-image' + || type === 'PlacedArt' + || type === 'placed-art' + || type === 'image' + ) { + const resourceId = typeof node.resourceId === 'string' + ? node.resourceId + : typeof node.imageId === 'string' + ? node.imageId + : undefined + if (resourceId !== undefined) resources.add(resourceId) + operations.push({ + kind: 'image', + ...withNodeId(id), + ...(resourceId === undefined ? {} : { resourceId }), + ...(node.bounds === undefined ? {} : { bounds: node.bounds }), + ...(node.matrix === undefined ? {} : { matrix: node.matrix }), + ...(node.appearance === undefined + ? {} + : { appearance: node.appearance }), + }) + return + } + if (type === 'UnknownNode' || type === 'unknown') { + const feature = typeof node.operator === 'string' + ? `operator:${node.operator}` + : 'unknown-visible-object' + operations.push({ + kind: 'unsupported', + ...withNodeId(id), + feature, + raw: node, + }) + diagnostics.push( + `${feature} was retained but cannot be emitted as fabricated artwork.`, + ) + } + } + + const walk = (value: unknown): void => { + const node = asNativeRecord(value) + if (node === undefined) return + if (visiting.has(value)) { + diagnostics.push( + `Scene cycle detected at ${nodeId(node) ?? '(unnamed node)'}.`, + ) + return + } + visiting.add(value) + const id = nodeId(node) + const type = nodeType(node) + const children = Array.isArray(node.children) ? node.children : [] + const isGroup = children.length > 0 + || type === 'Group' + || type === 'group' + || type === 'Layer' + || type === 'layer' + || type === 'ClipGroup' + || type === 'clip-group' + if (!isGroup) { + emitLeaf(node) + visiting.delete(value) + return + } + + operations.push({ kind: 'save', ...withNodeId(id) }) + operations.push({ + kind: 'begin-group', + ...withNodeId(id), + ...groupAppearance(node), + }) + + const explicitClip = asNativeRecord(node.clippingPath) + const clippingChildren = children + .map(asNativeRecord) + .filter((child): child is Record => + child !== undefined && isPathNode(child) && isClippingPath(child), + ) + const clipping = explicitClip ?? clippingChildren[0] + const clippingGeometry = clipping === undefined + ? undefined + : pathGeometry(clipping) + if (clippingGeometry !== undefined) { + const geometryRecord = asNativeRecord(clippingGeometry) + operations.push({ + kind: 'begin-clip', + ...withNodeId(nodeId(clipping)), + geometry: clippingGeometry, + fillRule: geometryRecord?.fillRule === 'evenodd' + ? 'evenodd' + : 'nonzero', + }) + } + + emitLeaf(node) + for (const child of children) { + const childRecord = asNativeRecord(child) + if ( + childRecord !== undefined + && clippingChildren.includes(childRecord) + ) continue + walk(child) + } + + if (clippingGeometry !== undefined) { + operations.push({ kind: 'end-clip', ...withNodeId(id) }) + } + operations.push({ kind: 'end-group', ...withNodeId(id) }) + operations.push({ kind: 'restore', ...withNodeId(id) }) + visiting.delete(value) + } + + for (const root of sceneRoots(scene)) walk(root) + const sortedResources = [...resources].sort() + const deterministic = stableNativeSerialize({ + operations, + resources: sortedResources, + }) + return { + version: 1, + operations, + resources: sortedResources, + deterministicHash: nativeFNV1a(deterministic), + diagnostics, + } +} + +interface NativePathTarget { + beginPath(): void + moveTo(x: number, y: number): void + lineTo(x: number, y: number): void + bezierCurveTo( + x1: number, + y1: number, + x2: number, + y2: number, + x: number, + y: number, + ): void + closePath(): void +} + +interface NativePointLike { + x: number + y: number + incoming?: Readonly<{ x: number; y: number }> + outgoing?: Readonly<{ x: number; y: number }> +} + +function pointFrom(value: unknown): NativePointLike | undefined { + const record = asNativeRecord(value) + const x = nativeNumber(record?.x) + const y = nativeNumber(record?.y) + if (x === undefined || y === undefined) return undefined + const incomingRecord = asNativeRecord(record?.incoming) + const outgoingRecord = asNativeRecord(record?.outgoing) + const incomingX = nativeNumber(incomingRecord?.x) + const incomingY = nativeNumber(incomingRecord?.y) + const outgoingX = nativeNumber(outgoingRecord?.x) + const outgoingY = nativeNumber(outgoingRecord?.y) + const incoming = incomingX === undefined || incomingY === undefined + ? undefined + : { x: incomingX, y: incomingY } + const outgoing = outgoingX === undefined || outgoingY === undefined + ? undefined + : { x: outgoingX, y: outgoingY } + return { + x, + y, + ...(incoming === undefined ? {} : { incoming }), + ...(outgoing === undefined ? {} : { outgoing }), + } +} + +function tracePointContour( + contour: Record, + target: NativePathTarget, +): boolean { + const values = Array.isArray(contour.points) ? contour.points : [] + const points = values + .map(pointFrom) + .filter((point): point is NativePointLike => point !== undefined) + const first = points[0] + if (first === undefined) return false + target.moveTo(first.x, first.y) + let previous = first + for (let index = 1; index < points.length; index++) { + const current = points[index] + if (current === undefined) continue + if (previous.outgoing !== undefined || current.incoming !== undefined) { + target.bezierCurveTo( + previous.outgoing?.x ?? previous.x, + previous.outgoing?.y ?? previous.y, + current.incoming?.x ?? current.x, + current.incoming?.y ?? current.y, + current.x, + current.y, + ) + } else { + target.lineTo(current.x, current.y) + } + previous = current + } + if (contour.closed === true) { + if (previous.outgoing !== undefined || first.incoming !== undefined) { + target.bezierCurveTo( + previous.outgoing?.x ?? previous.x, + previous.outgoing?.y ?? previous.y, + first.incoming?.x ?? first.x, + first.incoming?.y ?? first.y, + first.x, + first.y, + ) + } + target.closePath() + } + return true +} + +function segmentEnd( + segment: Record, +): NativePointLike | undefined { + return pointFrom(segment.end) + ?? pointFrom(segment.to) + ?? pointFrom(segment.point) +} + +function traceSegmentContour( + contour: Record, + target: NativePathTarget, +): boolean { + const segments = Array.isArray(contour.segments) ? contour.segments : [] + const firstSegment = asNativeRecord(segments[0]) + const start = pointFrom(contour.start) + ?? pointFrom(contour.first) + ?? pointFrom(firstSegment?.start) + if (start === undefined) return false + target.moveTo(start.x, start.y) + let previous = start + for (const rawSegment of segments) { + const segment = asNativeRecord(rawSegment) + if (segment === undefined) continue + const end = segmentEnd(segment) + if (end === undefined) continue + const control1 = pointFrom(segment.control1) + ?? pointFrom(segment.c1) + ?? pointFrom(segment.outgoing) + const control2 = pointFrom(segment.control2) + ?? pointFrom(segment.c2) + ?? pointFrom(segment.incoming) + const type = nativeString(segment.type) + ?? nativeString(segment.kind) + if ( + type === 'cubic' + || type === 'curve' + || control1 !== undefined + || control2 !== undefined + ) { + target.bezierCurveTo( + control1?.x ?? previous.x, + control1?.y ?? previous.y, + control2?.x ?? end.x, + control2?.y ?? end.y, + end.x, + end.y, + ) + } else { + target.lineTo(end.x, end.y) + } + previous = end + } + if (contour.closed === true) target.closePath() + return true +} + +export function traceNativeGeometry( + geometry: unknown, + target: NativePathTarget, +): boolean { + const record = asNativeRecord(geometry) + if (!Array.isArray(record?.contours)) return false + target.beginPath() + let traced = false + for (const rawContour of record.contours) { + const contour = asNativeRecord(rawContour) + if (contour === undefined) continue + if (Array.isArray(contour.points)) { + traced = tracePointContour(contour, target) || traced + } else { + traced = traceSegmentContour(contour, target) || traced + } + } + return traced +} + +function paintCss(value: unknown): string | undefined { + const record = asNativeRecord(value) + const kind = nativeString(record?.kind) + ?? nativeString(record?.space) + if (kind === 'solid') return paintCss(record?.color) + if (kind === 'rgb') { + const red = Math.round( + Math.max(0, Math.min(1, + nativeNumber(record?.red) ?? nativeNumber(record?.r) ?? 0, + )) * 255, + ) + const green = Math.round( + Math.max(0, Math.min(1, + nativeNumber(record?.green) ?? nativeNumber(record?.g) ?? 0, + )) * 255, + ) + const blue = Math.round( + Math.max(0, Math.min(1, + nativeNumber(record?.blue) ?? nativeNumber(record?.b) ?? 0, + )) * 255, + ) + const alpha = Math.max(0, Math.min(1, + nativeNumber(record?.alpha) ?? 1, + )) + return `rgba(${red},${green},${blue},${alpha})` + } + if (kind === 'gray') { + const gray = Math.round( + Math.max(0, Math.min(1, + nativeNumber(record?.gray) ?? nativeNumber(record?.value) ?? 0, + )) * 255, + ) + const alpha = Math.max(0, Math.min(1, + nativeNumber(record?.alpha) ?? 1, + )) + return `rgba(${gray},${gray},${gray},${alpha})` + } + if (kind === 'cmyk') { + const cyan = Math.max(0, Math.min(1, + nativeNumber(record?.cyan) ?? nativeNumber(record?.c) ?? 0, + )) + const magenta = Math.max(0, Math.min(1, + nativeNumber(record?.magenta) ?? nativeNumber(record?.m) ?? 0, + )) + const yellow = Math.max(0, Math.min(1, + nativeNumber(record?.yellow) ?? nativeNumber(record?.y) ?? 0, + )) + const black = Math.max(0, Math.min(1, + nativeNumber(record?.black) ?? nativeNumber(record?.k) ?? 0, + )) + const red = Math.round(255 * (1 - cyan) * (1 - black)) + const green = Math.round(255 * (1 - magenta) * (1 - black)) + const blue = Math.round(255 * (1 - yellow) * (1 - black)) + const alpha = Math.max(0, Math.min(1, + nativeNumber(record?.alpha) ?? 1, + )) + return `rgba(${red},${green},${blue},${alpha})` + } + if (kind === 'spot') return paintCss(record?.alternate) + return undefined +} + +function firstPaint( + appearance: unknown, + field: 'fills' | 'strokes', +): Record | undefined { + const record = asNativeRecord(appearance) + if (record === undefined) return undefined + const value = record[field] + if (Array.isArray(value)) return asNativeRecord(value[0]) + if (field === 'fills') return asNativeRecord(record.fill) + return asNativeRecord(record.stroke) +} + +function paintResourceId(paint: Record | undefined): string | undefined { + const payload = asNativeRecord(paint?.paint) ?? paint + return typeof payload?.resourceId === 'string' + ? payload.resourceId + : typeof payload?.gradientId === 'string' + ? payload.gradientId + : typeof payload?.patternId === 'string' + ? payload.patternId + : undefined +} + +export interface ExecuteNativeRenderPlanOptions { + resourceImages?: ReadonlyMap + paintServers?: ReadonlyMap + signal?: AbortSignal + maximumOperations?: number +} + +export function executeNativeRenderPlan( + plan: NativeRenderPlan, + context: CanvasRenderingContext2D, + options: ExecuteNativeRenderPlanOptions = {}, +): readonly string[] { + const diagnostics: string[] = [] + const maximumOperations = options.maximumOperations ?? 1_000_000 + if (plan.operations.length > maximumOperations) { + throw new RangeError( + `Render plan exceeds the ${maximumOperations}-operation limit.`, + ) + } + for (const operation of plan.operations) { + if (options.signal?.aborted === true) { + throw new DOMException('Rendering aborted.', 'AbortError') + } + if (operation.kind === 'save') context.save() + else if (operation.kind === 'restore') context.restore() + else if (operation.kind === 'begin-group') { + context.globalAlpha *= operation.opacity + const composite = canvasCompositeOperationForBlendMode( + operation.blendMode, + ) + if (composite !== undefined) { + context.globalCompositeOperation = composite + } else { + diagnostics.push( + `Canvas does not map blend mode ${operation.blendMode}.`, + ) + } + if (operation.isolated || operation.knockout) { + diagnostics.push( + `Group ${operation.nodeId ?? ''} requires offscreen isolation or knockout compositing.`, + ) + } + } else if (operation.kind === 'begin-clip') { + if (traceNativeGeometry(operation.geometry, context)) { + context.clip(operation.fillRule) + } + } else if (operation.kind === 'path') { + if (!traceNativeGeometry(operation.geometry, context)) continue + const fill = firstPaint(operation.appearance, 'fills') + const stroke = firstPaint(operation.appearance, 'strokes') + const fillResource = paintResourceId(fill) + const strokeResource = paintResourceId(stroke) + const fillStyle = fillResource === undefined + ? paintCss(fill?.paint ?? fill) + : options.paintServers?.get(fillResource) + const strokeStyle = strokeResource === undefined + ? paintCss(stroke?.paint ?? stroke) + : options.paintServers?.get(strokeResource) + if (fillStyle !== undefined) { + context.fillStyle = fillStyle + context.fill() + } + if (strokeStyle !== undefined) { + context.strokeStyle = strokeStyle + context.lineWidth = nativeNumber(stroke?.width) ?? 1 + context.stroke() + } + if (fillResource !== undefined && fillStyle === undefined) { + diagnostics.push( + `Fill resource ${fillResource} was not prepared; no placeholder fill was drawn.`, + ) + } + if (strokeResource !== undefined && strokeStyle === undefined) { + diagnostics.push( + `Stroke resource ${strokeResource} was not prepared; no placeholder stroke was drawn.`, + ) + } + } else if (operation.kind === 'text') { + const bounds = asNativeRecord(operation.bounds) + let x = nativeNumber(bounds?.left) ?? 0 + const y = nativeNumber(bounds?.bottom) ?? 0 + for (const rawRun of operation.runs) { + const run = asNativeRecord(rawRun) + const text = typeof run?.text === 'string' ? run.text : '' + const size = nativeNumber(run?.fontSize) ?? 12 + const family = typeof run?.fontPostScriptName === 'string' + ? run.fontPostScriptName + : 'sans-serif' + context.font = `${size}px ${JSON.stringify(family)}` + const fill = paintCss(run?.fill) + if (fill !== undefined) context.fillStyle = fill + context.fillText(text, x, y) + x += context.measureText(text).width + } + } else if (operation.kind === 'image') { + const image = operation.resourceId === undefined + ? undefined + : options.resourceImages?.get(operation.resourceId) + const bounds = asNativeRecord(operation.bounds) + const left = nativeNumber(bounds?.left) + const top = nativeNumber(bounds?.top) + const right = nativeNumber(bounds?.right) + const bottom = nativeNumber(bounds?.bottom) + if ( + image !== undefined + && left !== undefined + && top !== undefined + && right !== undefined + && bottom !== undefined + ) { + context.drawImage(image, left, top, right - left, bottom - top) + } else { + diagnostics.push( + `Image resource ${operation.resourceId ?? '(missing id)'} was not resolved; no placeholder artwork was drawn.`, + ) + } + } else if (operation.kind === 'unsupported') { + diagnostics.push( + `${operation.feature} is retained and intentionally omitted from fabricated output.`, + ) + } + } + return diagnostics +} + +function svgEscape(value: string): string { + return value.replace(/[&<>"']/gu, (character) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + })[character] ?? character) +} + +function svgNumber(value: number): string { + if (!Number.isFinite(value)) return '0' + return Number(value.toFixed(6)).toString() +} + +function geometrySvgPath(geometry: unknown): string | undefined { + const commands: string[] = [] + const target: NativePathTarget = { + beginPath: () => undefined, + moveTo: (x, y) => commands.push(`M${svgNumber(x)} ${svgNumber(y)}`), + lineTo: (x, y) => commands.push(`L${svgNumber(x)} ${svgNumber(y)}`), + bezierCurveTo: (x1, y1, x2, y2, x, y) => commands.push( + `C${svgNumber(x1)} ${svgNumber(y1)} ${svgNumber(x2)} ${svgNumber(y2)} ${svgNumber(x)} ${svgNumber(y)}`, + ), + closePath: () => commands.push('Z'), + } + return traceNativeGeometry(geometry, target) + ? commands.join(' ') + : undefined +} + +const SAFE_SVG_BLEND_MODES = new Set([ + 'normal', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +]) + +function safeSvgBlendMode(value: string): string { + const normalized = value + .trim() + .toLowerCase() + .replace(/[ _]/gu, '-') + return SAFE_SVG_BLEND_MODES.has(normalized) ? normalized : 'normal' +} + +function lastScope( + scopes: readonly ('group' | 'clip')[], +): 'group' | 'clip' | undefined { + return scopes[scopes.length - 1] +} + +export interface RenderNativeSvgOptions { + width?: number + height?: number + namespace?: string + paintServerIds?: ReadonlySet + imageHref?: ReadonlyMap +} + +export function renderNativePlanToSvg( + plan: NativeRenderPlan, + options: RenderNativeSvgOptions = {}, +): string { + const width = Math.max(1, Math.floor(options.width ?? 1024)) + const height = Math.max(1, Math.floor(options.height ?? 768)) + const namespace = (options.namespace ?? 'illustrator') + .replace(/[^A-Za-z0-9_-]/gu, '_') + const body: string[] = [] + const scopes: ('group' | 'clip')[] = [] + let clipIndex = 0 + for (const operation of plan.operations) { + if (operation.kind === 'begin-group') { + body.push( + ``, + ) + scopes.push('group') + } else if (operation.kind === 'end-group') { + while (lastScope(scopes) === 'clip') { + body.push('') + scopes.pop() + } + if (lastScope(scopes) === 'group') { + body.push('') + scopes.pop() + } + } else if (operation.kind === 'begin-clip') { + const path = geometrySvgPath(operation.geometry) + if (path !== undefined) { + const id = `${namespace}-clip-${clipIndex++}` + body.push( + ``, + ) + scopes.push('clip') + } + } else if (operation.kind === 'end-clip') { + if (lastScope(scopes) === 'clip') { + body.push('') + scopes.pop() + } + } else if (operation.kind === 'path') { + const path = geometrySvgPath(operation.geometry) + if (path === undefined) continue + const fill = firstPaint(operation.appearance, 'fills') + const stroke = firstPaint(operation.appearance, 'strokes') + const fillResource = paintResourceId(fill) + const strokeResource = paintResourceId(stroke) + const fillValue = fillResource !== undefined + && options.paintServerIds?.has(fillResource) === true + ? `url(#${namespace}-${fillResource.replace(/[^A-Za-z0-9_-]/gu, '_')})` + : paintCss(fill?.paint ?? fill) ?? 'none' + const strokeValue = strokeResource !== undefined + && options.paintServerIds?.has(strokeResource) === true + ? `url(#${namespace}-${strokeResource.replace(/[^A-Za-z0-9_-]/gu, '_')})` + : paintCss(stroke?.paint ?? stroke) ?? 'none' + const widthValue = nativeNumber(stroke?.width) ?? 1 + body.push( + ``, + ) + } else if (operation.kind === 'text') { + const bounds = asNativeRecord(operation.bounds) + const x = nativeNumber(bounds?.left) ?? 0 + const y = nativeNumber(bounds?.bottom) ?? 0 + const spans = operation.runs.map((rawRun) => { + const run = asNativeRecord(rawRun) + const text = typeof run?.text === 'string' ? run.text : '' + const size = nativeNumber(run?.fontSize) ?? 12 + const family = typeof run?.fontPostScriptName === 'string' + ? run.fontPostScriptName + : 'sans-serif' + return `${svgEscape(text)}` + }) + body.push( + `${spans.join('')}`, + ) + } else if (operation.kind === 'image') { + const href = operation.resourceId === undefined + ? undefined + : options.imageHref?.get(operation.resourceId) + const bounds = asNativeRecord(operation.bounds) + const left = nativeNumber(bounds?.left) + const top = nativeNumber(bounds?.top) + const right = nativeNumber(bounds?.right) + const bottom = nativeNumber(bounds?.bottom) + if ( + href !== undefined + && /^(?:data:image\/|blob:)/iu.test(href) + && left !== undefined + && top !== undefined + && right !== undefined + && bottom !== undefined + ) { + body.push( + ``, + ) + } else { + body.push( + ``, + ) + } + } else if (operation.kind === 'unsupported') { + body.push(``) + } + } + while (scopes.length > 0) { + body.push('') + scopes.pop() + } + return `${body.join('')}` +} diff --git a/src/native-verification.ts b/src/native-verification.ts new file mode 100644 index 0000000..fef2549 --- /dev/null +++ b/src/native-verification.ts @@ -0,0 +1,845 @@ +import { + asNativeRecord, + isNativeFidelity, + latin1SourceText, + nativeFNV1a, + nativeNumber, + walkNativeScene, + type NativeFidelity, + type NativeFidelityEvidence, +} from './native-foundation.js' +import type { NativeResourceGraph } from './native-analysis.js' + +export interface NativeSecurityLimits { + maximumSourceBytes: number + maximumStatements: number + maximumNesting: number + maximumDeclaredBinaryBytes: number + maximumExternalReferences: number +} + +export const DEFAULT_NATIVE_SECURITY_LIMITS: NativeSecurityLimits = { + maximumSourceBytes: 64 * 1024 * 1024, + maximumStatements: 250_000, + maximumNesting: 512, + maximumDeclaredBinaryBytes: 64 * 1024 * 1024, + maximumExternalReferences: 1_000, +} + +export interface NativeSecurityReport { + safeToParse: boolean + sourceBytes: number + estimatedStatements: number + maximumObservedNesting: number + declaredBinaryBytes: number + activeContentIndicators: readonly string[] + externalReferences: readonly string[] + diagnostics: readonly string[] +} + +const ACTIVE_POSTSCRIPT_OPERATORS: readonly [string, RegExp][] = [ + [ + 'PostScript file operator', + /(^|[\s{}\[\]()])file(?=$|[\s{}\[\]()])/mu, + ], + [ + 'PostScript run operator', + /(^|[\s{}\[\]()])run(?=$|[\s{}\[\]()])/mu, + ], + [ + 'PostScript deletefile operator', + /(^|[\s{}\[\]()])deletefile(?=$|[\s{}\[\]()])/mu, + ], + [ + 'PostScript renamefile operator', + /(^|[\s{}\[\]()])renamefile(?=$|[\s{}\[\]()])/mu, + ], + ['PostScript pipe path', /%pipe%/iu], +] + +function estimateNativeStatements(source: string): number { + let statements = 0 + let hasContent = false + for (let index = 0; index < source.length; index++) { + const code = source.charCodeAt(index) + if (code === 0x0d) { + if (source.charCodeAt(index + 1) === 0x0a) index++ + statements++ + hasContent = false + } else if (code === 0x0a) { + statements++ + hasContent = false + } else { + hasContent = true + } + } + return statements + (hasContent ? 1 : 0) +} + +function maximumNativeNesting(source: string): number { + let current = 0 + let maximum = 0 + let stringDepth = 0 + let escaped = false + let inComment = false + for (let index = 0; index < source.length; index++) { + const character = source[index] + if (inComment) { + if (character === '\r' || character === '\n') inComment = false + continue + } + if (stringDepth > 0) { + if (escaped) { + escaped = false + continue + } + if (character === '\\') escaped = true + else if (character === '(') stringDepth++ + else if (character === ')') stringDepth-- + maximum = Math.max(maximum, current + stringDepth) + continue + } + if (character === '%') { + inComment = true + continue + } + if (character === '(') { + stringDepth = 1 + maximum = Math.max(maximum, current + stringDepth) + continue + } + if ( + character === '[' + || character === '{' + || source.startsWith('<<', index) + ) { + current++ + maximum = Math.max(maximum, current) + if (source.startsWith('<<', index)) index++ + } else if ( + character === ']' + || character === '}' + || source.startsWith('>>', index) + ) { + current = Math.max(0, current - 1) + if (source.startsWith('>>', index)) index++ + } + } + return maximum +} + +function declaredNativeBinaryBytes(source: string): number { + let total = 0 + for (const match of source.matchAll( + /^%%Begin(?:Binary|Data)\s*:\s*(\d+)/gimu, + )) { + const value = Number(match[1]) + if (Number.isSafeInteger(value) && value >= 0) total += value + } + return total +} + +export function scanNativeSourceSecurity( + source: string | Uint8Array, + limits: Readonly> = {}, +): NativeSecurityReport { + const resolved = { ...DEFAULT_NATIVE_SECURITY_LIMITS, ...limits } + const sourceBytes = typeof source === 'string' + ? source.length + : source.byteLength + const text = latin1SourceText( + source, + Math.min(sourceBytes, resolved.maximumSourceBytes + 1), + ) + const estimatedStatements = estimateNativeStatements(text) + const maximumObservedNesting = maximumNativeNesting(text) + const declaredBinaryBytes = declaredNativeBinaryBytes(text) + const activeContentIndicators = ACTIVE_POSTSCRIPT_OPERATORS + .filter(([, expression]) => expression.test(text)) + .map(([name]) => name) + const externalReferences = [...text.matchAll( + /(?:https?|file):\/\/[^\s()<>{}\[\]]+/giu, + )].map((match) => match[0]) + const limitDiagnostics: string[] = [] + if (sourceBytes > resolved.maximumSourceBytes) { + limitDiagnostics.push( + `Source exceeds the ${resolved.maximumSourceBytes}-byte security limit.`, + ) + } + if (estimatedStatements > resolved.maximumStatements) { + limitDiagnostics.push( + `Source exceeds the ${resolved.maximumStatements}-statement security limit.`, + ) + } + if (maximumObservedNesting > resolved.maximumNesting) { + limitDiagnostics.push( + `Source exceeds the ${resolved.maximumNesting}-level nesting security limit.`, + ) + } + if (declaredBinaryBytes > resolved.maximumDeclaredBinaryBytes) { + limitDiagnostics.push( + `Declared binary resources exceed the ${resolved.maximumDeclaredBinaryBytes}-byte security limit.`, + ) + } + if (externalReferences.length > resolved.maximumExternalReferences) { + limitDiagnostics.push( + `Source exceeds the ${resolved.maximumExternalReferences}-reference external resource limit.`, + ) + } + const diagnostics = [...limitDiagnostics] + if (activeContentIndicators.length > 0) { + diagnostics.push( + 'Active PostScript operators are retained only as data and are never executed.', + ) + } + if (externalReferences.length > 0) { + diagnostics.push( + 'External references require an explicit resolver policy; implicit network access is forbidden.', + ) + } + return { + safeToParse: limitDiagnostics.length === 0, + sourceBytes, + estimatedStatements, + maximumObservedNesting, + declaredBinaryBytes, + activeContentIndicators, + externalReferences, + diagnostics, + } +} + +export interface NativeMutationCase { + id: string + description: string + bytes: Uint8Array +} + +function nativeSourceBytes(source: string | Uint8Array): Uint8Array { + if (source instanceof Uint8Array) return source.slice() + const result = new Uint8Array(source.length) + for (let index = 0; index < source.length; index++) { + result[index] = source.charCodeAt(index) & 0xff + } + return result +} + +function concatenateNativeBytes( + left: Uint8Array, + right: Uint8Array, +): Uint8Array { + const result = new Uint8Array(left.byteLength + right.byteLength) + result.set(left) + result.set(right, left.byteLength) + return result +} + +export function createDeterministicNativeMutations( + source: string | Uint8Array, + maximumCases = 64, +): readonly NativeMutationCase[] { + if (!Number.isSafeInteger(maximumCases) || maximumCases < 0) { + throw new RangeError( + 'maximumCases must be a non-negative safe integer.', + ) + } + const input = nativeSourceBytes(source) + const cases: NativeMutationCase[] = [] + const push = (description: string, bytes: Uint8Array): void => { + if (cases.length >= maximumCases) return + cases.push({ + id: `mutation:${cases.length}:${nativeFNV1a(description)}`, + description, + bytes, + }) + } + push('empty input', new Uint8Array()) + push( + 'one-byte truncation', + input.subarray(0, Math.max(0, input.length - 1)), + ) + push('half truncation', input.subarray(0, Math.floor(input.length / 2))) + push( + 'unterminated string prefix', + concatenateNativeBytes(input, Uint8Array.of(0x0a, 0x28, 0x78)), + ) + push( + 'unterminated array prefix', + concatenateNativeBytes(input, Uint8Array.of(0x0a, 0x5b, 0x31)), + ) + push( + 'oversized BeginData declaration', + concatenateNativeBytes( + input, + nativeSourceBytes('\n%%BeginData: 999999999 Binary\n'), + ), + ) + push( + 'active file operator', + concatenateNativeBytes( + input, + nativeSourceBytes('\n(secret) (r) file\n'), + ), + ) + push( + 'external URL reference', + concatenateNativeBytes( + input, + nativeSourceBytes('\n(https://example.invalid/resource)\n'), + ), + ) + const remaining = Math.max(1, maximumCases - cases.length) + const stride = Math.max(1, Math.floor(input.length / remaining)) + for ( + let offset = 0; + offset < input.length && cases.length < maximumCases; + offset += stride + ) { + const mutated = input.slice() + mutated[offset] = (mutated[offset] ?? 0) ^ 0xff + push(`bitwise byte mutation at ${offset}`, mutated) + } + return cases +} + +export interface NativeMutationCampaignResult { + total: number + completed: number + timeouts: readonly Readonly<{ + id: string + message: string + }>[] +} + +export async function runNativeMutationCampaign( + mutations: readonly NativeMutationCase[], + exercise: (bytes: Uint8Array) => unknown | Promise, + options: Readonly<{ + signal?: AbortSignal + timeoutMs?: number + }> = {}, +): Promise { + const timeouts: { id: string; message: string }[] = [] + let completed = 0 + for (const mutation of mutations) { + if (options.signal?.aborted === true) { + throw new DOMException('Mutation campaign aborted.', 'AbortError') + } + try { + if (options.timeoutMs === undefined) { + await exercise(mutation.bytes) + } else { + let timer: ReturnType | undefined + try { + await Promise.race([ + Promise.resolve(exercise(mutation.bytes)), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new DOMException( + `Mutation ${mutation.id} timed out.`, + 'TimeoutError', + )) + }, options.timeoutMs) + }), + ]) + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + timeouts.push({ id: mutation.id, message: error.message }) + } + } + completed++ + } + return { + total: mutations.length, + completed, + timeouts, + } +} + +export interface NativeOracleManifest { + schemaVersion: 1 + fixtureId: string + illustratorVersion: string + extension: 'ai' | 'ait' + license: string + sourceSha256: string + structureOracle?: Readonly<{ + artboards?: number + layers?: number + groups?: number + paths?: number + compoundPaths?: number + clipGroups?: number + textFrames?: number + rasterImages?: number + placedArt?: number + resources?: number + requiredFonts?: readonly string[] + fidelity?: NativeFidelity + }> + visualOracle?: Readonly<{ + renderer: string + width: number + height: number + pixelSha256?: string + channelThreshold?: number + maximumDifferentPixelRatio?: number + maximumMeanChannelDelta?: number + }> + notes?: string +} + +export interface NativeStructureSnapshot { + artboards: number + layers: number + groups: number + paths: number + compoundPaths: number + clipGroups: number + textFrames: number + rasterImages: number + placedArt: number + resources: number + requiredFonts: readonly string[] + fidelity: NativeFidelity +} + +export interface NativeVisualDiffResult { + width: number + height: number + pixels: number + differentPixels: number + differentPixelRatio: number + meanChannelDelta: number + maximumChannelDelta: number + passed: boolean + diagnostics: readonly string[] +} + +export interface NativeOracleEvaluation { + fixtureId: string + structureDiagnostics: readonly string[] + visual?: NativeVisualDiffResult + passed: boolean + evidence: readonly NativeFidelityEvidence[] +} + +export function validateNativeOracleManifest( + value: unknown, +): readonly string[] { + const diagnostics: string[] = [] + const manifest = asNativeRecord(value) + if (manifest?.schemaVersion !== 1) { + diagnostics.push('schemaVersion must equal 1.') + } + for (const field of [ + 'fixtureId', + 'illustratorVersion', + 'license', + 'sourceSha256', + ] as const) { + const fieldValue = manifest?.[field] + if (typeof fieldValue !== 'string' || fieldValue.trim() === '') { + diagnostics.push(`${field} must be a non-empty string.`) + } + } + if (manifest?.extension !== 'ai' && manifest?.extension !== 'ait') { + diagnostics.push('extension must be ai or ait.') + } + if ( + typeof manifest?.sourceSha256 === 'string' + && !/^[a-f0-9]{64}$/iu.test(manifest.sourceSha256) + ) { + diagnostics.push( + 'sourceSha256 must be a 64-character hexadecimal SHA-256 digest.', + ) + } + const structure = asNativeRecord(manifest?.structureOracle) + if (structure !== undefined) { + for (const field of [ + 'artboards', + 'layers', + 'groups', + 'paths', + 'compoundPaths', + 'clipGroups', + 'textFrames', + 'rasterImages', + 'placedArt', + 'resources', + ] as const) { + const fieldValue = structure[field] + if ( + fieldValue !== undefined + && ( + !Number.isSafeInteger(fieldValue) + || (fieldValue as number) < 0 + ) + ) { + diagnostics.push( + `structureOracle.${field} must be a non-negative integer.`, + ) + } + } + if ( + structure.fidelity !== undefined + && !isNativeFidelity(structure.fidelity) + ) diagnostics.push('structureOracle.fidelity is invalid.') + if ( + structure.requiredFonts !== undefined + && !Array.isArray(structure.requiredFonts) + ) { + diagnostics.push( + 'structureOracle.requiredFonts must be an array.', + ) + } + } + const visual = asNativeRecord(manifest?.visualOracle) + if (visual !== undefined) { + if (typeof visual.renderer !== 'string' || visual.renderer.trim() === '') { + diagnostics.push('visualOracle.renderer is required.') + } + for (const field of ['width', 'height'] as const) { + const fieldValue = visual[field] + if (!Number.isSafeInteger(fieldValue) || (fieldValue as number) <= 0) { + diagnostics.push( + `visualOracle.${field} must be a positive integer.`, + ) + } + } + } + return diagnostics +} + +function sceneFidelity(scene: unknown): NativeFidelity { + const record = asNativeRecord(scene) + return isNativeFidelity(record?.fidelity) + ? record.fidelity + : 'structure-only' +} + +export function nativeStructureSnapshot( + scene: unknown, + resources?: NativeResourceGraph, + requiredFonts: readonly string[] = [], + artboardCount?: number, +): NativeStructureSnapshot { + const counts = { + layers: 0, + groups: 0, + paths: 0, + compoundPaths: 0, + clipGroups: 0, + textFrames: 0, + rasterImages: 0, + placedArt: 0, + } + walkNativeScene(scene, (node) => { + const type = typeof node.type === 'string' + ? node.type + : typeof node.kind === 'string' + ? node.kind + : '' + if (type === 'Layer' || type === 'layer') counts.layers++ + else if (type === 'Group' || type === 'group') counts.groups++ + else if (type === 'Path' || type === 'path') counts.paths++ + else if (type === 'CompoundPath' || type === 'compound-path') { + counts.compoundPaths++ + } else if (type === 'ClipGroup' || type === 'clip-group') { + counts.clipGroups++ + } else if (type === 'Text' || type === 'text') counts.textFrames++ + else if (type === 'RasterImage' || type === 'raster-image') { + counts.rasterImages++ + } else if (type === 'PlacedArt' || type === 'placed-art') { + counts.placedArt++ + } + }) + const record = asNativeRecord(scene) + const sceneArtboards = Array.isArray(record?.artboards) + ? record.artboards.length + : 0 + const sceneResources = asNativeRecord(record?.resources) + return { + artboards: artboardCount ?? sceneArtboards, + ...counts, + resources: resources?.values().length + ?? (sceneResources === undefined ? 0 : Object.keys(sceneResources).length), + requiredFonts: [...new Set(requiredFonts)].sort(), + fidelity: sceneFidelity(scene), + } +} + +export function compareNativeStructureOracle( + actual: NativeStructureSnapshot, + expected: NonNullable, +): readonly string[] { + const diagnostics: string[] = [] + for (const field of [ + 'artboards', + 'layers', + 'groups', + 'paths', + 'compoundPaths', + 'clipGroups', + 'textFrames', + 'rasterImages', + 'placedArt', + 'resources', + ] as const) { + const expectation = expected[field] + if (expectation !== undefined && actual[field] !== expectation) { + diagnostics.push( + `${field}: expected ${expectation}, received ${actual[field]}.`, + ) + } + } + if (expected.requiredFonts !== undefined) { + const actualFonts = [...actual.requiredFonts].sort().join('\n') + const expectedFonts = [...expected.requiredFonts].sort().join('\n') + if (actualFonts !== expectedFonts) { + diagnostics.push( + `requiredFonts: expected [${expected.requiredFonts.join(', ')}], received [${actual.requiredFonts.join(', ')}].`, + ) + } + } + if ( + expected.fidelity !== undefined + && actual.fidelity !== expected.fidelity + ) { + diagnostics.push( + `fidelity: expected ${expected.fidelity}, received ${actual.fidelity}.`, + ) + } + return diagnostics +} + +export function compareNativeRgbaOracle( + actual: Uint8Array, + expected: Uint8Array, + width: number, + height: number, + options: Readonly<{ + channelThreshold?: number + maximumDifferentPixelRatio?: number + maximumMeanChannelDelta?: number + }> = {}, +): NativeVisualDiffResult { + if (!Number.isSafeInteger(width) || width <= 0) { + throw new RangeError('width must be a positive integer.') + } + if (!Number.isSafeInteger(height) || height <= 0) { + throw new RangeError('height must be a positive integer.') + } + const required = width * height * 4 + if (actual.byteLength !== required || expected.byteLength !== required) { + throw new RangeError( + `RGBA buffers must contain exactly ${required} bytes.`, + ) + } + const channelThreshold = Math.max( + 0, + Math.min(255, options.channelThreshold ?? 8), + ) + const maximumDifferentPixelRatio = Math.max( + 0, + Math.min(1, options.maximumDifferentPixelRatio ?? 0.001), + ) + const maximumMeanChannelDelta = Math.max( + 0, + Math.min(255, options.maximumMeanChannelDelta ?? 1), + ) + let differentPixels = 0 + let sumDelta = 0 + let maximumChannelDelta = 0 + for (let pixel = 0; pixel < width * height; pixel++) { + let pixelDifferent = false + for (let channel = 0; channel < 4; channel++) { + const index = pixel * 4 + channel + const delta = Math.abs( + (actual[index] ?? 0) - (expected[index] ?? 0), + ) + sumDelta += delta + maximumChannelDelta = Math.max(maximumChannelDelta, delta) + if (delta > channelThreshold) pixelDifferent = true + } + if (pixelDifferent) differentPixels++ + } + const pixels = width * height + const differentPixelRatio = differentPixels / pixels + const meanChannelDelta = sumDelta / required + const diagnostics: string[] = [] + if (differentPixelRatio > maximumDifferentPixelRatio) { + diagnostics.push( + `Different pixel ratio ${differentPixelRatio} exceeds ${maximumDifferentPixelRatio}.`, + ) + } + if (meanChannelDelta > maximumMeanChannelDelta) { + diagnostics.push( + `Mean channel delta ${meanChannelDelta} exceeds ${maximumMeanChannelDelta}.`, + ) + } + return { + width, + height, + pixels, + differentPixels, + differentPixelRatio, + meanChannelDelta, + maximumChannelDelta, + passed: diagnostics.length === 0, + diagnostics, + } +} + +export function evaluateNativeOracle( + manifest: NativeOracleManifest, + actual: NativeStructureSnapshot, + visual?: Readonly<{ + actual: Uint8Array + expected: Uint8Array + }>, +): NativeOracleEvaluation { + const manifestDiagnostics = validateNativeOracleManifest(manifest) + const structureDiagnostics = manifest.structureOracle === undefined + ? ['No structure Oracle is declared.'] + : compareNativeStructureOracle(actual, manifest.structureOracle) + const visualResult = manifest.visualOracle === undefined || visual === undefined + ? undefined + : compareNativeRgbaOracle( + visual.actual, + visual.expected, + manifest.visualOracle.width, + manifest.visualOracle.height, + { + ...(manifest.visualOracle.channelThreshold === undefined + ? {} + : { + channelThreshold: manifest.visualOracle.channelThreshold, + }), + ...(manifest.visualOracle.maximumDifferentPixelRatio === undefined + ? {} + : { + maximumDifferentPixelRatio: + manifest.visualOracle.maximumDifferentPixelRatio, + }), + ...(manifest.visualOracle.maximumMeanChannelDelta === undefined + ? {} + : { + maximumMeanChannelDelta: + manifest.visualOracle.maximumMeanChannelDelta, + }), + }, + ) + const structurePassed = manifestDiagnostics.length === 0 + && structureDiagnostics.length === 0 + const visualPassed = manifest.visualOracle === undefined + ? true + : visualResult?.passed === true + const evidence: NativeFidelityEvidence[] = [ + { + id: `${manifest.fixtureId}:real-fixture`, + kind: 'real-illustrator-fixture', + status: manifestDiagnostics.length === 0 ? 'passed' : 'failed', + versions: [manifest.illustratorVersion], + source: manifest.fixtureId, + }, + { + id: `${manifest.fixtureId}:structure`, + kind: 'structure-oracle', + status: manifest.structureOracle === undefined + ? 'missing' + : structurePassed + ? 'passed' + : 'failed', + versions: [manifest.illustratorVersion], + source: manifest.fixtureId, + notes: structureDiagnostics.join(' '), + }, + { + id: `${manifest.fixtureId}:visual`, + kind: 'visual-oracle', + status: manifest.visualOracle === undefined || visual === undefined + ? 'missing' + : visualPassed + ? 'passed' + : 'failed', + versions: [manifest.illustratorVersion], + source: manifest.fixtureId, + ...(visualResult === undefined + ? {} + : { notes: visualResult.diagnostics.join(' ') }), + }, + ] + return { + fixtureId: manifest.fixtureId, + structureDiagnostics: [ + ...manifestDiagnostics, + ...structureDiagnostics, + ], + ...(visualResult === undefined ? {} : { visual: visualResult }), + passed: structurePassed && visualPassed, + evidence, + } +} + +export interface NativeBenchmarkResult { + iterations: number + minimumMs: number + medianMs: number + p95Ms: number + maximumMs: number + meanMs: number +} + +function percentile(sorted: readonly number[], ratio: number): number { + if (sorted.length === 0) return 0 + const index = Math.min( + sorted.length - 1, + Math.max(0, Math.ceil(sorted.length * ratio) - 1), + ) + return sorted[index] ?? 0 +} + +export async function benchmarkNativeOperation( + operation: () => unknown | Promise, + iterations = 10, + now: () => number = () => performance.now(), +): Promise { + if (!Number.isSafeInteger(iterations) || iterations < 1) { + throw new RangeError('iterations must be a positive safe integer.') + } + const measurements: number[] = [] + for (let index = 0; index < iterations; index++) { + const start = now() + await operation() + measurements.push(Math.max(0, now() - start)) + } + measurements.sort((left, right) => left - right) + const sum = measurements.reduce((total, value) => total + value, 0) + return { + iterations, + minimumMs: measurements[0] ?? 0, + medianMs: percentile(measurements, 0.5), + p95Ms: percentile(measurements, 0.95), + maximumMs: measurements[measurements.length - 1] ?? 0, + meanMs: sum / measurements.length, + } +} + +export function performanceEvidence( + id: string, + result: NativeBenchmarkResult, + maximumP95Ms: number, +): NativeFidelityEvidence { + const passed = result.p95Ms <= maximumP95Ms + return { + id, + kind: 'performance-budget', + status: passed ? 'passed' : 'failed', + notes: `p95=${result.p95Ms}ms, budget=${maximumP95Ms}ms`, + } +} diff --git a/src/native-worker.ts b/src/native-worker.ts new file mode 100644 index 0000000..b3727d5 --- /dev/null +++ b/src/native-worker.ts @@ -0,0 +1,544 @@ +import type { + NativeFidelity, + NativeFidelityEvidence, +} from './native-foundation.js' +import { + openNativeIllustratorDocument, + type NativeIllustratorDocumentSession, + type NativeIllustratorSummary, +} from './native-pipeline.js' +import type { NativeRenderPlan } from './native-render.js' +import type { NativeSecurityLimits } from './native-verification.js' + +export interface NativeSerializableOpenOptions { + evidence?: readonly NativeFidelityEvidence[] + requestedFidelity?: NativeFidelity + resourceCacheBytes?: number + securityLimits?: Readonly> +} + +export type NativeIllustratorWorkerRequest = + | Readonly<{ + id: number + type: 'open' + bytes: ArrayBuffer + options?: NativeSerializableOpenOptions + }> + | Readonly<{ id: number; type: 'summary'; sessionId: string }> + | Readonly<{ id: number; type: 'ast'; sessionId: string }> + | Readonly<{ id: number; type: 'scene'; sessionId: string }> + | Readonly<{ id: number; type: 'render-plan'; sessionId: string }> + | Readonly<{ + id: number + type: 'svg' + sessionId: string + options?: Readonly<{ + width?: number + height?: number + namespace?: string + }> + }> + | Readonly<{ id: number; type: 'dispose'; sessionId: string }> + | Readonly<{ id: number; type: 'dispose-all' }> + +export type NativeIllustratorWorkerResponse = + | Readonly<{ + id: number + ok: true + type: NativeIllustratorWorkerRequest['type'] + value?: unknown + }> + | Readonly<{ + id: number + ok: false + type: NativeIllustratorWorkerRequest['type'] | 'invalid' + error: Readonly<{ + name: string + message: string + stack?: string + }> + }> + +export interface NativeIllustratorWorkerScope { + addEventListener( + type: 'message', + listener: (event: MessageEvent) => void, + ): void + removeEventListener?( + type: 'message', + listener: (event: MessageEvent) => void, + ): void + postMessage(message: NativeIllustratorWorkerResponse): void +} + +export interface InstallNativeIllustratorWorkerOptions { + maximumSessions?: number + decodeOptions?: unknown + parseOptions?: unknown +} + +function asWorkerRequest( + value: unknown, +): NativeIllustratorWorkerRequest | undefined { + if (typeof value !== 'object' || value === null) return undefined + const record = value as Record + if (!Number.isSafeInteger(record.id) || typeof record.type !== 'string') { + return undefined + } + const valid = new Set([ + 'open', + 'summary', + 'ast', + 'scene', + 'render-plan', + 'svg', + 'dispose', + 'dispose-all', + ]) + return valid.has(record.type) + ? value as NativeIllustratorWorkerRequest + : undefined +} + +function serializeWorkerError(error: unknown): Readonly<{ + name: string + message: string + stack?: string +}> { + if (error instanceof Error) { + return { + name: error.name, + message: error.message, + ...(error.stack === undefined ? {} : { stack: error.stack }), + } + } + return { name: 'Error', message: String(error) } +} + +export function installNativeIllustratorWorker( + scope: NativeIllustratorWorkerScope, + options: InstallNativeIllustratorWorkerOptions = {}, +): Readonly<{ dispose(): void }> { + const maximumSessions = options.maximumSessions ?? 8 + if (!Number.isSafeInteger(maximumSessions) || maximumSessions < 1) { + throw new RangeError( + 'maximumSessions must be a positive safe integer.', + ) + } + const sessions = new Map() + let sequence = 0 + let disposed = false + + const reply = (response: NativeIllustratorWorkerResponse): void => { + scope.postMessage(response) + } + + const session = (id: string): NativeIllustratorDocumentSession => { + const value = sessions.get(id) + if (value === undefined) { + throw new Error(`Unknown native Illustrator session ${id}.`) + } + sessions.delete(id) + sessions.set(id, value) + return value + } + + const enforceSessionLimit = (): void => { + while (sessions.size > maximumSessions) { + const oldest = sessions.entries().next().value as + | [string, NativeIllustratorDocumentSession] + | undefined + if (oldest === undefined) break + sessions.delete(oldest[0]) + oldest[1].dispose() + } + } + + const onMessage = (event: MessageEvent): void => { + if (disposed) return + const request = asWorkerRequest(event.data) + if (request === undefined) { + reply({ + id: -1, + ok: false, + type: 'invalid', + error: { + name: 'TypeError', + message: 'Worker request is malformed.', + }, + }) + return + } + void (async () => { + try { + if (request.type === 'open') { + if (!(request.bytes instanceof ArrayBuffer)) { + throw new TypeError('open.bytes must be an ArrayBuffer.') + } + const opened = await openNativeIllustratorDocument( + new Uint8Array(request.bytes), + { + ...(request.options ?? {}), + decodeOptions: options.decodeOptions, + parseOptions: options.parseOptions, + }, + ) + const sessionId = `native-${++sequence}` + sessions.set(sessionId, opened) + enforceSessionLimit() + reply({ + id: request.id, + ok: true, + type: request.type, + value: { + sessionId, + summary: opened.summary(), + }, + }) + } else if (request.type === 'summary') { + reply({ + id: request.id, + ok: true, + type: request.type, + value: session(request.sessionId).summary(), + }) + } else if (request.type === 'ast') { + reply({ + id: request.id, + ok: true, + type: request.type, + value: session(request.sessionId).ast, + }) + } else if (request.type === 'scene') { + reply({ + id: request.id, + ok: true, + type: request.type, + value: session(request.sessionId).scene, + }) + } else if (request.type === 'render-plan') { + reply({ + id: request.id, + ok: true, + type: request.type, + value: session(request.sessionId).renderPlan, + }) + } else if (request.type === 'svg') { + reply({ + id: request.id, + ok: true, + type: request.type, + value: session(request.sessionId).toSvg( + request.options ?? {}, + ), + }) + } else if (request.type === 'dispose') { + const value = sessions.get(request.sessionId) + sessions.delete(request.sessionId) + value?.dispose() + reply({ + id: request.id, + ok: true, + type: request.type, + value: true, + }) + } else { + for (const value of sessions.values()) value.dispose() + sessions.clear() + reply({ + id: request.id, + ok: true, + type: request.type, + value: true, + }) + } + } catch (error) { + reply({ + id: request.id, + ok: false, + type: request.type, + error: serializeWorkerError(error), + }) + } + })() + } + + scope.addEventListener('message', onMessage) + return { + dispose() { + if (disposed) return + disposed = true + scope.removeEventListener?.('message', onMessage) + for (const value of sessions.values()) value.dispose() + sessions.clear() + }, + } +} + +export interface NativeWorkerLike { + postMessage(message: unknown, transfer?: Transferable[]): void + addEventListener( + type: 'message', + listener: (event: MessageEvent) => void, + ): void + addEventListener( + type: 'error' | 'messageerror', + listener: (event: Event) => void, + ): void + removeEventListener?( + type: 'message', + listener: (event: MessageEvent) => void, + ): void + removeEventListener?( + type: 'error' | 'messageerror', + listener: (event: Event) => void, + ): void + terminate(): void +} + +export interface NativeWorkerRequestOptions { + signal?: AbortSignal + timeoutMs?: number +} + +type PendingWorkerRequest = { + resolve(value: unknown): void + reject(error: unknown): void + timer?: ReturnType + signal?: AbortSignal + abort?: () => void +} + +type WithoutWorkerId = T extends unknown ? Omit : never +type NativeWorkerRequestWithoutId = WithoutWorkerId< + NativeIllustratorWorkerRequest +> + +function isWorkerResponse( + value: unknown, +): value is NativeIllustratorWorkerResponse { + if (typeof value !== 'object' || value === null) return false + const record = value as Record + return Number.isSafeInteger(record.id) + && typeof record.ok === 'boolean' + && typeof record.type === 'string' +} + +function deserializeWorkerError( + response: Extract, +): Error { + const error = new Error(response.error.message) + error.name = response.error.name + if (response.error.stack !== undefined) error.stack = response.error.stack + return error +} + +export class NativeIllustratorWorkerClient { + readonly #worker: NativeWorkerLike + readonly #pending = new Map() + #requestId = 0 + #fatal: Error | undefined + + constructor(worker: NativeWorkerLike) { + this.#worker = worker + worker.addEventListener('message', this.#onMessage) + worker.addEventListener('error', this.#onFatal) + worker.addEventListener('messageerror', this.#onFatal) + } + + get disposed(): boolean { return this.#fatal !== undefined } + + readonly #onMessage = (event: MessageEvent): void => { + if (!isWorkerResponse(event.data)) return + const pending = this.#pending.get(event.data.id) + if (pending === undefined) return + this.#pending.delete(event.data.id) + this.#cleanupPending(pending) + if (event.data.ok) pending.resolve(event.data.value) + else pending.reject(deserializeWorkerError(event.data)) + } + + readonly #onFatal = (event: Event): void => { + const record = event as Event & { + error?: unknown + message?: string + } + const error = record.error instanceof Error + ? record.error + : new Error(record.message ?? `Native worker ${event.type}.`) + this.#terminate(error) + } + + #cleanupPending(pending: PendingWorkerRequest): void { + if (pending.timer !== undefined) clearTimeout(pending.timer) + if (pending.signal !== undefined && pending.abort !== undefined) { + pending.signal.removeEventListener('abort', pending.abort) + } + } + + #terminate(error: Error): void { + if (this.#fatal !== undefined) return + this.#fatal = error + this.#worker.removeEventListener?.('message', this.#onMessage) + this.#worker.removeEventListener?.('error', this.#onFatal) + this.#worker.removeEventListener?.('messageerror', this.#onFatal) + this.#worker.terminate() + for (const pending of this.#pending.values()) { + this.#cleanupPending(pending) + pending.reject(error) + } + this.#pending.clear() + } + + #request( + request: NativeWorkerRequestWithoutId, + options: NativeWorkerRequestOptions = {}, + transfer: Transferable[] = [], + ): Promise { + if (this.#fatal !== undefined) return Promise.reject(this.#fatal) + if (options.signal?.aborted === true) { + const error = new DOMException( + 'Worker request aborted.', + 'AbortError', + ) + this.#terminate(error) + return Promise.reject(error) + } + const id = ++this.#requestId + return new Promise((resolve, reject) => { + const pending: PendingWorkerRequest = { + resolve: (value) => resolve(value as T), + reject, + } + if (options.timeoutMs !== undefined) { + if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) { + reject(new RangeError( + 'timeoutMs must be a positive finite number.', + )) + return + } + pending.timer = setTimeout(() => { + this.#terminate(new DOMException( + `Worker request ${id} timed out.`, + 'TimeoutError', + )) + }, options.timeoutMs) + } + if (options.signal !== undefined) { + pending.signal = options.signal + pending.abort = () => { + this.#terminate(new DOMException( + `Worker request ${id} aborted.`, + 'AbortError', + )) + } + options.signal.addEventListener( + 'abort', + pending.abort, + { once: true }, + ) + } + this.#pending.set(id, pending) + try { + this.#worker.postMessage({ ...request, id }, transfer) + } catch (error) { + this.#pending.delete(id) + this.#cleanupPending(pending) + reject(error) + } + }) + } + + open( + bytes: Uint8Array, + options: NativeWorkerRequestOptions & Readonly<{ + document?: NativeSerializableOpenOptions + }> = {}, + ): Promise> { + const owned = new Uint8Array(bytes.byteLength) + owned.set(bytes) + const buffer = owned.buffer + return this.#request( + { + type: 'open', + bytes: buffer, + ...(options.document === undefined + ? {} + : { options: options.document }), + }, + options, + [buffer], + ) + } + + summary( + sessionId: string, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request({ type: 'summary', sessionId }, options) + } + + ast( + sessionId: string, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request({ type: 'ast', sessionId }, options) + } + + scene( + sessionId: string, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request({ type: 'scene', sessionId }, options) + } + + renderPlan( + sessionId: string, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request( + { type: 'render-plan', sessionId }, + options, + ) + } + + svg( + sessionId: string, + svgOptions: Readonly<{ + width?: number + height?: number + namespace?: string + }> = {}, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request( + { type: 'svg', sessionId, options: svgOptions }, + options, + ) + } + + disposeSession( + sessionId: string, + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request( + { type: 'dispose', sessionId }, + options, + ) + } + + disposeAll( + options: NativeWorkerRequestOptions = {}, + ): Promise { + return this.#request({ type: 'dispose-all' }, options) + } + + dispose(): void { + this.#terminate( + new Error('Native Illustrator worker client disposed.'), + ) + } +} diff --git a/tests/native-plan.test.ts b/tests/native-plan.test.ts new file mode 100644 index 0000000..8c3feab --- /dev/null +++ b/tests/native-plan.test.ts @@ -0,0 +1,450 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { + ByteLruCache, + CooperativeBudget, + createBrowserZstdCodecProvider, + detectIllustratorVersionProfile, + exactCubicBezierBounds, + resolveNativeFidelity, +} from '../src/native-foundation.js' +import { + buildNativeAdvancedObjectModel, + buildNativeResourceGraph, + buildNativeTextModel, + buildNativeTransparencyModel, + classifyNativeSourceSections, + decodeNativeResources, + extractNativeArtboards, + resolveNativeResource, +} from '../src/native-analysis.js' +import { + buildNativeRenderPlan, + renderNativePlanToSvg, +} from '../src/native-render.js' +import { + compareNativeRgbaOracle, + compareNativeStructureOracle, + createDeterministicNativeMutations, + nativeStructureSnapshot, + scanNativeSourceSecurity, + validateNativeOracleManifest, +} from '../src/native-verification.js' +import { openNativeIllustratorDocument } from '../src/native-pipeline.js' +import { DIRECT_SOURCE_BYTES } from './fixtures.js' + +test('high fidelity cannot be promoted without real Illustrator and Oracle evidence', () => { + const decision = resolveNativeFidelity('high', [ + { + id: 'synthetic', + kind: 'synthetic-fixture', + status: 'passed', + }, + { + id: 'security', + kind: 'security-budget', + status: 'passed', + }, + ]) + assert.equal(decision.effective, 'partial') + assert.equal(decision.promotable, false) + assert.ok(decision.missingEvidence.includes('structure-oracle')) + assert.ok(decision.missingEvidence.includes('visual-oracle')) +}) + +test('high fidelity becomes promotable only when every required gate passes', () => { + const decision = resolveNativeFidelity('high', [ + 'synthetic-fixture', + 'real-illustrator-fixture', + 'structure-oracle', + 'visual-oracle', + 'performance-budget', + 'security-budget', + ].map((kind, index) => ({ + id: `evidence-${index}`, + kind: kind as + | 'synthetic-fixture' + | 'real-illustrator-fixture' + | 'structure-oracle' + | 'visual-oracle' + | 'performance-budget' + | 'security-budget', + status: 'passed' as const, + }))) + assert.equal(decision.effective, 'high') + assert.equal(decision.promotable, true) +}) + +test('version profile distinguishes AI24 zstd private source', () => { + const profile = detectIllustratorVersionProfile([ + '%!PS-Adobe-3.0', + '%AI24_CreatorVersion: 24.0', + '%AI24_ZStandard_Data', + '%%Creator: Adobe Illustrator 24.0', + ].join('\n')) + assert.equal(profile.family, 'ai24') + assert.equal(profile.privateCompression, 'zstd') + assert.equal(profile.capabilities.zstdPrivateSource, true) +}) + +test('cubic bounds include derivative extrema rather than control-box bounds', () => { + const bounds = exactCubicBezierBounds( + { x: 0, y: 0 }, + { x: 0, y: 100 }, + { x: 100, y: 100 }, + { x: 100, y: 0 }, + ) + assert.equal(bounds.left, 0) + assert.equal(bounds.right, 100) + assert.ok(Math.abs(bounds.bottom - 75) < 1e-9) +}) + +test('byte-accounted LRU evicts the least recently used entries', () => { + const cache = new ByteLruCache(8) + cache.set('a', 'A', 4) + cache.set('b', 'B', 4) + assert.equal(cache.get('a'), 'A') + const evicted = cache.set('c', 'C', 4) + assert.deepEqual(evicted.map((entry) => entry.key), ['b']) + assert.deepEqual(cache.entries().map((entry) => entry.key), ['a', 'c']) +}) + +test('cooperative budget enforces operation and abort limits', () => { + const budget = new CooperativeBudget({ maximumOperations: 2 }) + budget.checkpoint() + budget.checkpoint() + assert.throws(() => budget.checkpoint(), /budget/iu) + const controller = new AbortController() + controller.abort() + assert.throws( + () => new CooperativeBudget({ signal: controller.signal }).checkpoint(), + /abort/iu, + ) +}) + +test('browser zstd adapter validates frame and decompressed byte limits', async () => { + const provider = createBrowserZstdCodecProvider({ + decode: () => Uint8Array.of(1, 2), + }) + const frame = Uint8Array.of(0x28, 0xb5, 0x2f, 0xfd) + assert.deepEqual( + [...await provider.zstdDecompress(frame, 2)], + [1, 2], + ) + await assert.rejects( + () => provider.zstdDecompress(frame, 1), + /limit/iu, + ) + await assert.rejects( + () => provider.zstdDecompress(Uint8Array.of(1, 2, 3, 4), 8), + /magic/iu, + ) +}) + +test('modern artboard metadata retains UUID selection lock PAR ruler and bleed', () => { + const source = [ + '%!PS-Adobe-3.0', + '3 /Real (BleedTopValue)', + '4 /Real (BleedRightValue)', + '5 /Real (BleedBottomValue)', + '6 /Real (BleedLeftValue)', + '%_/Dictionary :', + '0 100 /RealPointRelToROrigin %_ (PositionPoint1)', + '200 0 /RealPointRelToROrigin %_ (PositionPoint2)', + '10 20 /RealPoint %_ (RulerOrigin)', + '%_ (Board\\040One) /UnicodeString (Name)', + '%_ (uuid-1) /String (ArtboardUUID)', + '%_ 1 /Bool (IsArtboardSelected)', + '%_ 1 /Bool (IsArtboardLocked)', + '%_ 1.25 /Real (PAR)', + '(ArtboardArray)', + '%%EOF', + ].join('\n') + const artboards = extractNativeArtboards(source) + assert.equal(artboards.length, 1) + assert.equal(artboards[0]?.name, 'Board One') + assert.equal(artboards[0]?.uuid, 'uuid-1') + assert.equal(artboards[0]?.selected, true) + assert.equal(artboards[0]?.locked, true) + assert.equal(artboards[0]?.pixelAspectRatio, 1.25) + assert.deepEqual(artboards[0]?.rulerOrigin, { x: 10, y: 20 }) + assert.deepEqual(artboards[0]?.bleed, { + top: 3, + right: 4, + bottom: 5, + left: 6, + }) +}) + +test('lossless AST statements are classified into source sections without rewriting raw data', () => { + const ast = { + statements: [ + { kind: 'comment', raw: '%%BeginProlog\n', span: { start: 0, end: 14 } }, + { kind: 'operator', operator: 'def', raw: '/x 1 def\n', span: { start: 14, end: 23 } }, + { kind: 'comment', raw: '%%EndProlog\n', span: { start: 23, end: 35 } }, + { kind: 'operator', operator: 'm', raw: '0 0 m\n', span: { start: 35, end: 41 } }, + { kind: 'comment', raw: '%%Trailer\n', span: { start: 41, end: 51 } }, + { kind: 'comment', raw: '%%EOF\n', span: { start: 51, end: 57 } }, + ], + } + const sections = classifyNativeSourceSections(ast) + assert.ok(sections.sections.some((entry) => entry.kind === 'prolog')) + assert.ok(sections.sections.some((entry) => entry.kind === 'drawing')) + assert.ok(sections.sections.some((entry) => entry.kind === 'trailer')) +}) + +test('resource graph and resource decoders retain gradient pattern raster and advanced identities', () => { + const ast = { + statements: [ + { + kind: 'operator', + operator: 'Bd', + operands: [{ value: 'Gradient A' }, 0], + raw: '(Gradient A) 0 Bd\n', + }, + { + kind: 'operator', + operator: 'noop', + operands: [], + raw: '0 0.5 %_BS\n1 0.5 %_BS\n', + }, + { kind: 'operator', operator: 'BD', operands: [], raw: 'BD\n' }, + { + kind: 'operator', + operator: 'PB', + operands: [{ value: 'Pattern A' }, 1, 1, 0, 0, 20, 20], + raw: '(Pattern A) 1 1 0 0 20 20 PB\n', + }, + { kind: 'operator', operator: 'PE', operands: [], raw: 'PE\n' }, + { + kind: 'operator', + operator: 'XI', + operands: [32, 16, 8, { value: 'DeviceRGB' }], + raw: '32 16 8 /DeviceRGB XI\n', + }, + { + kind: 'operator', + operator: 'AI9_BeginSymbol', + operands: [{ value: 'Symbol A' }], + raw: '(Symbol A) AI9_BeginSymbol\n', + }, + { + kind: 'operator', + operator: 'AI9_EndSymbol', + operands: [], + raw: 'AI9_EndSymbol\n', + }, + ], + } + const graph = buildNativeResourceGraph(ast) + const decoded = decodeNativeResources('%!PS-Adobe-3.0\n', ast, graph) + const kinds = new Set(graph.values().map((entry) => entry.kind)) + assert.equal(kinds.has('gradient'), true) + assert.equal(kinds.has('pattern'), true) + assert.equal(kinds.has('embedded-raster'), true) + assert.equal(kinds.has('symbol'), true) + assert.equal(decoded.gradients.length, 1) + assert.equal(decoded.gradients[0]?.stops.length, 2) + assert.equal(decoded.patterns.length, 1) + assert.equal(decoded.rasters.length, 1) + const advanced = buildNativeAdvancedObjectModel(ast, { children: [] }, graph) + assert.ok(advanced.objects.some((entry) => entry.kind === 'symbol-definition')) +}) + +test('text transparency and advanced object models retain structure and downgrade unsupported layout', () => { + const scene = { + children: [ + { + id: 'area-text', + type: 'Text', + textKind: 'area', + storyId: 'story-1', + runs: [{ + text: 'Hello', + fontPostScriptName: 'ExamplePS', + fontSize: 12, + }], + }, + { + id: 'effect-1', + type: 'LiveEffect', + appearance: { + opacity: 0.5, + blendMode: 'multiply', + maskResourceId: 'mask-1', + }, + children: [], + }, + ], + } + const text = buildNativeTextModel(scene) + assert.equal(text.frames[0]?.kind, 'area') + assert.deepEqual(text.requiredFonts, ['ExamplePS']) + assert.equal(text.frames[0]?.fidelity, 'structure-only') + const transparency = buildNativeTransparencyModel(scene) + assert.equal(transparency.records[0]?.blendMode, 'multiply') + assert.equal(transparency.records[0]?.maskResourceId, 'mask-1') + const advanced = buildNativeAdvancedObjectModel( + { statements: [] }, + scene, + buildNativeResourceGraph({ statements: [] }), + ) + assert.ok(advanced.objects.some((entry) => entry.kind === 'live-effect')) +}) + +test('shared render plan preserves clip ordering and SVG safety', () => { + const clipGeometry = { + fillRule: 'evenodd', + contours: [{ + closed: true, + points: [ + { x: 0, y: 0 }, + { x: 100, y: 0 }, + { x: 100, y: 100 }, + { x: 0, y: 100 }, + ], + }], + } + const pathGeometry = { + contours: [{ + closed: false, + points: [ + { x: 10, y: 10 }, + { x: 90, y: 90 }, + ], + }], + } + const scene = { + children: [ + { + id: 'clip-group', + type: 'ClipGroup', + children: [ + { + id: 'clip-path', + type: 'Path', + clippingPath: true, + geometry: clipGeometry, + }, + { + id: 'painted-path', + type: 'Path', + geometry: pathGeometry, + appearance: { + fills: [{ paint: { kind: 'rgb', red: 1, green: 0, blue: 0 } }], + }, + }, + ], + }, + { + id: 'unknown', + type: 'UnknownNode', + operator: 'vendorVisibleOperator', + }, + ], + } + const first = buildNativeRenderPlan(scene) + const second = buildNativeRenderPlan(scene) + assert.equal(first.deterministicHash, second.deterministicHash) + const beginClip = first.operations.findIndex((entry) => entry.kind === 'begin-clip') + const path = first.operations.findIndex((entry) => entry.kind === 'path') + const endClip = first.operations.findIndex((entry) => entry.kind === 'end-clip') + assert.ok(beginClip >= 0 && path > beginClip && endClip > path) + assert.ok(first.operations.some((entry) => entry.kind === 'unsupported')) + const svg = renderNativePlanToSvg(first, { + namespace: 'safe