@flyfish-dev/dwf-viewer 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,20 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1
4
+
5
+ - Changed the public demo default sample to `Blocks and Tables · Binary W2D DWF` for a faster and clearer first load.
6
+ - Kept Autodesk Floor Plans as an optional XPS WebGL validation sample instead of loading the large DWFx file by default.
7
+ - Updated demo asset versioning to `dist-v0.6.1`.
8
+
9
+ ## 0.6.0
10
+
11
+ - Added WebGL-accelerated XPS/DWFx 2D vector rendering through `WebGlXpsBackend`.
12
+ - Added XPS ODTTF embedded font deobfuscation/loading for CAD-like text appearance.
13
+ - Tuned demo defaults for dense CAD overview linework and lower DPR memory pressure.
14
+ - Added curated Autodesk floor-plan DWFx demo entry with default A03 sheet.
15
+ - Coalesced viewer render requests to avoid zoom/pan render pile-ups.
16
+ - Kept Cloudflare demo assets versioned under `dist-v0.6.0` to prevent stale browser module caches.
17
+
3
18
  ## 0.5.1
4
19
 
5
- - Published as `dwf-viewer` and `@flyfish-dev/dwf-viewer` with AGPL-3.0-only package metadata.
6
20
  - Added CAD adaptive line-weight rendering for XPS FixedPage, W2D Canvas/WASM, and W2D WebGL paths.
7
- - Added overview text LOD culling to avoid black annotation blobs at fit-to-page while preserving text when zoomed in.
21
+ - Added overview text LOD culling to avoid dense annotation blobs at fit-to-page while preserving text when zoomed in.
8
22
  - Added embedded XPS TrueType font loading for Glyphs when browsers support the FontFace API.
9
23
  - Added demo line-weight mode selector: CAD adaptive, hairline, physical.
24
+ - Versioned demo dist assets under `dist-v0.5.1` to prevent stale browser module caches.
10
25
 
11
26
  ## 0.5.0
12
27
 
13
- - Prepared repository for public npm release and Cloudflare Pages demo deployment.
14
- - Renamed the public package metadata to `dwf-viewer`.
15
- - Added dual npm publishing support for `dwf-viewer` and `@flyfish-dev/dwf-viewer`.
16
- - Switched the project license to `AGPL-3.0-only` and added `NOTICE`.
17
- - Normalized GitHub repository metadata for `flyfish-dev/dwf-viewer`.
28
+ - Prepared the repository for public npm release and Cloudflare Pages demo deployment.
29
+ - Published normalized packages as `dwf-viewer` and `@flyfish-dev/dwf-viewer`.
30
+ - Adopted `AGPL-3.0-only` with `NOTICE` for strict open-source distribution.
18
31
  - Added curated, de-duplicated demo example manifest.
19
32
  - Removed production-success info diagnostics from W3D/eModel pages so the Robot Arm demo renders with zero page diagnostics.
20
- - Added static demo build pipeline and package validation scripts.
33
+ - Added static demo build pipeline, package validation scripts, CI, and publish helpers.
@@ -46,3 +46,7 @@ Serve the WASM file with `application/wasm`. The Cloudflare Pages demo writes `_
46
46
  ## Unsupported semantics
47
47
 
48
48
  Unsupported historical HSF opcodes, advanced CAD display-list behavior, and full Design Review parity are tracked as incremental parser work. The rendering contract is: exact-enough supported semantics are displayed, unsupported semantics are diagnosed, and known-failing 3D geometry is not hidden by image fallback.
49
+
50
+ ## 2D production rendering update
51
+
52
+ Version 0.6.0 adds WebGL acceleration for XPS/DWFx vector geometry and keeps text/images in a Canvas overlay. This avoids CPU redraw bottlenecks when zooming dense sheets and keeps GPU memory bounded through scene cache limits. Embedded ODTTF fonts are deobfuscated and loaded through FontFace where supported, producing CAD-like text weights in the demo.
package/README.md CHANGED
@@ -1,31 +1,36 @@
1
1
  # DWF Viewer
2
2
 
3
- Pure frontend DWF/DWFx viewer for browsers. It parses DWF/DWFx packages locally in the browser and renders common 2D and 3D content without a server-side CAD conversion service.
4
-
5
- ## Status
6
-
7
- This repository is structured as a publishable npm package plus a static Cloudflare Pages demo. The same build is published under both `dwf-viewer` and `@flyfish-dev/dwf-viewer`.
3
+ Pure frontend DWF/DWFx CAD viewer for browsers. It parses DWF/DWFx packages locally and renders common 2D and 3D content without a server-side CAD conversion service.
8
4
 
9
5
  ## Links
10
6
 
11
- | Target | URL |
7
+ | Entry | URL |
12
8
  |---|---|
13
- | Live demo | https://dwf-viewer-demo.pages.dev/ |
14
- | GitHub repository | https://github.com/flyfish-dev/dwf-viewer |
15
- | Official documentation | https://github.com/flyfish-dev/dwf-viewer#readme |
16
- | npm package | https://www.npmjs.com/package/dwf-viewer |
17
- | Scoped npm package | https://www.npmjs.com/package/@flyfish-dev/dwf-viewer |
9
+ | Online demo | https://dwf-viewer-demo.pages.dev/ |
10
+ | Repository | https://github.com/flyfish-dev/dwf-viewer |
11
+ | npm | https://www.npmjs.com/package/dwf-viewer |
12
+ | scoped npm | https://www.npmjs.com/package/@flyfish-dev/dwf-viewer |
13
+
14
+ Current version: `0.6.1`
15
+
16
+ ## Why
18
17
 
19
- Supported paths:
18
+ DWF and DWFx are still common in manufacturing, construction, field service, engineering archives, PLM, after-sales systems, and document management products. Many web systems can preview PDF, Office files, and images, but DWF often falls back to desktop viewers, server-side conversion, or static thumbnails.
19
+
20
+ DWF Viewer is built for the browser-native path: parse the package, decode the sheet/model data, and render it directly inside a web application. This keeps integration simple for private deployments and reduces conversion queues, temporary files, and infrastructure coupling.
21
+
22
+ ## Supported Paths
20
23
 
21
24
  | Format / content | Status |
22
25
  |---|---|
23
26
  | DWF 6+ ZIP container | Supported |
24
27
  | DWFx / OPC package | Supported |
25
- | XPS FixedPage 2D sheets | Supported common subset |
28
+ | XPS FixedPage 2D sheets | Supported common subset with WebGL vector acceleration and Canvas text/image overlay |
26
29
  | Classic binary WHIP!/W2D 2D sheets | Supported for core geometry/text/images used by Autodesk samples |
30
+ | Textual W2D pages | Supported for smoke tests and simple sheets |
27
31
  | W3D/HSF 3D eModel shell geometry | Supported: uncompressed, CS_TRIVIAL, and Edgebreaker shell meshes |
28
32
  | Three.js adapter | Supported |
33
+ | Built-in WebGL 3D renderer | Supported through `DwfViewer` |
29
34
  | WASM raster fallback | Supported for 2D vector rasterization |
30
35
  | eModel metadata | Materials, textures, scene tree, saved views, PMI/animation data containers |
31
36
 
@@ -41,7 +46,7 @@ npm install @flyfish-dev/dwf-viewer three
41
46
 
42
47
  `three` is an optional peer dependency. It is required only when you use `createThreeGroupFromW3d()` directly. The built-in `DwfViewer` uses its own WebGL 3D renderer.
43
48
 
44
- ## Basic browser usage
49
+ ## Basic Browser Usage
45
50
 
46
51
  ```ts
47
52
  import 'dwf-viewer/styles.css';
@@ -51,14 +56,15 @@ const viewer = new DwfViewer(document.getElementById('viewer')!, {
51
56
  wasmUrl: '/dwfv-render.wasm',
52
57
  preferWebgl: true,
53
58
  preferWasm: true,
54
- maxDevicePixelRatio: 2,
55
- maxCanvasPixels: 16_777_216,
56
- maxGpuCacheBytes: 160 * 1024 * 1024,
57
- maxCachedScenes: 2,
58
-
59
- // CAD-viewer style overview: thin readable lines at fit-to-page,
60
- // source line weights return progressively while zooming in.
61
- lineWeightMode: 'adaptive'
59
+ maxDevicePixelRatio: 1.5,
60
+ maxCanvasPixels: 12_000_000,
61
+ maxGpuCacheBytes: 192 * 1024 * 1024,
62
+ maxCachedScenes: 4,
63
+ lineWeightMode: 'adaptive',
64
+ minStrokeCssPx: 0.42,
65
+ maxOverviewStrokeCssPx: 0.9,
66
+ minTextCssPx: 1.05,
67
+ minFilledAreaCssPx: 0.04
62
68
  });
63
69
 
64
70
  await viewer.load(file);
@@ -70,32 +76,47 @@ Copy the WASM asset from the package into your public assets directory:
70
76
  cp node_modules/dwf-viewer/public/dwfv-render.wasm public/dwfv-render.wasm
71
77
  ```
72
78
 
73
- ## CAD line-weight rendering
79
+ For the scoped package:
80
+
81
+ ```bash
82
+ cp node_modules/@flyfish-dev/dwf-viewer/public/dwfv-render.wasm public/dwfv-render.wasm
83
+ ```
84
+
85
+ ## WebGL 2D Rendering
86
+
87
+ Version `0.6.x` adds WebGL-accelerated XPS/DWFx 2D vector rendering through `WebGlXpsBackend`, alongside the existing W2D WebGL path.
74
88
 
75
- The default 2D rendering mode is `lineWeightMode: 'adaptive'`. This follows the behavior users expect from CAD viewers: at fit-to-page, linework is normalized toward screen-space hairlines so drawings remain readable; as zoom increases, the original DWF/XPS line weights are allowed to grow progressively. This prevents overview pages from becoming black while still preserving heavy line intent when inspecting details.
89
+ The built-in viewer uses WebGL for Classic W2D and DWFx/XPS vector geometry when `preferWebgl` is enabled. Geometry is uploaded to GPU buffers and cached by page and zoom bucket; pan operations update shader uniforms. Text, images, and XPS image brushes stay on the transparent Canvas overlay so browser font rendering and bitmap decoding remain reliable.
90
+
91
+ For dense architectural sheets, the demo defaults are tuned for CAD review: 1.5 maximum DPR, a 12M-pixel canvas cap, adaptive thin-line overview, and render coalescing during wheel/pointer interaction.
92
+
93
+ ## CAD Line-Weight Rendering
94
+
95
+ The default 2D rendering mode is `lineWeightMode: 'adaptive'`. At fit-to-page, linework is normalized toward screen-space hairlines so dense drawings remain readable; as zoom increases, original DWF/XPS line weights return progressively.
76
96
 
77
97
  Available modes:
78
98
 
79
99
  | Mode | Behavior |
80
100
  |---|---|
81
101
  | `adaptive` | Default. Overview thin-line rendering with zoom-aware recovery of source line weights. |
82
- | `hairline` | Force all strokes to one visible CSS-pixel hairline. Useful for dense plans and review thumbnails. |
83
- | `physical` | Preserve source stroke widths exactly. Useful for print-fidelity comparisons, but dense sheets can look heavy when zoomed out. |
102
+ | `hairline` | Force strokes to a visible CSS-pixel hairline. Useful for dense plans and review thumbnails. |
103
+ | `physical` | Preserve source stroke widths. Useful for print-fidelity comparisons; dense sheets can look heavy when zoomed out. |
84
104
 
85
105
  Related options:
86
106
 
87
107
  ```ts
88
108
  new DwfViewer(el, {
89
109
  lineWeightMode: 'adaptive',
90
- minStrokeCssPx: 0.55,
91
- maxOverviewStrokeCssPx: 1.15,
92
- minTextCssPx: 3.5
110
+ minStrokeCssPx: 0.42,
111
+ maxOverviewStrokeCssPx: 0.9,
112
+ minTextCssPx: 1.05,
113
+ minFilledAreaCssPx: 0.04
93
114
  });
94
115
  ```
95
116
 
96
- XPS/DWFx `Glyphs` use embedded TrueType fonts when the browser allows `FontFace` loading. Very small text is skipped in adaptive overview mode and appears normally when zoomed in; this avoids unreadable black blobs in architectural sheets.
117
+ XPS/DWFx `Glyphs` use embedded TrueType fonts when the browser allows `FontFace` loading. Very small text is skipped in adaptive overview mode and appears normally when zoomed in; this avoids unreadable annotation blocks in dense architectural sheets.
97
118
 
98
- ## Three.js integration
119
+ ## Three.js Integration
99
120
 
100
121
  ```ts
101
122
  import * as THREE from 'three';
@@ -108,7 +129,6 @@ if (page?.kind === 'w3d-model') {
108
129
  const group = createThreeGroupFromW3d(page, THREE, {
109
130
  showFeatureEdges: true,
110
131
  textureResolver(texture) {
111
- // Return a THREE.Texture if your app wants to bind DWFx texture resources.
112
132
  return undefined;
113
133
  }
114
134
  });
@@ -116,7 +136,7 @@ if (page?.kind === 'w3d-model') {
116
136
  }
117
137
  ```
118
138
 
119
- ## Local development
139
+ ## Local Development
120
140
 
121
141
  ```bash
122
142
  npm install
@@ -131,14 +151,15 @@ Then open:
131
151
  http://127.0.0.1:8080/
132
152
  ```
133
153
 
134
- ## Example set
154
+ ## Example Set
135
155
 
136
156
  The demo examples are listed in `examples/manifest.json` and are intentionally de-duplicated:
137
157
 
138
158
  | Example | Purpose |
139
159
  |---|---|
140
- | `robot-arm.dwfx` | 3D W3D/HSF eModel with shell meshes, scene tree, materials, textures, saved views |
141
- | `blocks-and-tables.dwf` | Binary WHIP!/W2D 2D ePlot sample |
160
+ | `blocks-and-tables.dwf` | Default demo. Binary WHIP!/W2D ePlot sample with fast loading and a clear first impression |
161
+ | `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 |
162
+ | `robot-arm.dwfx` | 3D W3D/HSF eModel with shell meshes, scene tree, materials, textures, and saved views |
142
163
  | `minimal-xps.dwfx` | Small DWFx/XPS FixedPage sample |
143
164
  | `text-w2d.dwf` | Textual W2D smoke-test sample |
144
165
 
@@ -148,30 +169,19 @@ Run:
148
169
  npm run check:examples
149
170
  ```
150
171
 
151
- ## NPM publishing checklist
172
+ ## NPM Publishing Checklist
152
173
 
153
174
  ```bash
154
175
  npm run clean
155
- npm run publish:all -- --dry-run
176
+ npm run build
177
+ npm run validate:production
178
+ npm run check:package
156
179
  npm run publish:all
157
180
  ```
158
181
 
159
- `publish:all` builds once, validates the production examples, checks the package tarball, then publishes both `dwf-viewer` and `@flyfish-dev/dwf-viewer`. Add npm options when needed:
160
-
161
- ```bash
162
- npm run publish:all -- --dry-run
163
- npm run publish:all -- --otp=123456
164
- ```
165
-
166
- GitHub release publishing can use provenance through the included workflow and `NPM_TOKEN`.
167
-
168
- ## Cloudflare Pages demo
182
+ The package is published as both `dwf-viewer` and `@flyfish-dev/dwf-viewer`.
169
183
 
170
- Live demo:
171
-
172
- ```text
173
- https://dwf-viewer-demo.pages.dev/
174
- ```
184
+ ## Cloudflare Pages Demo
175
185
 
176
186
  The repository includes `wrangler.toml` with:
177
187
 
@@ -194,9 +204,7 @@ Direct upload:
194
204
  npm run deploy:pages
195
205
  ```
196
206
 
197
- `build:demo` produces a static directory containing only demo HTML/JS, `dist`, `public/dwfv-render.wasm`, `styles`, and the curated examples.
198
-
199
- GitHub Actions builds the demo for every pull request. On pushes to `main`, it deploys to Cloudflare Pages when the repository secrets `CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` are present.
207
+ `build:demo` produces a static directory containing demo HTML/JS, a versioned `dist-v*` directory, `public/dwfv-render.wasm`, `styles`, and curated examples. Versioned dist assets prevent stale browser module caches after releases.
200
208
 
201
209
  ## Public API
202
210
 
@@ -207,6 +215,7 @@ openDwfDocument(input, options?)
207
215
  DwfViewer
208
216
  PageRenderer
209
217
  WebGlW2dBackend
218
+ WebGlXpsBackend
210
219
  ThreeW3dRenderer
211
220
  createThreeGroupFromW3d(page, THREE, options?)
212
221
  ```
@@ -216,6 +225,7 @@ Types:
216
225
  ```ts
217
226
  LoadedDwfDocument
218
227
  PageData
228
+ XpsPageData
219
229
  W3dPageData
220
230
  W3dModelData
221
231
  W3dMeshData
@@ -224,15 +234,20 @@ Diagnostic
224
234
  RenderStats
225
235
  ```
226
236
 
227
- ## Production behavior
237
+ ## Production Behavior
228
238
 
229
- The production validation target is strict for the bundled Robot Arm eModel:
239
+ The production validation target is strict for the bundled samples:
230
240
 
231
241
  ```text
242
+ Robot Arm:
232
243
  page kind: w3d-model
233
244
  meshes >= 30
234
245
  triangles >= 40,000
235
- page diagnostics: 0
246
+ non-info diagnostics: 0
247
+
248
+ Autodesk Floor Plans:
249
+ page kind: xps-fixed-page
250
+ pages >= 18
236
251
  non-info diagnostics: 0
237
252
  ```
238
253
 
@@ -242,10 +257,12 @@ Run:
242
257
  npm run validate:production
243
258
  ```
244
259
 
245
- ## Known boundaries
260
+ ## License
246
261
 
247
- This is a pure frontend implementation, not Autodesk Design Review or HOOPS Exchange. The parser is intentionally fail-closed for unsupported historical HSF/W2D opcode semantics: it should show explicit diagnostics instead of silently drawing incorrect geometry. Current production coverage includes the core 2D/3D rendering paths needed by the bundled samples and the extension points for materials, textures, PMI, animation and selection tree metadata.
262
+ DWF Viewer is licensed under `AGPL-3.0-only`. See `LICENSE` and `NOTICE`.
248
263
 
249
- ## License
264
+ Commercial use, private forks, and second-development integrations must comply with the license and preserve attribution. Contributions are welcome through Issues and Pull Requests.
265
+
266
+ ## Known Boundaries
250
267
 
251
- AGPL-3.0-only. See `LICENSE` and `NOTICE`.
268
+ This is a pure frontend implementation and is not affiliated with Autodesk Design Review or HOOPS Exchange. The parser is intentionally fail-closed for unsupported historical HSF/W2D opcode semantics: it should show explicit diagnostics instead of silently drawing guessed geometry. Current production coverage includes the core 2D/3D rendering paths needed by the bundled samples and extension points for materials, textures, PMI, animation, and selection tree metadata.
@@ -58,7 +58,7 @@ export interface PageRenderOptions {
58
58
  maxCachedScenes?: number;
59
59
  }
60
60
  export interface RenderStats {
61
- backend: 'canvas2d' | 'wasm-raster' | 'webgl' | 'threejs-webgl' | 'image' | 'unsupported';
61
+ backend: 'canvas2d' | 'wasm-raster' | 'webgl' | 'webgl-xps' | 'threejs-webgl' | 'image' | 'unsupported';
62
62
  commands: number;
63
63
  warnings: Diagnostic[];
64
64
  }
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export type { DwfViewerOptions, LoadOptions } from './viewer/DwfViewer.js';
6
6
  export { PageRenderer } from './render/PageRenderer.js';
7
7
  export { WasmRasterBackend } from './wasm/WasmRasterBackend.js';
8
8
  export { WebGlW2dBackend } from './render/WebGlW2dBackend.js';
9
+ export { WebGlXpsBackend } from './render/WebGlXpsBackend.js';
9
10
  export { ThreeW3dRenderer } from './render/ThreeW3dRenderer.js';
10
11
  export { createThreeGroupFromW3d } from './render/ThreeJsSceneAdapter.js';
11
12
  export type { LoadedDwfDocument, PageData, XpsPageData, W2dTextPageData, ImagePageData, UnsupportedPageData, W3dPageData, W3dModelData, W3dMeshData, W2dPrimitive } from './format/document.js';
package/dist/index.js CHANGED
@@ -5,5 +5,6 @@ export { DwfViewer } from './viewer/DwfViewer.js';
5
5
  export { PageRenderer } from './render/PageRenderer.js';
6
6
  export { WasmRasterBackend } from './wasm/WasmRasterBackend.js';
7
7
  export { WebGlW2dBackend } from './render/WebGlW2dBackend.js';
8
+ export { WebGlXpsBackend } from './render/WebGlXpsBackend.js';
8
9
  export { ThreeW3dRenderer } from './render/ThreeW3dRenderer.js';
9
10
  export { createThreeGroupFromW3d } from './render/ThreeJsSceneAdapter.js';
@@ -30,6 +30,7 @@ export class PageRenderer {
30
30
  return this.renderUnsupported(page, canvas, options);
31
31
  }
32
32
  dispose() {
33
+ this.xps?.dispose();
33
34
  this.w2d?.dispose();
34
35
  this.w3d?.dispose();
35
36
  }
@@ -0,0 +1,38 @@
1
+ import type { Diagnostic } from '../format/types.js';
2
+ import type { XpsPageData } from '../format/document.js';
3
+ import { type CadLineStyleOptions } from './cadLineStyle.js';
4
+ export interface WebGlXpsRenderOptions extends CadLineStyleOptions {
5
+ zoom?: number;
6
+ panX?: number;
7
+ panY?: number;
8
+ background?: string;
9
+ maxGpuCacheBytes?: number;
10
+ maxCachedScenes?: number;
11
+ compositeToTarget?: boolean;
12
+ }
13
+ export interface WebGlXpsRenderResult {
14
+ commands: number;
15
+ warnings: Diagnostic[];
16
+ gpuBytes: number;
17
+ vertexCount: number;
18
+ pathCount: number;
19
+ cacheHit: boolean;
20
+ }
21
+ export declare class WebGlXpsBackend {
22
+ private readonly canvas;
23
+ private readonly gl;
24
+ private readonly program;
25
+ private readonly aPos;
26
+ private readonly aColor;
27
+ private readonly uMatrix;
28
+ private readonly uViewport;
29
+ private readonly scenes;
30
+ private gpuBytes;
31
+ private tick;
32
+ constructor(canvas?: HTMLCanvasElement);
33
+ render(page: XpsPageData, root: Element, targetCanvas: HTMLCanvasElement, options?: WebGlXpsRenderOptions): WebGlXpsRenderResult;
34
+ dispose(): void;
35
+ private resize;
36
+ private compileScene;
37
+ private evictIfNeeded;
38
+ }
@@ -0,0 +1,541 @@
1
+ import { diag } from '../format/types.js';
2
+ import { childElements, getAttr, localName, parseNumberList } from '../format/util.js';
3
+ import { flattenPath, parsePathData } from './xpsPath.js';
4
+ import { colorToRgba32, multiplyMatrix, parseBrushColor, parseMatrix, transformPoint } from './style.js';
5
+ import { adaptiveStrokeUserWidth, canvasDpr, estimateMatrixScale, shouldDrawFilledBounds } from './cadLineStyle.js';
6
+ import { fitPageMatrix } from './viewport.js';
7
+ const VERTEX_STRIDE = 12;
8
+ const DEFAULT_MAX_GPU_CACHE_BYTES = 128 * 1024 * 1024;
9
+ const DEFAULT_MAX_CACHED_SCENES = 4;
10
+ const IDENTITY_MATRIX = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
11
+ export class WebGlXpsBackend {
12
+ constructor(canvas) {
13
+ this.scenes = new Map();
14
+ this.gpuBytes = 0;
15
+ this.tick = 0;
16
+ this.canvas = canvas ?? document.createElement('canvas');
17
+ const gl = this.canvas.getContext('webgl', {
18
+ alpha: false,
19
+ antialias: true,
20
+ depth: false,
21
+ stencil: false,
22
+ preserveDrawingBuffer: true,
23
+ powerPreference: 'high-performance'
24
+ });
25
+ if (!gl)
26
+ throw new Error('WebGLRenderingContext is not available.');
27
+ this.gl = gl;
28
+ this.program = createProgram(gl, VERTEX_SHADER, FRAGMENT_SHADER);
29
+ this.aPos = gl.getAttribLocation(this.program, 'a_pos');
30
+ this.aColor = gl.getAttribLocation(this.program, 'a_color');
31
+ const matrix = gl.getUniformLocation(this.program, 'u_matrix');
32
+ const viewport = gl.getUniformLocation(this.program, 'u_viewport');
33
+ if (this.aPos < 0 || this.aColor < 0 || !matrix || !viewport)
34
+ throw new Error('Failed to resolve WebGL shader locations.');
35
+ this.uMatrix = matrix;
36
+ this.uViewport = viewport;
37
+ }
38
+ render(page, root, targetCanvas, options = {}) {
39
+ const warnings = [];
40
+ if (targetCanvas.width <= 0 || targetCanvas.height <= 0) {
41
+ return { commands: 0, warnings, gpuBytes: this.gpuBytes, vertexCount: 0, pathCount: 0, cacheHit: true };
42
+ }
43
+ this.resize(targetCanvas.width, targetCanvas.height);
44
+ const pageMatrix = fitPageMatrix({
45
+ canvasWidth: this.canvas.width,
46
+ canvasHeight: this.canvas.height,
47
+ pageWidth: page.width,
48
+ pageHeight: page.height,
49
+ zoom: options.zoom,
50
+ panX: options.panX,
51
+ panY: options.panY
52
+ });
53
+ const runtime = { dpr: canvasDpr(targetCanvas), zoom: options.zoom ?? 1 };
54
+ const key = sceneKey(page, pageMatrix, options);
55
+ let scene = this.scenes.get(key);
56
+ const cacheHit = !!scene;
57
+ if (!scene) {
58
+ const vectors = collectVectorPaths(root);
59
+ scene = this.compileScene(page, key, vectors, pageMatrix, options, runtime);
60
+ this.scenes.set(key, scene);
61
+ this.gpuBytes += scene.gpuBytes;
62
+ this.evictIfNeeded(options);
63
+ scene = this.scenes.get(key) ?? scene;
64
+ }
65
+ scene.lastUsed = ++this.tick;
66
+ const gl = this.gl;
67
+ const bg = rgba01(options.background ?? '#ffffff');
68
+ gl.viewport(0, 0, this.canvas.width, this.canvas.height);
69
+ gl.disable(gl.DEPTH_TEST);
70
+ gl.disable(gl.CULL_FACE);
71
+ gl.enable(gl.BLEND);
72
+ gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
73
+ gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
74
+ gl.clear(gl.COLOR_BUFFER_BIT);
75
+ gl.useProgram(this.program);
76
+ gl.bindBuffer(gl.ARRAY_BUFFER, scene.buffer);
77
+ gl.enableVertexAttribArray(this.aPos);
78
+ gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, false, VERTEX_STRIDE, 0);
79
+ gl.enableVertexAttribArray(this.aColor);
80
+ gl.vertexAttribPointer(this.aColor, 4, gl.UNSIGNED_BYTE, true, VERTEX_STRIDE, 8);
81
+ gl.uniform4f(this.uMatrix, pageMatrix.a, pageMatrix.b, pageMatrix.c, pageMatrix.d);
82
+ gl.uniform4f(this.uViewport, pageMatrix.e, pageMatrix.f, this.canvas.width, this.canvas.height);
83
+ gl.drawArrays(gl.TRIANGLES, 0, scene.vertexCount);
84
+ if (options.compositeToTarget ?? true) {
85
+ const ctx = targetCanvas.getContext('2d');
86
+ if (!ctx)
87
+ throw new Error('CanvasRenderingContext2D is not available for WebGL compositing.');
88
+ ctx.save();
89
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
90
+ ctx.clearRect(0, 0, targetCanvas.width, targetCanvas.height);
91
+ ctx.drawImage(this.canvas, 0, 0);
92
+ ctx.restore();
93
+ }
94
+ if (scene.vertexCount === 0) {
95
+ warnings.push(diag('warning', 'WEBGL_XPS_EMPTY_SCENE', 'WebGL XPS scene contained no drawable vector geometry; Canvas/WASM fallback may be required.', page.sourcePath));
96
+ }
97
+ return {
98
+ commands: scene.pathCount,
99
+ warnings,
100
+ gpuBytes: this.gpuBytes,
101
+ vertexCount: scene.vertexCount,
102
+ pathCount: scene.pathCount,
103
+ cacheHit
104
+ };
105
+ }
106
+ dispose() {
107
+ for (const scene of this.scenes.values())
108
+ this.gl.deleteBuffer(scene.buffer);
109
+ this.scenes.clear();
110
+ this.gpuBytes = 0;
111
+ }
112
+ resize(width, height) {
113
+ if (this.canvas.width !== width || this.canvas.height !== height) {
114
+ this.canvas.width = width;
115
+ this.canvas.height = height;
116
+ }
117
+ }
118
+ compileScene(page, key, vectors, pageMatrix, options, runtime) {
119
+ const writer = new VertexWriter();
120
+ let pathCount = 0;
121
+ for (const vector of vectors) {
122
+ pathCount++;
123
+ appendVectorPath(writer, vector, pageMatrix, options, runtime);
124
+ }
125
+ const bufferBytes = writer.byteLength;
126
+ const maxBytes = options.maxGpuCacheBytes ?? DEFAULT_MAX_GPU_CACHE_BYTES;
127
+ if (bufferBytes > maxBytes) {
128
+ throw new Error(`WebGL XPS scene buffer would require ${formatBytes(bufferBytes)}, exceeding maxGpuCacheBytes=${formatBytes(maxBytes)}.`);
129
+ }
130
+ const gl = this.gl;
131
+ const buffer = gl.createBuffer();
132
+ if (!buffer)
133
+ throw new Error('Failed to allocate WebGLBuffer.');
134
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
135
+ gl.bufferData(gl.ARRAY_BUFFER, writer.toArrayBuffer(), gl.STATIC_DRAW);
136
+ const err = gl.getError();
137
+ if (err !== gl.NO_ERROR) {
138
+ gl.deleteBuffer(buffer);
139
+ throw new Error(`WebGL XPS buffer upload failed: 0x${err.toString(16)} for ${page.sourcePath}.`);
140
+ }
141
+ return { key, buffer, vertexCount: writer.vertexCount, gpuBytes: bufferBytes, pathCount, lastUsed: ++this.tick };
142
+ }
143
+ evictIfNeeded(options) {
144
+ const maxBytes = options.maxGpuCacheBytes ?? DEFAULT_MAX_GPU_CACHE_BYTES;
145
+ const maxScenes = options.maxCachedScenes ?? DEFAULT_MAX_CACHED_SCENES;
146
+ while (this.scenes.size > Math.max(1, maxScenes) || this.gpuBytes > maxBytes) {
147
+ let oldest;
148
+ for (const scene of this.scenes.values())
149
+ if (!oldest || scene.lastUsed < oldest.lastUsed)
150
+ oldest = scene;
151
+ if (!oldest)
152
+ break;
153
+ this.scenes.delete(oldest.key);
154
+ this.gl.deleteBuffer(oldest.buffer);
155
+ this.gpuBytes -= oldest.gpuBytes;
156
+ }
157
+ }
158
+ }
159
+ function collectVectorPaths(root) {
160
+ const out = [];
161
+ walk(root, IDENTITY_MATRIX, 1, out);
162
+ return out;
163
+ }
164
+ function walk(el, matrix, opacity, out) {
165
+ const name = localName(el);
166
+ const local = elementMatrix(el);
167
+ const composed = multiplyMatrix(matrix, local);
168
+ const ownOpacity = opacity * parseOpacity(getAttr(el, 'Opacity'));
169
+ if (name === 'Path') {
170
+ const commands = extractPathCommands(el);
171
+ if (commands.length > 0) {
172
+ const fill = extractBrush(el, 'Fill', ownOpacity);
173
+ const stroke = extractBrush(el, 'Stroke', ownOpacity);
174
+ const thickness = Number(getAttr(el, 'StrokeThickness') ?? 1);
175
+ if (fill || (stroke && thickness > 0)) {
176
+ out.push({
177
+ commands,
178
+ matrix: composed,
179
+ opacity: ownOpacity,
180
+ fill,
181
+ stroke,
182
+ thickness: Number.isFinite(thickness) ? thickness : 1,
183
+ lineStyle: extractLineStyle(el),
184
+ fillRule: fillRule(el),
185
+ bounds: pathBounds(commands)
186
+ });
187
+ }
188
+ }
189
+ }
190
+ for (const child of childElements(el)) {
191
+ const childName = localName(child);
192
+ if (childName.includes('.'))
193
+ continue;
194
+ walk(child, composed, ownOpacity, out);
195
+ }
196
+ }
197
+ function appendVectorPath(writer, vector, pageMatrix, options, runtime) {
198
+ const fullMatrix = multiplyMatrix(pageMatrix, vector.matrix);
199
+ const localScale = estimateMatrixScale(vector.matrix);
200
+ const sourceWidth = Math.max(0.000001, vector.thickness || 1);
201
+ const width = Math.max(0.01, adaptiveStrokeUserWidth(sourceWidth, fullMatrix, options, runtime) * localScale);
202
+ const subs = flattenPath(vector.commands, 0.5);
203
+ const fill = vector.fill ? rgbaBytes(vector.fill) : undefined;
204
+ const stroke = vector.stroke ? rgbaBytes(vector.stroke) : undefined;
205
+ if (fill && shouldDrawFilledBounds(vector.bounds, fullMatrix, options, runtime)) {
206
+ for (const sub of subs) {
207
+ if (sub.closed || sub.points.length >= 6)
208
+ appendPolygonFan(writer, transformPointsArray(sub.points, vector.matrix), fill);
209
+ }
210
+ }
211
+ if (stroke && vector.thickness > 0) {
212
+ for (const sub of subs) {
213
+ const pts = transformPointsArray(sub.points, vector.matrix);
214
+ const drawPts = sub.closed ? closePoints(pts) : pts;
215
+ if (vector.lineStyle.dash.length > 0)
216
+ appendDashedPolyline(writer, drawPts, width, vector.lineStyle, stroke);
217
+ else
218
+ appendPolyline(writer, drawPts, width, stroke);
219
+ }
220
+ }
221
+ }
222
+ class VertexWriter {
223
+ constructor() {
224
+ this.buffer = new ArrayBuffer(64 * 1024);
225
+ this.view = new DataView(this.buffer);
226
+ this.vertexCount = 0;
227
+ }
228
+ get byteLength() { return this.vertexCount * VERTEX_STRIDE; }
229
+ push(x, y, color) {
230
+ if (!Number.isFinite(x) || !Number.isFinite(y))
231
+ return;
232
+ const offset = this.byteLength;
233
+ this.ensure(VERTEX_STRIDE);
234
+ this.view.setFloat32(offset, x, true);
235
+ this.view.setFloat32(offset + 4, y, true);
236
+ this.view.setUint8(offset + 8, color.r);
237
+ this.view.setUint8(offset + 9, color.g);
238
+ this.view.setUint8(offset + 10, color.b);
239
+ this.view.setUint8(offset + 11, color.a);
240
+ this.vertexCount++;
241
+ }
242
+ toArrayBuffer() { return this.buffer.slice(0, this.byteLength); }
243
+ ensure(extraBytes) {
244
+ const needed = this.byteLength + extraBytes;
245
+ if (needed <= this.buffer.byteLength)
246
+ return;
247
+ let next = this.buffer.byteLength;
248
+ while (next < needed)
249
+ next *= 2;
250
+ const newBuffer = new ArrayBuffer(next);
251
+ new Uint8Array(newBuffer).set(new Uint8Array(this.buffer, 0, this.byteLength));
252
+ this.buffer = newBuffer;
253
+ this.view = new DataView(this.buffer);
254
+ }
255
+ }
256
+ function appendPolygonFan(writer, pts, color) {
257
+ if (pts.length < 3)
258
+ return;
259
+ const p0 = pts[0];
260
+ for (let i = 1; i + 1 < pts.length; i++) {
261
+ const p1 = pts[i];
262
+ const p2 = pts[i + 1];
263
+ if (triangleAreaAbs(p0, p1, p2) < 1e-6)
264
+ continue;
265
+ writer.push(p0.x, p0.y, color);
266
+ writer.push(p1.x, p1.y, color);
267
+ writer.push(p2.x, p2.y, color);
268
+ }
269
+ }
270
+ function appendPolyline(writer, pts, width, color) {
271
+ if (pts.length < 2)
272
+ return;
273
+ const half = Math.max(0.05, width * 0.5);
274
+ for (let i = 0; i + 1 < pts.length; i++)
275
+ appendLineSegment(writer, pts[i], pts[i + 1], half, color);
276
+ }
277
+ function appendDashedPolyline(writer, pts, width, style, color) {
278
+ if (pts.length < 2 || style.dash.length === 0)
279
+ return;
280
+ const pattern = style.dash.map(v => Math.max(0.01, v * width));
281
+ const total = pattern.reduce((a, b) => a + b, 0);
282
+ if (total <= 0.01) {
283
+ appendPolyline(writer, pts, width, color);
284
+ return;
285
+ }
286
+ let patternIndex = 0;
287
+ let remaining = pattern[0];
288
+ let draw = true;
289
+ let offset = ((style.dashOffset * width) % total + total) % total;
290
+ while (offset > remaining) {
291
+ offset -= remaining;
292
+ patternIndex = (patternIndex + 1) % pattern.length;
293
+ remaining = pattern[patternIndex];
294
+ draw = !draw;
295
+ }
296
+ remaining -= offset;
297
+ for (let i = 0; i + 1 < pts.length; i++) {
298
+ const a = pts[i];
299
+ const b = pts[i + 1];
300
+ const dx = b.x - a.x;
301
+ const dy = b.y - a.y;
302
+ const len = Math.hypot(dx, dy);
303
+ if (len <= 1e-9)
304
+ continue;
305
+ let consumed = 0;
306
+ while (consumed < len - 1e-9) {
307
+ const step = Math.min(remaining, len - consumed);
308
+ if (draw && step > 1e-9) {
309
+ const t0 = consumed / len;
310
+ const t1 = (consumed + step) / len;
311
+ appendLineSegment(writer, { x: a.x + dx * t0, y: a.y + dy * t0 }, { x: a.x + dx * t1, y: a.y + dy * t1 }, Math.max(0.05, width * 0.5), color);
312
+ }
313
+ consumed += step;
314
+ remaining -= step;
315
+ if (remaining <= 1e-9) {
316
+ patternIndex = (patternIndex + 1) % pattern.length;
317
+ remaining = pattern[patternIndex];
318
+ draw = !draw;
319
+ }
320
+ }
321
+ }
322
+ }
323
+ function appendLineSegment(writer, p0, p1, half, color) {
324
+ const dx = p1.x - p0.x;
325
+ const dy = p1.y - p0.y;
326
+ const len = Math.hypot(dx, dy);
327
+ if (len <= 1e-9)
328
+ return;
329
+ const nx = -dy / len * half;
330
+ const ny = dx / len * half;
331
+ const ax = p0.x + nx, ay = p0.y + ny;
332
+ const bx = p0.x - nx, by = p0.y - ny;
333
+ const cx = p1.x - nx, cy = p1.y - ny;
334
+ const dx2 = p1.x + nx, dy2 = p1.y + ny;
335
+ writer.push(ax, ay, color);
336
+ writer.push(bx, by, color);
337
+ writer.push(cx, cy, color);
338
+ writer.push(ax, ay, color);
339
+ writer.push(cx, cy, color);
340
+ writer.push(dx2, dy2, color);
341
+ }
342
+ function transformPointsArray(points, m) {
343
+ const out = [];
344
+ for (let i = 0; i + 1 < points.length; i += 2) {
345
+ const [x, y] = transformPoint(m, points[i] ?? 0, points[i + 1] ?? 0);
346
+ out.push({ x, y });
347
+ }
348
+ return out;
349
+ }
350
+ function closePoints(pts) {
351
+ if (pts.length < 2)
352
+ return pts;
353
+ const first = pts[0];
354
+ const last = pts[pts.length - 1];
355
+ if (first.x === last.x && first.y === last.y)
356
+ return pts;
357
+ return [...pts, { x: first.x, y: first.y }];
358
+ }
359
+ function triangleAreaAbs(a, b, c) {
360
+ return Math.abs((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x));
361
+ }
362
+ function sceneKey(page, pageMatrix, options) {
363
+ const mode = options.lineWeightMode ?? 'adaptive';
364
+ const scaleBucket = mode === 'physical' ? 'physical' : String(Math.round(Math.log2(Math.max(1e-12, estimateMatrixScale(pageMatrix))) * 8));
365
+ return `${page.id}|${page.sourcePath}|${page.width}x${page.height}|lw:${mode}:${scaleBucket}`;
366
+ }
367
+ function elementMatrix(el) {
368
+ let m = parseMatrix(getAttr(el, 'RenderTransform') ?? getAttr(el, 'Transform'));
369
+ for (const child of childElements(el)) {
370
+ const name = localName(child);
371
+ if (name.endsWith('.RenderTransform') || name.endsWith('.Transform')) {
372
+ const matrixEl = childElements(child).find(c => localName(c) === 'MatrixTransform');
373
+ if (matrixEl)
374
+ m = multiplyMatrix(m, parseMatrix(getAttr(matrixEl, 'Matrix')));
375
+ }
376
+ }
377
+ const left = Number(getAttr(el, 'Canvas.Left') ?? 0);
378
+ const top = Number(getAttr(el, 'Canvas.Top') ?? 0);
379
+ if (left || top)
380
+ m = multiplyMatrix({ a: 1, b: 0, c: 0, d: 1, e: left, f: top }, m);
381
+ return m;
382
+ }
383
+ function extractPathCommands(pathEl) {
384
+ const data = getAttr(pathEl, 'Data');
385
+ if (data)
386
+ return parsePathData(data);
387
+ for (const prop of childElements(pathEl)) {
388
+ if (localName(prop) !== 'Path.Data')
389
+ continue;
390
+ for (const geom of childElements(prop)) {
391
+ const figures = getAttr(geom, 'Figures');
392
+ if (figures)
393
+ return parsePathData(figures);
394
+ }
395
+ }
396
+ return [];
397
+ }
398
+ function extractBrush(el, prop, opacity) {
399
+ const direct = getAttr(el, prop);
400
+ if (direct)
401
+ return parseBrushColor(direct, opacity);
402
+ const solid = findPropertyBrush(el, prop, 'SolidColorBrush');
403
+ if (solid)
404
+ return parseBrushColor(getAttr(solid, 'Color'), opacity * parseOpacity(getAttr(solid, 'Opacity')));
405
+ return undefined;
406
+ }
407
+ function findPropertyBrush(el, prop, brushLocalName) {
408
+ const propName = `${localName(el)}.${prop}`;
409
+ for (const child of childElements(el)) {
410
+ if (localName(child) !== propName)
411
+ continue;
412
+ return childElements(child).find(c => localName(c) === brushLocalName);
413
+ }
414
+ return undefined;
415
+ }
416
+ function parseOpacity(value) {
417
+ if (!value)
418
+ return 1;
419
+ const n = Number(value);
420
+ return Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : 1;
421
+ }
422
+ function fillRule(el) {
423
+ const data = getAttr(el, 'Data') ?? '';
424
+ return /F0/.test(data) ? 'nonzero' : 'evenodd';
425
+ }
426
+ function extractLineStyle(el) {
427
+ const start = (getAttr(el, 'StrokeStartLineCap') ?? '').toLowerCase();
428
+ const end = (getAttr(el, 'StrokeEndLineCap') ?? '').toLowerCase();
429
+ const dashCap = (getAttr(el, 'StrokeDashCap') ?? '').toLowerCase();
430
+ const cap = start === 'round' || end === 'round' || dashCap === 'round'
431
+ ? 'round'
432
+ : start === 'square' || end === 'square' || dashCap === 'square'
433
+ ? 'square'
434
+ : 'butt';
435
+ const joinAttr = (getAttr(el, 'StrokeLineJoin') ?? '').toLowerCase();
436
+ const join = joinAttr === 'round' ? 'round' : joinAttr === 'bevel' ? 'bevel' : 'miter';
437
+ const miter = Number(getAttr(el, 'StrokeMiterLimit') ?? 10);
438
+ return {
439
+ cap,
440
+ join,
441
+ miterLimit: Number.isFinite(miter) && miter > 0 ? miter : 10,
442
+ dash: parseNumberList(getAttr(el, 'StrokeDashArray') ?? '').filter(v => Number.isFinite(v) && v > 0),
443
+ dashOffset: Number(getAttr(el, 'StrokeDashOffset') ?? 0) || 0
444
+ };
445
+ }
446
+ function pathBounds(commands) {
447
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
448
+ const add = (x, y) => {
449
+ if (!Number.isFinite(x) || !Number.isFinite(y))
450
+ return;
451
+ minX = Math.min(minX, x);
452
+ minY = Math.min(minY, y);
453
+ maxX = Math.max(maxX, x);
454
+ maxY = Math.max(maxY, y);
455
+ };
456
+ for (const c of commands) {
457
+ if (c.type === 'M' || c.type === 'L')
458
+ add(c.x, c.y);
459
+ else if (c.type === 'C') {
460
+ add(c.x1, c.y1);
461
+ add(c.x2, c.y2);
462
+ add(c.x, c.y);
463
+ }
464
+ else if (c.type === 'Q') {
465
+ add(c.x1, c.y1);
466
+ add(c.x, c.y);
467
+ }
468
+ else if (c.type === 'A') {
469
+ add(c.x - c.rx, c.y - c.ry);
470
+ add(c.x + c.rx, c.y + c.ry);
471
+ }
472
+ }
473
+ return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : undefined;
474
+ }
475
+ function rgbaBytes(css) {
476
+ const packed = colorToRgba32(css, 0xff000000);
477
+ return { r: packed & 255, g: (packed >>> 8) & 255, b: (packed >>> 16) & 255, a: (packed >>> 24) & 255 };
478
+ }
479
+ function rgba01(css) {
480
+ const c = rgbaBytes(css);
481
+ return [c.r / 255, c.g / 255, c.b / 255, c.a / 255];
482
+ }
483
+ function formatBytes(bytes) {
484
+ if (bytes < 1024)
485
+ return `${bytes} B`;
486
+ if (bytes < 1024 * 1024)
487
+ return `${(bytes / 1024).toFixed(1)} KiB`;
488
+ return `${(bytes / 1024 / 1024).toFixed(1)} MiB`;
489
+ }
490
+ function createProgram(gl, vertexSource, fragmentSource) {
491
+ const vertex = compileShader(gl, gl.VERTEX_SHADER, vertexSource);
492
+ const fragment = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource);
493
+ const program = gl.createProgram();
494
+ if (!program)
495
+ throw new Error('Failed to create WebGLProgram.');
496
+ gl.attachShader(program, vertex);
497
+ gl.attachShader(program, fragment);
498
+ gl.linkProgram(program);
499
+ gl.deleteShader(vertex);
500
+ gl.deleteShader(fragment);
501
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
502
+ const log = gl.getProgramInfoLog(program) ?? 'unknown link error';
503
+ gl.deleteProgram(program);
504
+ throw new Error(`WebGL program link failed: ${log}`);
505
+ }
506
+ return program;
507
+ }
508
+ function compileShader(gl, type, source) {
509
+ const shader = gl.createShader(type);
510
+ if (!shader)
511
+ throw new Error('Failed to create WebGLShader.');
512
+ gl.shaderSource(shader, source);
513
+ gl.compileShader(shader);
514
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
515
+ const log = gl.getShaderInfoLog(shader) ?? 'unknown shader compile error';
516
+ gl.deleteShader(shader);
517
+ throw new Error(`WebGL shader compile failed: ${log}`);
518
+ }
519
+ return shader;
520
+ }
521
+ const VERTEX_SHADER = `
522
+ attribute vec2 a_pos;
523
+ attribute vec4 a_color;
524
+ uniform vec4 u_matrix;
525
+ uniform vec4 u_viewport;
526
+ varying vec4 v_color;
527
+ void main() {
528
+ float x = u_matrix.x * a_pos.x + u_matrix.z * a_pos.y + u_viewport.x;
529
+ float y = u_matrix.y * a_pos.x + u_matrix.w * a_pos.y + u_viewport.y;
530
+ vec2 clip = vec2((x / u_viewport.z) * 2.0 - 1.0, 1.0 - (y / u_viewport.w) * 2.0);
531
+ gl_Position = vec4(clip, 0.0, 1.0);
532
+ v_color = a_color;
533
+ }
534
+ `;
535
+ const FRAGMENT_SHADER = `
536
+ precision mediump float;
537
+ varying vec4 v_color;
538
+ void main() {
539
+ gl_FragColor = v_color;
540
+ }
541
+ `;
@@ -5,16 +5,26 @@ export interface XpsRenderOptions extends CadLineStyleOptions {
5
5
  zoom?: number;
6
6
  panX?: number;
7
7
  panY?: number;
8
+ preferWebgl?: boolean;
8
9
  preferWasm?: boolean;
9
10
  wasmUrl?: string;
11
+ webglCanvas?: HTMLCanvasElement;
12
+ maxGpuCacheBytes?: number;
13
+ maxCachedScenes?: number;
10
14
  background?: string;
11
15
  }
12
16
  export declare class XpsRenderer {
13
17
  private readonly document;
14
18
  private wasm?;
19
+ private webgl?;
20
+ private webglCanvas?;
15
21
  private readonly fontCache;
22
+ private readonly xmlCache;
16
23
  constructor(document: LoadedDwfDocument);
17
24
  render(page: XpsPageData, canvas: HTMLCanvasElement, options?: XpsRenderOptions): Promise<RenderStats>;
25
+ private getWebGlBackend;
26
+ private getXmlDocument;
27
+ dispose(): void;
18
28
  private renderElementToCanvas;
19
29
  private renderElementToWasm;
20
30
  private drawGlyphs;
@@ -5,18 +5,19 @@ import { multiplyMatrix, parseBrushColor, parseMatrix } from './style.js';
5
5
  import { adaptiveStrokeUserWidth, canvasDpr, shouldDrawFilledBounds, shouldDrawTextByPixelSize } from './cadLineStyle.js';
6
6
  import { fitPageMatrix } from './viewport.js';
7
7
  import { WasmRasterBackend } from '../wasm/WasmRasterBackend.js';
8
+ import { WebGlXpsBackend } from './WebGlXpsBackend.js';
8
9
  export class XpsRenderer {
9
10
  constructor(document) {
10
11
  this.document = document;
11
12
  this.fontCache = new Map();
13
+ this.xmlCache = new Map();
12
14
  }
13
15
  async render(page, canvas, options = {}) {
14
16
  const opc = this.document.opc;
15
17
  if (!opc)
16
18
  throw new Error('XPS page requires an OPC package view.');
17
19
  const warnings = actionableDiagnostics(page.diagnostics);
18
- const xml = await opc.readText(page.sourcePath);
19
- const doc = parseXml(xml, page.sourcePath);
20
+ const doc = await this.getXmlDocument(page.sourcePath);
20
21
  const root = doc.documentElement;
21
22
  const ctx = canvas.getContext('2d');
22
23
  if (!ctx)
@@ -25,6 +26,29 @@ export class XpsRenderer {
25
26
  const runtime = { dpr: canvasDpr(canvas), zoom: options.zoom ?? 1 };
26
27
  const pageMatrix = fitPageMatrix({ canvasWidth: canvas.width, canvasHeight: canvas.height, pageWidth: page.width, pageHeight: page.height, zoom: options.zoom, panX: options.panX, panY: options.panY });
27
28
  let commands = 0;
29
+ if (options.preferWebgl ?? true) {
30
+ try {
31
+ const backend = this.getWebGlBackend(options.webglCanvas);
32
+ if (options.webglCanvas)
33
+ options.webglCanvas.style.visibility = 'visible';
34
+ const stats = backend.render(page, root, canvas, { ...options, compositeToTarget: !options.webglCanvas });
35
+ ctx.save();
36
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
37
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
38
+ ctx.restore();
39
+ commands += stats.commands;
40
+ commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: false, overlays: true }, options, runtime);
41
+ warnings.push(...stats.warnings);
42
+ return { backend: 'webgl-xps', commands, warnings };
43
+ }
44
+ catch (err) {
45
+ if (options.webglCanvas)
46
+ options.webglCanvas.style.visibility = 'hidden';
47
+ warnings.push(diag('warning', 'WEBGL_XPS_BACKEND_FALLBACK', `WebGL XPS vector path failed, falling back to ${options.preferWasm ? 'WASM raster' : 'Canvas2D'}: ${String(err)}`, page.sourcePath));
48
+ }
49
+ }
50
+ if (options.webglCanvas)
51
+ options.webglCanvas.style.visibility = 'hidden';
28
52
  if (options.preferWasm) {
29
53
  try {
30
54
  this.wasm ?? (this.wasm = new WasmRasterBackend({ wasmUrl: options.wasmUrl }));
@@ -48,6 +72,30 @@ export class XpsRenderer {
48
72
  commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: true, overlays: true }, options, runtime);
49
73
  return { backend: 'canvas2d', commands, warnings };
50
74
  }
75
+ getWebGlBackend(canvas) {
76
+ if (!this.webgl || this.webglCanvas !== canvas) {
77
+ this.webgl?.dispose();
78
+ this.webgl = new WebGlXpsBackend(canvas);
79
+ this.webglCanvas = canvas;
80
+ }
81
+ return this.webgl;
82
+ }
83
+ getXmlDocument(part) {
84
+ let cached = this.xmlCache.get(part);
85
+ if (!cached) {
86
+ const opc = this.document.opc;
87
+ cached = opc.readText(part).then(xml => parseXml(xml, part));
88
+ this.xmlCache.set(part, cached);
89
+ }
90
+ return cached;
91
+ }
92
+ dispose() {
93
+ this.webgl?.dispose();
94
+ this.webgl = undefined;
95
+ this.webglCanvas = undefined;
96
+ this.xmlCache.clear();
97
+ this.fontCache.clear();
98
+ }
51
99
  async renderElementToCanvas(el, ctx, pagePath, matrix, opacity, warnings, mode, options, runtime) {
52
100
  const name = localName(el);
53
101
  const local = elementMatrix(el);
@@ -192,8 +240,9 @@ export class XpsRenderer {
192
240
  if (!uri)
193
241
  return undefined;
194
242
  const part = resolvePart(pagePath, uri.replace(/^\//, ''));
195
- if (/\.odttf$/i.test(part))
196
- return undefined;
243
+ // XPS/DWFx often stores embedded TrueType fonts as ODTTF.
244
+ // Deobfuscate and load them so overview text matches CAD viewers instead
245
+ // of falling back to thick system fonts.
197
246
  let cached = this.fontCache.get(part);
198
247
  if (!cached) {
199
248
  cached = this.loadFontFace(part).catch(err => {
@@ -209,14 +258,26 @@ export class XpsRenderer {
209
258
  const fontSet = document.fonts;
210
259
  if (!FontFaceCtor || !fontSet)
211
260
  return undefined;
212
- const bytes = await this.document.opc.readBytes(part);
261
+ let bytes = await this.document.opc.readBytes(part);
262
+ let mime = mimeFromPath(part) ?? 'font/ttf';
263
+ if (/\.odttf$/i.test(part)) {
264
+ bytes = deobfuscateOdttf(part, bytes);
265
+ mime = 'font/ttf';
266
+ }
213
267
  const family = `dwfv_xps_${hashString(part)}`;
214
- const blob = new Blob([bytes], { type: mimeFromPath(part) ?? 'font/ttf' });
268
+ const blob = new Blob([bytes], { type: mime });
215
269
  const url = URL.createObjectURL(blob);
216
270
  const face = new FontFaceCtor(family, `url("${url}")`);
217
- await face.load();
218
- fontSet.add(face);
219
- return family;
271
+ try {
272
+ await face.load();
273
+ fontSet.add(face);
274
+ URL.revokeObjectURL(url);
275
+ return family;
276
+ }
277
+ catch (err) {
278
+ URL.revokeObjectURL(url);
279
+ throw err;
280
+ }
220
281
  }
221
282
  async drawImageResource(ctx, pagePath, source, matrix, opacity, el) {
222
283
  const opc = this.document.opc;
@@ -330,6 +391,54 @@ function drawGlyphRunWithIndices(ctx, text, indices, x, y, emSize) {
330
391
  if (charIndex < text.length)
331
392
  ctx.fillText(text.slice(charIndex), cursor, y);
332
393
  }
394
+ function deobfuscateOdttf(part, bytes) {
395
+ const name = part.split('/').pop() ?? '';
396
+ const guid = name.replace(/\.odttf$/i, '').replace(/[^0-9a-fA-F]/g, '');
397
+ if (guid.length !== 32 || bytes.length < 32)
398
+ return bytes;
399
+ const key = new Uint8Array(16);
400
+ for (let i = 0; i < 16; i++)
401
+ key[i] = parseInt(guid.slice(i * 2, i * 2 + 2), 16);
402
+ // XPS font obfuscation uses the GUID bytes in reverse order, repeated over
403
+ // the first 32 bytes of the font payload.
404
+ const out = new Uint8Array(bytes);
405
+ for (let i = 0; i < Math.min(32, out.length); i++)
406
+ out[i] = (out[i] ?? 0) ^ key[15 - (i % 16)];
407
+ if (!looksLikeSfnt(out)) {
408
+ // A few producers use the GUID binary/little-endian representation. Try it
409
+ // as a guarded fallback rather than silently returning a broken font.
410
+ const altKey = guidLittleEndianBytes(guid);
411
+ const alt = new Uint8Array(bytes);
412
+ for (let i = 0; i < Math.min(32, alt.length); i++)
413
+ alt[i] = (alt[i] ?? 0) ^ altKey[15 - (i % 16)];
414
+ if (looksLikeSfnt(alt))
415
+ return alt;
416
+ }
417
+ return out;
418
+ }
419
+ function guidLittleEndianBytes(hex) {
420
+ const b = new Uint8Array(16);
421
+ const raw = new Uint8Array(16);
422
+ for (let i = 0; i < 16; i++)
423
+ raw[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
424
+ b[0] = raw[3];
425
+ b[1] = raw[2];
426
+ b[2] = raw[1];
427
+ b[3] = raw[0];
428
+ b[4] = raw[5];
429
+ b[5] = raw[4];
430
+ b[6] = raw[7];
431
+ b[7] = raw[6];
432
+ for (let i = 8; i < 16; i++)
433
+ b[i] = raw[i];
434
+ return b;
435
+ }
436
+ function looksLikeSfnt(bytes) {
437
+ if (bytes.length < 4)
438
+ return false;
439
+ const tag = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3]);
440
+ return tag === 'OTTO' || tag === 'true' || tag === 'typ1' || tag === 'ttcf' || (bytes[0] === 0 && bytes[1] === 1 && bytes[2] === 0 && bytes[3] === 0);
441
+ }
333
442
  function hashString(s) {
334
443
  let h = 2166136261;
335
444
  for (let i = 0; i < s.length; i++) {
@@ -51,6 +51,8 @@ export declare class DwfViewer {
51
51
  private yaw;
52
52
  private pitch;
53
53
  private pendingRender?;
54
+ private rendering;
55
+ private renderAgain;
54
56
  private renderRaf;
55
57
  private renderSeq;
56
58
  private currentDpr;
@@ -10,6 +10,8 @@ export class DwfViewer {
10
10
  this.panY = 0;
11
11
  this.yaw = -0.78;
12
12
  this.pitch = 0.55;
13
+ this.rendering = false;
14
+ this.renderAgain = false;
13
15
  this.renderRaf = 0;
14
16
  this.renderSeq = 0;
15
17
  this.currentDpr = 1;
@@ -107,44 +109,58 @@ export class DwfViewer {
107
109
  async render() {
108
110
  if (!this.renderer || !this.doc)
109
111
  return undefined;
110
- this.resizeCanvasToDisplaySize();
111
- const page = this.doc.pageData[this.pageIndex];
112
- if (!page)
113
- return undefined;
114
- const seq = ++this.renderSeq;
115
- const task = this.renderer.render(this.pageIndex, this.canvas, {
116
- zoom: this.zoom,
117
- panX: this.panX,
118
- panY: this.panY,
119
- preferWebgl: this.preferWebgl,
120
- preferWasm: this.preferWasm,
121
- wasmUrl: this.wasmUrl,
122
- background: this.background,
123
- maxGpuCacheBytes: this.maxGpuCacheBytes,
124
- maxCachedScenes: this.maxCachedScenes,
125
- webglCanvas: this.webglCanvas,
126
- yaw: this.yaw,
127
- pitch: this.pitch,
128
- lineWeightMode: this.lineWeightMode,
129
- minStrokeCssPx: this.minStrokeCssPx,
130
- maxOverviewStrokeCssPx: this.maxOverviewStrokeCssPx,
131
- minTextCssPx: this.minTextCssPx,
132
- minFilledAreaCssPx: this.minFilledAreaCssPx
133
- });
134
- this.pendingRender = task;
112
+ if (this.rendering) {
113
+ this.renderAgain = true;
114
+ return this.pendingRender;
115
+ }
116
+ this.rendering = true;
135
117
  try {
136
- const stats = await task;
137
- if (this.pendingRender === task && seq === this.renderSeq) {
138
- const warnCount = stats.warnings.filter(w => w.level !== 'info').length;
139
- const dprText = this.currentDpr > 1 ? ` · DPR ${this.currentDpr.toFixed(2)}` : '';
140
- this.setStatus(`${this.doc.kind.toUpperCase()} · ${page.kind} · ${stats.backend} · ${stats.commands} ops${dprText}${warnCount ? ` · ${warnCount} 警告` : ''}`, warnCount > 0);
118
+ this.resizeCanvasToDisplaySize();
119
+ const page = this.doc.pageData[this.pageIndex];
120
+ if (!page)
121
+ return undefined;
122
+ const seq = ++this.renderSeq;
123
+ const task = this.renderer.render(this.pageIndex, this.canvas, {
124
+ zoom: this.zoom,
125
+ panX: this.panX,
126
+ panY: this.panY,
127
+ preferWebgl: this.preferWebgl,
128
+ preferWasm: this.preferWasm,
129
+ wasmUrl: this.wasmUrl,
130
+ background: this.background,
131
+ maxGpuCacheBytes: this.maxGpuCacheBytes,
132
+ maxCachedScenes: this.maxCachedScenes,
133
+ webglCanvas: this.webglCanvas,
134
+ yaw: this.yaw,
135
+ pitch: this.pitch,
136
+ lineWeightMode: this.lineWeightMode,
137
+ minStrokeCssPx: this.minStrokeCssPx,
138
+ maxOverviewStrokeCssPx: this.maxOverviewStrokeCssPx,
139
+ minTextCssPx: this.minTextCssPx,
140
+ minFilledAreaCssPx: this.minFilledAreaCssPx
141
+ });
142
+ this.pendingRender = task;
143
+ try {
144
+ const stats = await task;
145
+ if (this.pendingRender === task && seq === this.renderSeq) {
146
+ const warnCount = stats.warnings.filter(w => w.level !== 'info').length;
147
+ const dprText = this.currentDpr > 1 ? ` · DPR ${this.currentDpr.toFixed(2)}` : '';
148
+ this.setStatus(`${this.doc.kind.toUpperCase()} · ${page.kind} · ${stats.backend} · ${stats.commands} ops${dprText}${warnCount ? ` · ${warnCount} 警告` : ''}`, warnCount > 0);
149
+ }
150
+ return stats;
151
+ }
152
+ catch (err) {
153
+ if (seq === this.renderSeq)
154
+ this.setStatus(`渲染失败:${String(err)}`, true);
155
+ throw err;
141
156
  }
142
- return stats;
143
157
  }
144
- catch (err) {
145
- if (seq === this.renderSeq)
146
- this.setStatus(`渲染失败:${String(err)}`, true);
147
- throw err;
158
+ finally {
159
+ this.rendering = false;
160
+ if (this.renderAgain) {
161
+ this.renderAgain = false;
162
+ this.requestRender();
163
+ }
148
164
  }
149
165
  }
150
166
  getDocument() {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@flyfish-dev/dwf-viewer",
3
- "version": "0.5.1",
3
+ "version": "0.6.1",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "Pure frontend DWF/DWFx viewer with W2D WebGL rendering, W3D/HSF Three.js 3D rendering, DWFx/XPS support, and optional WASM raster fallback.",
6
+ "description": "Pure frontend DWF/DWFx CAD viewer with WebGL-accelerated XPS/W2D 2D rendering, W3D/HSF 3D rendering, embedded XPS fonts, and optional WASM fallback.",
7
7
  "license": "AGPL-3.0-only",
8
8
  "author": "flyfish-dev",
9
9
  "homepage": "https://github.com/flyfish-dev/dwf-viewer#readme",
@@ -24,6 +24,7 @@
24
24
  "cad",
25
25
  "lineweight",
26
26
  "line-width",
27
+ "xps-webgl",
27
28
  "viewer",
28
29
  "webgl",
29
30
  "threejs",