From 1b87b976787b6db9a58d5d1359f1dbe3c08861d2 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:27:30 +0800 Subject: [PATCH 01/13] Add legacy ASCII DWF regression fixture --- examples/legacy-ascii-v0034.dwf | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 examples/legacy-ascii-v0034.dwf 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) From 51169d90e4969c1b3d4954176adda824f30d9cde Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:27:53 +0800 Subject: [PATCH 02/13] Register legacy ASCII DWF example --- examples/manifest.json | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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", From 6a6c484e9534a59d62c3507a81970b4d04eafb10 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:28:13 +0800 Subject: [PATCH 03/13] Document legacy ASCII DWF support --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4df00c6..367643d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +- 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. + ## 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. From 868b9dc3d0f9e6ea9a8e0b4fc2c7bb41951f4108 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:30:47 +0800 Subject: [PATCH 04/13] Classify readable legacy streams as DWF documents --- src/format/open.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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', From 8ef5f513bc639ac8ca85867b1d953c7c78935620 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:33:07 +0800 Subject: [PATCH 05/13] Parse legacy readable WHIP DWF streams --- src/format/legacyAsciiDwf.ts | 511 +++++++++++++++++++++++++++++++++++ 1 file changed, 511 insertions(+) create mode 100644 src/format/legacyAsciiDwf.ts diff --git a/src/format/legacyAsciiDwf.ts b/src/format/legacyAsciiDwf.ts new file mode 100644 index 0000000..237aa40 --- /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 (typeof command.x === 'number' && typeof command.y === 'number') add(command.x, command.y); + if (typeof command.x1 === 'number' && typeof command.y1 === 'number') add(command.x1, command.y1); + if (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; +} From 131e44ad580c2c62e9f97a476c2ef41f43327746 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:33:55 +0800 Subject: [PATCH 06/13] Route legacy DWF text through the stream parser --- src/format/w2dText.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/format/w2dText.ts b/src/format/w2dText.ts index fe8f45d..725c9f0 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 (typeof c.x === 'number' && typeof c.y === 'number') add(c.x, c.y); + if (typeof c.x1 === 'number' && typeof c.y1 === 'number') add(c.x1, c.y1); + if (typeof c.x2 === 'number' && typeof c.y2 === 'number') add(c.x2, c.y2); } } } From 5ff197faeeb007b4d52ab71208584acd0e5d5c93 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:34:38 +0800 Subject: [PATCH 07/13] Validate legacy ASCII DWF geometry and backdrop --- scripts/validate-production.mjs | 34 ++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) 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; +} From a11d24697bdf89b0a03dfbcb977d2d7ce2642c16 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:40:48 +0800 Subject: [PATCH 08/13] Narrow path commands before reading bounds --- src/format/w2dText.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/format/w2dText.ts b/src/format/w2dText.ts index 725c9f0..74372fe 100644 --- a/src/format/w2dText.ts +++ b/src/format/w2dText.ts @@ -164,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 (typeof c.x === 'number' && typeof c.y === 'number') add(c.x, c.y); - if (typeof c.x1 === 'number' && typeof c.y1 === 'number') add(c.x1, c.y1); - if (typeof c.x2 === 'number' && typeof c.y2 === 'number') 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); } } } From 83afbc2c7e8753685980217c8a06b387b4fa17af Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:42:12 +0800 Subject: [PATCH 09/13] Narrow legacy path commands before reading bounds --- src/format/legacyAsciiDwf.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/format/legacyAsciiDwf.ts b/src/format/legacyAsciiDwf.ts index 237aa40..5af3954 100644 --- a/src/format/legacyAsciiDwf.ts +++ b/src/format/legacyAsciiDwf.ts @@ -330,9 +330,9 @@ function computeBounds(primitives: W2dPrimitive[]): W2dBounds | undefined { add(primitive.x + primitive.text.length * size * 0.6, primitive.y + size); } else if (primitive.type === 'path') { for (const command of primitive.commands) { - if (typeof command.x === 'number' && typeof command.y === 'number') add(command.x, command.y); - if (typeof command.x1 === 'number' && typeof command.y1 === 'number') add(command.x1, command.y1); - if (typeof command.x2 === 'number' && typeof command.y2 === 'number') add(command.x2, command.y2); + 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); } } } From f09aa66709e28f680f8a0dcace40157dd45641d3 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:24:15 +0800 Subject: [PATCH 10/13] Prepare 0.6.5 legacy DWF release --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.", From fe2088ff57e8f934e4c857e73c3f81e17463eb67 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:24:16 +0800 Subject: [PATCH 11/13] Prepare 0.6.5 legacy DWF release --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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" From 54fd8dfd1aeccf8f3c3f42b40ad0660778b1f1c8 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:24:18 +0800 Subject: [PATCH 12/13] Prepare 0.6.5 legacy DWF release --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 367643d..d6a676e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,11 @@ # Changelog -## Unreleased +## 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 From ac5a679ae0520bdd421a4c80547f48b3ec391d23 Mon Sep 17 00:00:00 2001 From: Brownie Woom <161313394+brownie-cake@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:24:19 +0800 Subject: [PATCH 13/13] Prepare 0.6.5 legacy DWF release --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) 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 |