feat: implement OLE2Frame embedded image parsing and exact coordinate rendering

This commit is contained in:
minsung
2026-07-30 14:17:56 +09:00
parent 95fa6a452b
commit be8d52fcd5
13 changed files with 1919 additions and 45 deletions
+12 -1
View File
@@ -1,9 +1,11 @@
// Types for the ported JS 2D engine (Viewer2D.js). Colocated so tsc resolves
// `./Viewer2D.js` imports without allowJs. Only the methods the app uses are typed.
import type { CadSpace } from './cadSpaces';
export class Viewer2D {
constructor(container: HTMLElement);
/** Render a DWG/DXF parseResult. `keepView` preserves pan/zoom on re-render. */
load(result: unknown, opts?: { keepView?: boolean }): void;
load(result: unknown, opts?: { keepView?: boolean; spaceHandle?: string | number | null }): void;
/** Zoom-extents to the loaded drawing. */
fit(): void;
/** Resync renderer/camera to the container size (call after un-hiding). */
@@ -13,8 +15,17 @@ export class Viewer2D {
setGrid(visible: boolean): void;
getLayerInfo(): { name: string; colorHex: string; count: number; visible: boolean }[];
setHiddenLayers(nameSet: Set<string>): void;
/** Model / Paper (Layout) spaces for the loaded drawing. */
getSpaces(): CadSpace[];
getActiveSpace(): string | null;
/** Switch Model ↔ Layout; re-renders (fit unless keepView). */
setSpace(spaceHandle: string | number | null, opts?: { keepView?: boolean }): void;
getZoomPercent(): number;
onViewChange(cb: () => void): void;
/** Screen client coords → drawing world (Z=0 plane). */
screenToWorld(clientX: number, clientY: number): { x: number; y: number; z: number } | null;
/** Live CAD coords under the cursor; null when pointer leaves the canvas. */
onPointerWorld(cb: ((p: { x: number; y: number; z: number } | null) => void) | null): void;
startMeasure(cb: (r: { phase: string; ax: number; ay: number; az: number; bx: number; by: number; bz: number; d: number }) => void): void;
stopMeasure(): void;
}
+847 -41
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+166
View File
@@ -0,0 +1,166 @@
/**
* Model Space / Paper Space (Layout) discovery for CadParseResult.
*
* DWG entities are owned by a block header handle. System blocks
* *Model_Space / *Paper_Space (and *Paper_Space0…) are the two spaces
* AutoCAD switches between. Viewer2D must render only one at a time —
* drawing both stacks survey-model geometry on top of layout sheet
* geometry and produces the "all text piled in the center" glitch.
*/
export type CadSpaceKind = 'model' | 'paper' | 'other';
export type CadSpace = {
/** Stable id for UI (hex handle string, same as ownerHandle.value.toString(16)). */
id: string;
/** Display label, e.g. "Model" / "Layout" / raw block name. */
label: string;
/** Raw block name from tables.blocks. */
name: string;
/** Block handle as number when available. */
handle: number | null;
/** Hex string matching entity ownerHandle.value.toString(16). */
handleHex: string;
kind: CadSpaceKind;
entityCount: number;
};
function handleToHex(handle: unknown): string | null {
if (handle == null) return null;
if (typeof handle === 'object' && handle !== null && 'value' in handle) {
const v = (handle as { value?: unknown }).value;
if (typeof v === 'number' && Number.isFinite(v)) return v.toString(16);
if (typeof v === 'string' && v.length) {
const n = Number(v);
return Number.isFinite(n) ? n.toString(16) : v.toLowerCase();
}
return null;
}
if (typeof handle === 'number' && Number.isFinite(handle)) return handle.toString(16);
if (typeof handle === 'string' && handle.length) {
const n = Number(handle);
return Number.isFinite(n) ? n.toString(16) : handle.toLowerCase();
}
return null;
}
function ownerHex(entity: { ownerHandle?: unknown }): string | null {
return handleToHex(entity.ownerHandle);
}
function classifySpaceName(raw: string): CadSpaceKind | null {
const name = raw.toLowerCase().replace(/\*/g, '').trim();
if (!name) return null;
if (name === 'model_space' || name === 'ms' || name.startsWith('model')) return 'model';
if (name === 'paper_space' || name === 'ps' || name.startsWith('paper')) return 'paper';
return null;
}
function spaceLabel(kind: CadSpaceKind, name: string, paperIndex: number): string {
if (kind === 'model') return 'Model';
if (kind === 'paper') {
// First paper space → "Layout"; further ones keep a disambiguator.
if (paperIndex <= 1) return 'Layout';
return `Layout (${name.replace(/^\*/, '')})`;
}
return name;
}
/**
* List model/paper space blocks that own at least zero entities.
* Always includes Model/Paper headers found in tables.blocks.
*/
export function listCadSpaces(result: {
entities?: unknown[];
tables?: { blocks?: Array<{ name?: string; handle?: unknown }> };
} | null | undefined): CadSpace[] {
const blocks = result?.tables?.blocks ?? [];
const entities = (result?.entities ?? []) as Array<{ ownerHandle?: unknown }>;
const counts = new Map<string, number>();
for (const e of entities) {
const hex = ownerHex(e);
if (!hex) continue;
counts.set(hex, (counts.get(hex) || 0) + 1);
}
const spaces: CadSpace[] = [];
let paperIndex = 0;
for (const b of blocks) {
const kind = classifySpaceName(b.name ?? '');
if (!kind) continue;
const hex = handleToHex(b.handle);
if (!hex) continue;
if (kind === 'paper') paperIndex += 1;
const handleNum =
typeof b.handle === 'number'
? b.handle
: typeof b.handle === 'object' && b.handle && 'value' in b.handle
? Number((b.handle as { value: unknown }).value)
: null;
spaces.push({
id: hex,
label: spaceLabel(kind, b.name ?? '', paperIndex),
name: b.name ?? '',
handle: Number.isFinite(handleNum as number) ? (handleNum as number) : null,
handleHex: hex,
kind,
entityCount: counts.get(hex) || 0,
});
}
// Stable order: Model first, then papers in table order.
spaces.sort((a, b) => {
if (a.kind !== b.kind) {
if (a.kind === 'model') return -1;
if (b.kind === 'model') return 1;
}
return 0;
});
return spaces;
}
/**
* Default tab when a file is opened:
* - Paper/Layout with entities if any (civil sheet drawings often leave Layout active)
* - else Model
* - else first listed space
*/
export function pickDefaultSpace(spaces: CadSpace[]): CadSpace | null {
if (!spaces.length) return null;
const paperWithContent = spaces.find((s) => s.kind === 'paper' && s.entityCount > 0);
if (paperWithContent) return paperWithContent;
const model = spaces.find((s) => s.kind === 'model');
if (model) return model;
return spaces[0] ?? null;
}
/**
* Owners allowed when filtering to one space: the space handle itself plus
* handles of entities that live under it (e.g. INSERT → ATTRIB children).
*/
export function buildAllowedOwnerHexes(
entities: Array<{ handle?: unknown; ownerHandle?: unknown }>,
activeSpaceHex: string,
rounds = 3,
): Set<string> {
const allowed = new Set<string>([activeSpaceHex]);
for (let pass = 0; pass < rounds; pass++) {
let grew = false;
for (const ent of entities) {
const oh = ownerHex(ent);
if (!oh || !allowed.has(oh)) continue;
const h = handleToHex(ent.handle);
if (h && !allowed.has(h)) {
allowed.add(h);
grew = true;
}
}
if (!grew) break;
}
return allowed;
}
export function isSystemSpaceBlockName(name: string): boolean {
return classifySpaceName(name) != null;
}
+7 -1
View File
@@ -7,8 +7,14 @@
* History: until 2026-07-10 this module selected between acadrust and
* @horu2day/pure-cad-parser (?dwgparser= toggle). The old parser was removed
* after side-by-side validation (PROGRESS.md Phase 15).
*
* Post-process: fixDwgKoreanText repairs CP949→Latin-1 mojibake common in
* older Korean DWGs (see fixDwgKoreanText.ts). Unicode-correct drawings
* (e.g. BasicSample) are left unchanged.
*/
import { fixDwgKoreanText } from './fixDwgKoreanText';
export type CadParseResult = {
version?: string;
vars?: Record<string, unknown>;
@@ -21,5 +27,5 @@ export type CadParseResult = {
export async function parseDwgBuffer(bytes: Uint8Array): Promise<CadParseResult> {
const { initAcadrustParser, parseDwgAcadrust } = await import('./acadrustParser');
await initAcadrustParser();
return parseDwgAcadrust(bytes);
return fixDwgKoreanText(parseDwgAcadrust(bytes));
}
+106
View File
@@ -0,0 +1,106 @@
/**
* Repair Korean text that acadrust emits as Latin-1 mojibake of CP949/EUC-KR.
*
* Older Korean DWGs store strings in code page 949. When those bytes are
* decoded as ISO-8859-1/Windows-1252, Hangul becomes garbled Latin
* (e.g. "½Ã°ø½Ã" instead of "시공시"). Unicode-correct drawings
* (already containing Hangul syllables) are left unchanged.
*
* Apply after parse_dwg, before Viewer2D.load().
*/
const HANGUL_SYLLABLE = /[\uAC00-\uD7A3]/;
const HANGUL_JAMO = /[\u1100-\u11FF\u3130-\u318F]/;
/** Labels tried in order; first supported decoder wins. */
const KOREAN_LABELS = ['cp949', 'windows-949', 'euc-kr'] as const;
let koreanDecoder: TextDecoder | null | undefined;
function getKoreanDecoder(): TextDecoder | null {
if (koreanDecoder !== undefined) return koreanDecoder;
for (const label of KOREAN_LABELS) {
try {
koreanDecoder = new TextDecoder(label);
return koreanDecoder;
} catch {
/* label not available in this runtime */
}
}
koreanDecoder = null;
return null;
}
/**
* If `s` looks like CP949 bytes misread as Latin-1, return the Hangul
* form; otherwise return `s` unchanged.
*/
export function fixCp949Mojibake(s: string): string {
if (!s) return s;
// Already real Hangul → leave alone (e.g. BasicSample.dwg).
if (HANGUL_SYLLABLE.test(s) || HANGUL_JAMO.test(s)) return s;
let hasHigh = false;
const bytes = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) {
const code = s.charCodeAt(i);
if (code > 255) return s; // true Unicode beyond Latin-1
if (code > 127) hasHigh = true;
bytes[i] = code;
}
if (!hasHigh) return s; // pure ASCII
const decoder = getKoreanDecoder();
if (!decoder) return s;
let fixed: string;
try {
fixed = decoder.decode(bytes);
} catch {
return s;
}
// Accept only when we gain Hangul and the decode is clean.
if (
(HANGUL_SYLLABLE.test(fixed) || HANGUL_JAMO.test(fixed)) &&
!fixed.includes('\uFFFD')
) {
return fixed;
}
return s;
}
/**
* Deep-walk a CadParseResult (or any JSON-like tree) and fix string fields
* in place. Returns the same object for chaining.
*/
export function fixDwgKoreanText<T>(root: T): T {
if (root == null || typeof root !== 'object') return root;
const seen = new WeakSet<object>();
const walk = (value: unknown): void => {
if (value == null || typeof value !== 'object') return;
if (seen.has(value as object)) return;
seen.add(value as object);
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const item = value[i];
if (typeof item === 'string') value[i] = fixCp949Mojibake(item);
else walk(item);
}
return;
}
const obj = value as Record<string, unknown>;
for (const key of Object.keys(obj)) {
if (key === 'imageDataUrl') continue;
const item = obj[key];
if (typeof item === 'string') obj[key] = fixCp949Mojibake(item);
else walk(item);
}
};
walk(root);
return root;
}