diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df00c6..d6a676e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.6.5 - 2026-08-24 + +- Added a linear-time parser for pre-DWF-6 readable WHIP!/W2D streams such as AutoCAD R14 DWF V00.34 files. +- Added palette/background handling and core `C`, `L`, `P`, `T`, `M`, `F/f`, and `V/v` opcode support, including multiline counted point sets and correct triangle-strip triangulation. +- Added a public synthetic legacy ASCII regression fixture while keeping customer drawings out of the repository. +- Fixed strict TypeScript builds for path-command bounds calculation so both CI and the Cloudflare Pages demo build remain green. + ## 0.6.4 - 2026-06-12 - Restored colorful W3D/HSF 3D rendering for eModel files whose ContentDefinition instance count does not match decoded shell geometry, avoiding incorrect ordinal material binding that could wash Robot Arm into gray. diff --git a/README.md b/README.md index bd080e8..226f669 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ English is the default language for the README, npm package, and online demo. Th | npm | https://www.npmjs.com/package/dwf-viewer | | scoped npm | https://www.npmjs.com/package/@flyfish-dev/dwf-viewer | -Current version: `0.6.4` +Current version: `0.6.5` ## Why @@ -36,6 +36,7 @@ DWF Viewer 是面向 Web 的开源纯前端 DWF/DWFx 预览组件。它在浏览 | DWFx / OPC package | Supported | | XPS FixedPage 2D sheets | Supported common subset with WebGL vector acceleration and Canvas text/image overlay | | Classic binary WHIP!/W2D 2D sheets | Supported for core geometry/text/images used by Autodesk samples | +| Legacy readable ASCII WHIP!/W2D (including AutoCAD R14 DWF V00.34) | Supported for palette colors, backgrounds, core geometry, markers, fill state, and visibility | | Textual W2D pages | Supported for smoke tests and simple sheets | | W3D/HSF 3D eModel shell geometry | Supported: uncompressed, CS_TRIVIAL, and Edgebreaker shell meshes | | Three.js adapter | Supported | @@ -131,6 +132,12 @@ Version `0.6.3` adds a repair-and-fallback path for legacy Autodesk eModel XML m This keeps valid 3D DWFx models from surfacing noisy `EMODEL_CONTENTDEF_PARSE_FAILED` diagnostics when only optional metadata XML is malformed. +## Legacy AutoCAD R14 ASCII DWF + +Version `0.6.5` recognizes pre-DWF-6 readable WHIP!/W2D streams such as `DWF V00.34` files produced by AutoCAD R14. These files are plain opcode streams rather than DWF 6+ ZIP packages, so the loader routes them through a bounded stream parser instead of the generic line-oriented text fallback. + +The supported legacy subset includes palette and background colors, `C`, `L`, `P`, `T`, `M`, `F/f`, and `V/v` opcodes, multiline counted point sets, and triangle-strip triangulation. The source background is emitted consistently across Canvas, WASM, and WebGL render paths. + ## Three.js Integration ```ts @@ -173,6 +180,7 @@ The demo examples are listed in `examples/manifest.json` and are intentionally d | Example | Purpose | |---|---| | `blocks-and-tables.dwf` | Default demo. Binary WHIP!/W2D ePlot sample with fast loading and a clear first impression | +| `legacy-ascii-v0034.dwf` | Synthetic AutoCAD R14-style readable ASCII DWF regression covering multiline geometry, palette/background handling, triangle strips, fill state, markers, and visibility | | `autodesk-floor-plans.dwfx` | Multi-page architectural DWFx/XPS sample, using A03 First Floor Plan for WebGL XPS, embedded font, and thin-line overview validation | | `robot-arm.dwfx` | 3D W3D/HSF eModel with shell meshes, scene tree, materials, textures, and saved views | | `minimal-xps.dwfx` | Small DWFx/XPS FixedPage sample | diff --git a/examples/legacy-ascii-v0034.dwf b/examples/legacy-ascii-v0034.dwf new file mode 100644 index 0000000..8e09541 --- /dev/null +++ b/examples/legacy-ascii-v0034.dwf @@ -0,0 +1,23 @@ +(DWF V00.34) +(Author 'Regression fixture') +(Creator 'dwf-viewer') +(ColorMap 4 + 0,0,0,255 255,255,255,255 255,0,0,255 0,255,0,255) +(Background 0) +C 1 +L 0,0 100,0 +P 5 0,0 100,0 100,100 + 50,125 0,100 +C 2 +T 4 20,20 20,80 80,20 80,80 +F +C 3 +P 3 10,10 50,90 90,10 +f +M 1 50,50 +v +(URL 'javascript:void(0);') +L 0,0 100,100 +V +L 0,100 100,100 +(EndOfDWF) diff --git a/examples/manifest.json b/examples/manifest.json index 68dddeb..bf2b81c 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -12,6 +12,19 @@ "maxWarnings": 0 } }, + { + "id": "legacy-ascii-v0034", + "title": "AutoCAD R14 · Legacy ASCII DWF V00.34", + "kind": "2d", + "path": "legacy-ascii-v0034.dwf", + "description": "Dependency-free regression fixture for pre-DWF-6 readable WHIP!/W2D streams, including multiline point sets, palette colors, triangle strips, fill state, visibility, and source background.", + "descriptionZh": "早期 AutoCAD 可读 ASCII DWF V00.34 回归示例,覆盖跨行点集、调色板、三角带、填充、可见性和源文件背景。", + "expected": { + "pageKind": "w2d-text", + "pages": 1, + "maxWarnings": 0 + } + }, { "id": "autodesk-floor-plans-dwfx", "title": "Autodesk Floor Plans · 2D DWFx/XPS", diff --git a/package-lock.json b/package-lock.json index 96ad109..4af0da1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dwf-viewer", - "version": "0.6.4", + "version": "0.6.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dwf-viewer", - "version": "0.6.4", + "version": "0.6.5", "license": "AGPL-3.0-only", "devDependencies": { "typescript": "^4.9.5" diff --git a/package.json b/package.json index 736112b..8e677ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dwf-viewer", - "version": "0.6.4", + "version": "0.6.5", "private": false, "type": "module", "description": "World's first open-source pure frontend DWF/DWFx preview component with WebGL XPS/W2D 2D rendering, W3D/HSF 3D rendering, embedded fonts, and WASM fallback.", diff --git a/scripts/validate-production.mjs b/scripts/validate-production.mjs index c51cccf..b0213d4 100644 --- a/scripts/validate-production.mjs +++ b/scripts/validate-production.mjs @@ -5,7 +5,16 @@ const targets = [ { label: 'Autodesk Floor Plans DWFx A03', path: 'examples/autodesk-floor-plans.dwfx', pageIndex: 3, kind: 'xps-fixed-page', minPages: 18, maxNonInfoDiagnostics: 0 }, { label: 'Robot Arm 3D DWFx', path: 'examples/robot-arm.dwfx', kind: 'w3d-model', minMeshes: 30, minTriangles: 40000, minDistinctMeshColors: 24, maxNonInfoDiagnostics: 0, maxPageDiagnostics: 0 }, { label: '2D sample DWFx', path: 'examples/minimal-xps.dwfx', kind: 'xps-fixed-page' }, - { label: 'official binary W2D DWF', path: 'examples/blocks-and-tables.dwf', kind: 'w2d-text' } + { label: 'official binary W2D DWF', path: 'examples/blocks-and-tables.dwf', kind: 'w2d-text' }, + { + label: 'AutoCAD R14 legacy ASCII DWF V00.34', + path: 'examples/legacy-ascii-v0034.dwf', + kind: 'w2d-text', + expectedPrimitives: 8, + expectedPrimitiveTypes: { polyline: 3, polygon: 3, rect: 2 }, + expectedBackdrop: 'rgb(0, 0, 0)', + maxNonInfoDiagnostics: 0 + } ]; let failed = false; @@ -13,6 +22,8 @@ for (const t of targets) { const doc = await openDwfDocument(await readFile(t.path), { fileName: t.path }); const page = doc.pageData[t.pageIndex ?? 0]; const nonInfo = (page?.diagnostics ?? []).filter(d => d.level !== 'info'); + const primitiveTypes = page?.kind === 'w2d-text' ? countPrimitiveTypes(page.primitives) : undefined; + const backdrop = page?.kind === 'w2d-text' && page.primitives[0]?.type === 'rect' ? page.primitives[0].fill : undefined; const record = { label: t.label, documentKind: doc.kind, @@ -20,6 +31,9 @@ for (const t of targets) { pageIndex: t.pageIndex ?? 0, pageKind: page?.kind, pageName: page?.name, + primitives: page?.kind === 'w2d-text' ? page.primitives.length : undefined, + primitiveTypes, + backdrop, meshes: page?.kind === 'w3d-model' ? page.model.meshes.length : undefined, triangles: page?.kind === 'w3d-model' ? page.model.stats.triangleCount : undefined, distinctMeshColors: page?.kind === 'w3d-model' ? distinctMeshColors(page) : undefined, @@ -32,6 +46,18 @@ for (const t of targets) { if (typeof t.minMeshes === 'number' && (!(page?.kind === 'w3d-model') || page.model.meshes.length < t.minMeshes)) failed = true; if (typeof t.minTriangles === 'number' && (!(page?.kind === 'w3d-model') || page.model.stats.triangleCount < t.minTriangles)) failed = true; if (typeof t.minDistinctMeshColors === 'number' && (!(page?.kind === 'w3d-model') || distinctMeshColors(page) < t.minDistinctMeshColors)) failed = true; + if (typeof t.expectedPrimitives === 'number' && (!(page?.kind === 'w2d-text') || page.primitives.length !== t.expectedPrimitives)) failed = true; + if (t.expectedPrimitiveTypes) { + if (!(page?.kind === 'w2d-text')) { + failed = true; + } else { + const actual = countPrimitiveTypes(page.primitives); + for (const [type, count] of Object.entries(t.expectedPrimitiveTypes)) { + if ((actual[type] ?? 0) !== count) failed = true; + } + } + } + if (typeof t.expectedBackdrop === 'string' && backdrop !== t.expectedBackdrop) failed = true; if (typeof t.maxNonInfoDiagnostics === 'number' && nonInfo.length > t.maxNonInfoDiagnostics) failed = true; if (typeof t.maxPageDiagnostics === 'number' && (page?.diagnostics?.length ?? 0) > t.maxPageDiagnostics) failed = true; } @@ -88,3 +114,9 @@ if (failed) process.exit(1); function distinctMeshColors(page) { return new Set(page.model.meshes.map(mesh => (mesh.color ?? []).map(v => Number(v).toFixed(4)).join(','))).size; } + +function countPrimitiveTypes(primitives) { + const counts = {}; + for (const primitive of primitives) counts[primitive.type] = (counts[primitive.type] ?? 0) + 1; + return counts; +} diff --git a/src/format/legacyAsciiDwf.ts b/src/format/legacyAsciiDwf.ts new file mode 100644 index 0000000..5af3954 --- /dev/null +++ b/src/format/legacyAsciiDwf.ts @@ -0,0 +1,511 @@ +import { diag, type Diagnostic } from './types.js'; +import type { W2dPrimitive } from './document.js'; + +export interface W2dBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} + +export interface LegacyAsciiDwfParseResult { + primitives: W2dPrimitive[]; + diagnostics: Diagnostic[]; + bounds?: W2dBounds; + version?: string; + background?: string; +} + +const MAX_POINT_COUNT = 1_000_000; +const LEGACY_HEADER = /^\uFEFF?\s*\(DWF\s+V\d{2}\.\d{2}(?:\s|\))/i; + +/** Returns true for the pre-DWF-6 readable WHIP!/W2D stream produced by tools such as AutoCAD R14. */ +export function isLegacyAsciiDwf(text: string): boolean { + return LEGACY_HEADER.test(text); +} + +/** + * Parses the readable single-byte WHIP!/W2D opcode form used by early DWF files. + * + * The format is a stream rather than a line-oriented language: counted point sets can + * wrap across physical lines, and extended ASCII operands can contain nested parentheses. + * A cursor-based scanner is therefore required for correct parsing and bounded O(n) work. + */ +export function parseLegacyAsciiDwf(text: string, sourcePath: string): LegacyAsciiDwfParseResult { + const scanner = new LegacyAsciiScanner(text); + const diagnostics: Diagnostic[] = []; + const primitives: W2dPrimitive[] = []; + const colors: string[] = []; + const operationCounts = new Map(); + const unsupportedOpcodes = new Set(); + + let version: string | undefined; + let background: string | undefined; + let pendingBackgroundIndex: number | undefined; + let currentColorIndex: number | undefined = 0; + let currentColor = '#000000'; + let fill = false; + let visible = true; + let lineWidth = 1; + let markerSize = 1; + let hiddenGeometry = 0; + let ended = false; + let fatal = false; + + const countOperation = (name: string): void => { + operationCounts.set(name, (operationCounts.get(name) ?? 0) + 1); + }; + + const resolveIndexedColor = (index: number): string | undefined => { + if (!Number.isInteger(index) || index < 0) return undefined; + return colors[index]; + }; + + const readCountedPoints = (opcode: string): number[] | undefined => { + const count = scanner.readInteger(); + if (count === undefined || count < 1 || count > MAX_POINT_COUNT) { + diagnostics.push(diag('error', 'LEGACY_ASCII_DWF_INVALID_POINT_COUNT', `Opcode ${opcode} has an invalid point count at byte ${scanner.position}.`, sourcePath)); + fatal = true; + return undefined; + } + const points = new Array(count * 2); + for (let i = 0; i < count; i++) { + const point = scanner.readPoint(); + if (!point) { + diagnostics.push(diag('error', 'LEGACY_ASCII_DWF_TRUNCATED_POINT_SET', `Opcode ${opcode} ended before point ${i + 1} of ${count} at byte ${scanner.position}.`, sourcePath)); + fatal = true; + return undefined; + } + points[i * 2] = point[0]; + points[i * 2 + 1] = point[1]; + } + return points; + }; + + while (!scanner.eof && !ended && !fatal) { + scanner.skipWhitespace(); + if (scanner.eof) break; + const start = scanner.position; + const token = scanner.peek(); + + if (token === '(') { + const body = scanner.readExtendedAscii(); + if (body === undefined) { + diagnostics.push(diag('error', 'LEGACY_ASCII_DWF_UNTERMINATED_EXTENDED_OPCODE', `Unterminated extended ASCII opcode at byte ${start}.`, sourcePath)); + break; + } + const extended = splitExtendedOpcode(body); + if (!extended) continue; + const name = extended.name.toLowerCase(); + countOperation(`(${extended.name})`); + + if (name === 'dwf') { + version = extended.operands.match(/\bV\d{2}\.\d{2}\b/i)?.[0]?.toUpperCase(); + } else if (name === 'colormap') { + const values = parseNumbers(extended.operands); + const declaredCount = values[0]; + if (declaredCount === undefined || !Number.isInteger(declaredCount) || declaredCount < 1) { + diagnostics.push(diag('warning', 'LEGACY_ASCII_DWF_INVALID_COLORMAP', 'The legacy DWF ColorMap does not declare a valid color count.', sourcePath)); + } else { + const availableCount = Math.floor((values.length - 1) / 4); + const count = Math.min(declaredCount, availableCount); + colors.length = 0; + for (let i = 0; i < count; i++) { + const offset = 1 + i * 4; + colors.push(toCssColor(values[offset]!, values[offset + 1]!, values[offset + 2]!, values[offset + 3]!)); + } + if (availableCount < declaredCount) { + diagnostics.push(diag('warning', 'LEGACY_ASCII_DWF_TRUNCATED_COLORMAP', `ColorMap declares ${declaredCount} colors but contains ${availableCount}.`, sourcePath)); + } + if (currentColorIndex !== undefined) currentColor = resolveIndexedColor(currentColorIndex) ?? currentColor; + if (pendingBackgroundIndex !== undefined) background = resolveIndexedColor(pendingBackgroundIndex) ?? background; + } + } else if (name === 'background') { + const values = parseNumbers(extended.operands); + if (values.length === 1 && Number.isInteger(values[0])) { + pendingBackgroundIndex = values[0]; + background = resolveIndexedColor(values[0]!) ?? background; + } else if (values.length >= 3) { + background = toCssColor(values[0]!, values[1]!, values[2]!, values[3] ?? 255); + pendingBackgroundIndex = undefined; + } + } else if (name === 'color') { + const values = parseNumbers(extended.operands); + if (values.length === 1 && Number.isInteger(values[0])) { + currentColorIndex = values[0]; + currentColor = resolveIndexedColor(values[0]!) ?? currentColor; + } else if (values.length >= 3) { + currentColor = toCssColor(values[0]!, values[1]!, values[2]!, values[3] ?? 255); + currentColorIndex = undefined; + } + } else if (name === 'lineweight') { + const value = parseNumbers(extended.operands)[0]; + if (value !== undefined && Number.isFinite(value)) lineWidth = Math.max(0.2, Math.abs(value)); + } else if (name === 'markersize') { + const value = parseNumbers(extended.operands)[0]; + if (value !== undefined && Number.isFinite(value)) markerSize = Math.max(1, Math.abs(value)); + } else if (name === 'visibility') { + const value = extended.operands.trim().toLowerCase(); + const number = parseNumbers(value)[0]; + visible = number !== undefined ? number !== 0 : !/^(?:off|false|hidden)\b/.test(value); + } else if (name === 'endofdwf') { + ended = true; + } + continue; + } + + if (!token || !isAsciiLetter(token)) { + scanner.skipLine(); + continue; + } + + scanner.advance(); + countOperation(token); + switch (token) { + case 'C': { + const index = scanner.readInteger(); + if (index === undefined) { + diagnostics.push(diag('error', 'LEGACY_ASCII_DWF_INVALID_COLOR_INDEX', `Color opcode C is missing its palette index at byte ${scanner.position}.`, sourcePath)); + fatal = true; + break; + } + currentColorIndex = index; + const resolved = resolveIndexedColor(index); + if (resolved) currentColor = resolved; + break; + } + case 'F': + fill = true; + break; + case 'f': + fill = false; + break; + case 'V': + visible = true; + break; + case 'v': + visible = false; + break; + case 'L': { + const first = scanner.readPoint(); + const second = scanner.readPoint(); + if (!first || !second) { + diagnostics.push(diag('error', 'LEGACY_ASCII_DWF_TRUNCATED_LINE', `Line opcode L is missing coordinates at byte ${scanner.position}.`, sourcePath)); + fatal = true; + break; + } + if (visible) { + primitives.push({ type: 'polyline', points: [first[0], first[1], second[0], second[1]], stroke: currentColor, lineWidth }); + } else { + hiddenGeometry++; + } + break; + } + case 'P': { + const points = readCountedPoints(token); + if (!points) break; + if (!visible) { + hiddenGeometry++; + } else if (fill && points.length >= 6) { + primitives.push({ type: 'polygon', points, fill: currentColor }); + } else { + primitives.push({ type: 'polyline', points, stroke: currentColor, lineWidth }); + } + break; + } + case 'T': { + const points = readCountedPoints(token); + if (!points) break; + if (!visible) { + hiddenGeometry++; + break; + } + appendTriangleStrip(primitives, points, currentColor); + break; + } + case 'M': { + const points = readCountedPoints(token); + if (!points) break; + if (!visible) { + hiddenGeometry++; + break; + } + const size = Math.max(1, markerSize, lineWidth); + for (let i = 0; i + 1 < points.length; i += 2) { + primitives.push({ + type: 'rect', + x: points[i]! - size / 2, + y: points[i + 1]! - size / 2, + width: size, + height: size, + fill: currentColor + }); + } + break; + } + default: + unsupportedOpcodes.add(printableOpcode(token)); + // Readable WHIP! writers put one opcode on a physical line. Skipping an + // unsupported operand prevents its numeric payload from being mistaken for opcodes. + scanner.skipLine(); + break; + } + + if (scanner.position <= start) scanner.advance(); + } + + const bounds = computeBounds(primitives); + const geometryPrimitiveCount = primitives.length; + const versionLabel = version ?? 'legacy ASCII DWF'; + if (geometryPrimitiveCount === 0) { + diagnostics.push(diag('warning', 'LEGACY_ASCII_DWF_NO_GEOMETRY', `${versionLabel} contained no supported visible geometry.`, sourcePath)); + } else { + const counts = ['L', 'P', 'T', 'M'] + .map(opcode => `${opcode}=${operationCounts.get(opcode) ?? 0}`) + .join(', '); + const hiddenText = hiddenGeometry > 0 ? `; skipped ${hiddenGeometry} hidden geometry operation(s)` : ''; + diagnostics.push(diag('info', 'LEGACY_ASCII_DWF_PARSED', `Parsed ${versionLabel} readable WHIP!/W2D stream (${counts}) into ${geometryPrimitiveCount} geometry primitive(s)${hiddenText}.`, sourcePath)); + } + if (unsupportedOpcodes.size > 0) { + diagnostics.push(diag('warning', 'LEGACY_ASCII_DWF_UNSUPPORTED_OPCODES', `Ignored unsupported readable WHIP!/W2D opcode(s): ${Array.from(unsupportedOpcodes).sort().join(', ')}.`, sourcePath)); + } + if (!ended) { + diagnostics.push(diag('warning', 'LEGACY_ASCII_DWF_MISSING_END', 'The readable WHIP!/W2D stream did not contain a complete (EndOfDWF) marker.', sourcePath)); + } + + // The existing W2D render contract does not carry a page-background field. Emit the + // declared DWF backdrop as the first primitive so Canvas, WASM, and WebGL backends all + // preserve the source appearance without backend-specific special cases. + if (background && bounds) { + primitives.unshift({ + type: 'rect', + x: bounds.minX, + y: bounds.minY, + width: Math.max(1, bounds.maxX - bounds.minX), + height: Math.max(1, bounds.maxY - bounds.minY), + fill: background + }); + } + + return { primitives, diagnostics, bounds, version, background }; +} + +function appendTriangleStrip(primitives: W2dPrimitive[], points: number[], color: string): void { + const pointCount = Math.floor(points.length / 2); + for (let i = 2; i < pointCount; i++) { + const firstIndex = i % 2 === 0 ? i - 2 : i - 1; + const secondIndex = i % 2 === 0 ? i - 1 : i - 2; + const ax = points[firstIndex * 2]!; + const ay = points[firstIndex * 2 + 1]!; + const bx = points[secondIndex * 2]!; + const by = points[secondIndex * 2 + 1]!; + const cx = points[i * 2]!; + const cy = points[i * 2 + 1]!; + // Degenerate vertices are legal separators in triangle strips and must not emit geometry. + if ((bx - ax) * (cy - ay) - (by - ay) * (cx - ax) === 0) continue; + primitives.push({ type: 'polygon', points: [ax, ay, bx, by, cx, cy], fill: color }); + } +} + +function computeBounds(primitives: W2dPrimitive[]): W2dBounds | undefined { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + const add = (x: number, y: number): void => { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + }; + for (const primitive of primitives) { + if ('points' in primitive) { + for (let i = 0; i + 1 < primitive.points.length; i += 2) add(primitive.points[i]!, primitive.points[i + 1]!); + } else if (primitive.type === 'rect') { + add(primitive.x, primitive.y); + add(primitive.x + primitive.width, primitive.y + primitive.height); + } else if (primitive.type === 'text') { + const size = primitive.size ?? 12; + add(primitive.x, primitive.y); + add(primitive.x + primitive.text.length * size * 0.6, primitive.y + size); + } else if (primitive.type === 'path') { + for (const command of primitive.commands) { + if ('x' in command && 'y' in command && typeof command.x === 'number' && typeof command.y === 'number') add(command.x, command.y); + if ('x1' in command && 'y1' in command && typeof command.x1 === 'number' && typeof command.y1 === 'number') add(command.x1, command.y1); + if ('x2' in command && 'y2' in command && typeof command.x2 === 'number' && typeof command.y2 === 'number') add(command.x2, command.y2); + } + } + } + return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : undefined; +} + +function splitExtendedOpcode(body: string): { name: string; operands: string } | undefined { + const trimmed = body.trimStart(); + const match = trimmed.match(/^([^\s()]+)/); + if (!match?.[1]) return undefined; + return { name: match[1], operands: trimmed.slice(match[1].length) }; +} + +function parseNumbers(value: string): number[] { + return Array.from(value.matchAll(/[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g), match => Number(match[0])); +} + +function toCssColor(red: number, green: number, blue: number, alpha = 255): string { + const r = clampByte(red); + const g = clampByte(green); + const b = clampByte(blue); + const a = clampByte(alpha); + if (a === 255) return `rgb(${r}, ${g}, ${b})`; + const normalized = Number((a / 255).toFixed(4)); + return `rgba(${r}, ${g}, ${b}, ${normalized})`; +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, Math.round(value))); +} + +function isAsciiLetter(value: string): boolean { + const code = value.charCodeAt(0); + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); +} + +function printableOpcode(value: string): string { + const code = value.charCodeAt(0); + return code >= 0x20 && code <= 0x7e ? value : `0x${code.toString(16).padStart(2, '0')}`; +} + +class LegacyAsciiScanner { + position = 0; + + constructor(private readonly source: string) {} + + get eof(): boolean { + return this.position >= this.source.length; + } + + peek(): string | undefined { + return this.source[this.position]; + } + + advance(): void { + if (!this.eof) this.position++; + } + + skipWhitespace(): void { + while (!this.eof) { + const code = this.source.charCodeAt(this.position); + if (code !== 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x0c) break; + this.position++; + } + } + + skipLine(): void { + while (!this.eof) { + const code = this.source.charCodeAt(this.position++); + if (code === 0x0a) break; + } + } + + readInteger(): number | undefined { + const value = this.readNumber(); + return value !== undefined && Number.isInteger(value) ? value : undefined; + } + + readPoint(): [number, number] | undefined { + const start = this.position; + const x = this.readNumber(); + if (x === undefined) { + this.position = start; + return undefined; + } + this.skipWhitespace(); + if (this.source[this.position] !== ',') { + this.position = start; + return undefined; + } + this.position++; + const y = this.readNumber(); + if (y === undefined) { + this.position = start; + return undefined; + } + return [x, y]; + } + + readNumber(): number | undefined { + this.skipWhitespace(); + const start = this.position; + if (this.source[this.position] === '+' || this.source[this.position] === '-') this.position++; + + let digits = 0; + while (!this.eof && isDigitCode(this.source.charCodeAt(this.position))) { + this.position++; + digits++; + } + if (this.source[this.position] === '.') { + this.position++; + while (!this.eof && isDigitCode(this.source.charCodeAt(this.position))) { + this.position++; + digits++; + } + } + if (digits === 0) { + this.position = start; + return undefined; + } + + if (this.source[this.position] === 'e' || this.source[this.position] === 'E') { + const exponentStart = this.position; + this.position++; + if (this.source[this.position] === '+' || this.source[this.position] === '-') this.position++; + let exponentDigits = 0; + while (!this.eof && isDigitCode(this.source.charCodeAt(this.position))) { + this.position++; + exponentDigits++; + } + if (exponentDigits === 0) this.position = exponentStart; + } + + const value = Number(this.source.slice(start, this.position)); + if (!Number.isFinite(value)) { + this.position = start; + return undefined; + } + return value; + } + + readExtendedAscii(): string | undefined { + if (this.source[this.position] !== '(') return undefined; + const start = this.position++; + let depth = 1; + let quote: string | undefined; + let escaped = false; + + while (!this.eof) { + const value = this.source[this.position++]!; + if (quote) { + if (escaped) { + escaped = false; + } else if (value === '\\') { + escaped = true; + } else if (value === quote) { + quote = undefined; + } + continue; + } + if (value === "'" || value === '"') { + quote = value; + } else if (value === '(') { + depth++; + } else if (value === ')') { + depth--; + if (depth === 0) return this.source.slice(start + 1, this.position - 1); + } + } + return undefined; + } +} + +function isDigitCode(code: number): boolean { + return code >= 48 && code <= 57; +} diff --git a/src/format/open.ts b/src/format/open.ts index c09eb9d..87628bd 100644 --- a/src/format/open.ts +++ b/src/format/open.ts @@ -20,6 +20,7 @@ export async function openDwfDocument(input: ArrayBuffer | Uint8Array | Blob | F if (bytesLookTextual(bytes)) { const parsed = parseW2dText(bytes, fileName); + const isLegacyAsciiDwf = parsed.format === 'legacy-ascii-dwf'; const pageData: PageData[] = [{ id: 'page-1', name: fileName, @@ -32,10 +33,12 @@ export async function openDwfDocument(input: ArrayBuffer | Uint8Array | Blob | F diagnostics: parsed.diagnostics } as W2dTextPageData]; const base: DwfDocument = { - kind: 'unknown', + kind: isLegacyAsciiDwf ? 'dwf' : 'unknown', pages: [], - resources: [{ path: fileName, mediaType: 'text/plain', size: bytes.byteLength }], - diagnostics: [diag('warning', 'RAW_TEXT_W2D_MODE', 'Input is not a DWF ZIP package; opened it as a textual W2D-like vector stream.', fileName)], + resources: [{ path: fileName, mediaType: isLegacyAsciiDwf ? 'model/vnd.dwf' : 'text/plain', size: bytes.byteLength }], + diagnostics: isLegacyAsciiDwf + ? [diag('info', 'LEGACY_ASCII_DWF_MODE', `Opened ${parsed.version ?? 'legacy'} readable WHIP!/W2D stream as a classic DWF document.`, fileName)] + : [diag('warning', 'RAW_TEXT_W2D_MODE', 'Input is not a DWF ZIP package; opened it as a textual W2D-like vector stream.', fileName)], packageEntries: [fileName] }; return makeLoadedDocument(base, pageData); @@ -49,7 +52,7 @@ export async function openDwfDocument(input: ArrayBuffer | Uint8Array | Blob | F height: 1000, sourcePath: fileName, reason: 'Input is neither a ZIP-based DWF/DWFx package nor a supported textual W2D stream.', - diagnostics: [diag('error', 'UNSUPPORTED_RAW_FORMAT', 'Unsupported raw DWF input. DWF 6+/DWFx ZIP packages are required for this build.', fileName)] + diagnostics: [diag('error', 'UNSUPPORTED_RAW_FORMAT', 'Unsupported raw DWF input. DWF 6+/DWFx ZIP packages or readable legacy WHIP!/W2D streams are required for this build.', fileName)] }; const base: DwfDocument = { kind: 'unknown', diff --git a/src/format/w2dText.ts b/src/format/w2dText.ts index fe8f45d..74372fe 100644 --- a/src/format/w2dText.ts +++ b/src/format/w2dText.ts @@ -2,15 +2,23 @@ import { diag, type Diagnostic } from './types.js'; import { decodeUtf8, parseNumberList } from './util.js'; import { parsePathData } from '../render/xpsPath.js'; import type { W2dPrimitive } from './document.js'; +import { isLegacyAsciiDwf, parseLegacyAsciiDwf } from './legacyAsciiDwf.js'; export interface W2dTextParseResult { + format: 'legacy-ascii-dwf' | 'generic-text'; primitives: W2dPrimitive[]; diagnostics: Diagnostic[]; bounds?: { minX: number; minY: number; maxX: number; maxY: number }; + version?: string; + background?: string; } export function parseW2dText(bytes: Uint8Array, sourcePath: string): W2dTextParseResult { const text = decodeUtf8(bytes); + if (isLegacyAsciiDwf(text)) { + return { format: 'legacy-ascii-dwf', ...parseLegacyAsciiDwf(text, sourcePath) }; + } + const diagnostics: Diagnostic[] = []; const primitives: W2dPrimitive[] = []; let stroke = '#000000'; @@ -122,7 +130,7 @@ export function parseW2dText(bytes: Uint8Array, sourcePath: string): W2dTextPars if (parsedLines === 0) { diagnostics.push(diag('warning', 'W2D_TEXT_UNKNOWN_DIALECT', 'The file is textual, but does not look like the supported WHIP/W2D textual subset.', sourcePath)); } - return { primitives, diagnostics, bounds }; + return { format: 'generic-text', primitives, diagnostics, bounds }; } function normalizePointList(nums: number[]): number[] { @@ -156,9 +164,9 @@ function computeBounds(primitives: W2dPrimitive[]): { minX: number; minY: number add(p.x, p.y); add(p.x + p.text.length * (p.size ?? 12) * 0.6, p.y + (p.size ?? 12)); } else if (p.type === 'path') { for (const c of p.commands) { - if ('x' in c && 'y' in c) add(c.x, c.y); - if ('x1' in c && 'y1' in c) add(c.x1, c.y1); - if ('x2' in c && 'y2' in c) add(c.x2, c.y2); + if ('x' in c && 'y' in c && typeof c.x === 'number' && typeof c.y === 'number') add(c.x, c.y); + if ('x1' in c && 'y1' in c && typeof c.x1 === 'number' && typeof c.y1 === 'number') add(c.x1, c.y1); + if ('x2' in c && 'y2' in c && typeof c.x2 === 'number' && typeof c.y2 === 'number') add(c.x2, c.y2); } } }