dwf-viewer 0.5.0 → 0.6.0
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 +21 -6
- package/PRODUCTION_3D_NOTES.md +4 -0
- package/README.md +101 -36
- package/dist/format/types.d.ts +6 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/render/PageRenderer.d.ts +5 -0
- package/dist/render/PageRenderer.js +1 -0
- package/dist/render/W2dRenderer.d.ts +2 -1
- package/dist/render/W2dRenderer.js +16 -13
- package/dist/render/WebGlW2dBackend.d.ts +2 -1
- package/dist/render/WebGlW2dBackend.js +14 -9
- package/dist/render/WebGlXpsBackend.d.ts +38 -0
- package/dist/render/WebGlXpsBackend.js +541 -0
- package/dist/render/XpsRenderer.d.ts +16 -1
- package/dist/render/XpsRenderer.js +270 -25
- package/dist/render/cadLineStyle.d.ts +32 -0
- package/dist/render/cadLineStyle.js +59 -0
- package/dist/viewer/DwfViewer.d.ts +13 -0
- package/dist/viewer/DwfViewer.js +66 -30
- package/package.json +6 -3
|
@@ -2,35 +2,62 @@ import { actionableDiagnostics, diag } from '../format/types.js';
|
|
|
2
2
|
import { childElements, getAttr, localName, parseNumberList, parseXml, resolvePart, blobToImage, mimeFromPath } from '../format/util.js';
|
|
3
3
|
import { applyPathToCanvas, flattenPath, parsePathData } from './xpsPath.js';
|
|
4
4
|
import { multiplyMatrix, parseBrushColor, parseMatrix } from './style.js';
|
|
5
|
+
import { adaptiveStrokeUserWidth, canvasDpr, shouldDrawFilledBounds, shouldDrawTextByPixelSize } from './cadLineStyle.js';
|
|
5
6
|
import { fitPageMatrix } from './viewport.js';
|
|
6
7
|
import { WasmRasterBackend } from '../wasm/WasmRasterBackend.js';
|
|
8
|
+
import { WebGlXpsBackend } from './WebGlXpsBackend.js';
|
|
7
9
|
export class XpsRenderer {
|
|
8
10
|
constructor(document) {
|
|
9
11
|
this.document = document;
|
|
12
|
+
this.fontCache = new Map();
|
|
13
|
+
this.xmlCache = new Map();
|
|
10
14
|
}
|
|
11
15
|
async render(page, canvas, options = {}) {
|
|
12
16
|
const opc = this.document.opc;
|
|
13
17
|
if (!opc)
|
|
14
18
|
throw new Error('XPS page requires an OPC package view.');
|
|
15
19
|
const warnings = actionableDiagnostics(page.diagnostics);
|
|
16
|
-
const
|
|
17
|
-
const doc = parseXml(xml, page.sourcePath);
|
|
20
|
+
const doc = await this.getXmlDocument(page.sourcePath);
|
|
18
21
|
const root = doc.documentElement;
|
|
19
22
|
const ctx = canvas.getContext('2d');
|
|
20
23
|
if (!ctx)
|
|
21
24
|
throw new Error('CanvasRenderingContext2D is not available.');
|
|
22
25
|
const bg = options.background ?? '#ffffff';
|
|
26
|
+
const runtime = { dpr: canvasDpr(canvas), zoom: options.zoom ?? 1 };
|
|
23
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 });
|
|
24
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';
|
|
25
52
|
if (options.preferWasm) {
|
|
26
53
|
try {
|
|
27
54
|
this.wasm ?? (this.wasm = new WasmRasterBackend({ wasmUrl: options.wasmUrl }));
|
|
28
55
|
await this.wasm.init();
|
|
29
56
|
this.wasm.begin(canvas.width, canvas.height, bg);
|
|
30
|
-
commands += this.renderElementToWasm(root, pageMatrix, 1, warnings);
|
|
57
|
+
commands += this.renderElementToWasm(root, pageMatrix, 1, warnings, options, runtime);
|
|
31
58
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
32
59
|
ctx.putImageData(this.wasm.toImageData(), 0, 0);
|
|
33
|
-
commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: false, overlays: true });
|
|
60
|
+
commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: false, overlays: true }, options, runtime);
|
|
34
61
|
return { backend: 'wasm-raster', commands, warnings };
|
|
35
62
|
}
|
|
36
63
|
catch (err) {
|
|
@@ -42,10 +69,34 @@ export class XpsRenderer {
|
|
|
42
69
|
ctx.fillStyle = bg;
|
|
43
70
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
44
71
|
ctx.restore();
|
|
45
|
-
commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: true, overlays: true });
|
|
72
|
+
commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: true, overlays: true }, options, runtime);
|
|
46
73
|
return { backend: 'canvas2d', commands, warnings };
|
|
47
74
|
}
|
|
48
|
-
|
|
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
|
+
}
|
|
99
|
+
async renderElementToCanvas(el, ctx, pagePath, matrix, opacity, warnings, mode, options, runtime) {
|
|
49
100
|
const name = localName(el);
|
|
50
101
|
const local = elementMatrix(el);
|
|
51
102
|
const composed = multiplyMatrix(matrix, local);
|
|
@@ -68,13 +119,15 @@ export class XpsRenderer {
|
|
|
68
119
|
const fill = extractBrush(el, 'Fill', ownOpacity);
|
|
69
120
|
const stroke = extractBrush(el, 'Stroke', ownOpacity);
|
|
70
121
|
const thickness = Number(getAttr(el, 'StrokeThickness') ?? 1);
|
|
71
|
-
|
|
122
|
+
const bounds = pathBounds(path);
|
|
123
|
+
if (fill && shouldDrawFilledBounds(bounds, composed, options, runtime)) {
|
|
72
124
|
ctx.fillStyle = fill;
|
|
73
125
|
ctx.fill(fillRule(el));
|
|
74
126
|
}
|
|
75
127
|
if (stroke && thickness > 0) {
|
|
76
128
|
ctx.strokeStyle = stroke;
|
|
77
|
-
ctx.lineWidth = thickness;
|
|
129
|
+
ctx.lineWidth = adaptiveStrokeUserWidth(thickness, composed, options, runtime);
|
|
130
|
+
applyStrokeStyle(ctx, el, ctx.lineWidth);
|
|
78
131
|
ctx.stroke();
|
|
79
132
|
}
|
|
80
133
|
ctx.restore();
|
|
@@ -82,19 +135,7 @@ export class XpsRenderer {
|
|
|
82
135
|
}
|
|
83
136
|
}
|
|
84
137
|
else if (name === 'Glyphs' && mode.overlays) {
|
|
85
|
-
|
|
86
|
-
ctx.setTransform(composed.a, composed.b, composed.c, composed.d, composed.e, composed.f);
|
|
87
|
-
ctx.globalAlpha = ownOpacity;
|
|
88
|
-
const text = getAttr(el, 'UnicodeString') ?? '';
|
|
89
|
-
const x = Number(getAttr(el, 'OriginX') ?? 0);
|
|
90
|
-
const y = Number(getAttr(el, 'OriginY') ?? 0);
|
|
91
|
-
const size = Number(getAttr(el, 'FontRenderingEmSize') ?? 12);
|
|
92
|
-
const fill = extractBrush(el, 'Fill', ownOpacity) ?? '#000000';
|
|
93
|
-
ctx.fillStyle = fill;
|
|
94
|
-
ctx.font = `${size}px sans-serif`;
|
|
95
|
-
ctx.fillText(text, x, y);
|
|
96
|
-
ctx.restore();
|
|
97
|
-
commands++;
|
|
138
|
+
commands += await this.drawGlyphs(ctx, pagePath, el, composed, ownOpacity, warnings, options, runtime);
|
|
98
139
|
}
|
|
99
140
|
else if (name === 'Image' && mode.overlays) {
|
|
100
141
|
const source = getAttr(el, 'Source') ?? getAttr(el, 'ImageSource');
|
|
@@ -129,11 +170,11 @@ export class XpsRenderer {
|
|
|
129
170
|
const childName = localName(child);
|
|
130
171
|
if (childName.includes('.'))
|
|
131
172
|
continue;
|
|
132
|
-
commands += await this.renderElementToCanvas(child, ctx, pagePath, composed, ownOpacity, warnings, mode);
|
|
173
|
+
commands += await this.renderElementToCanvas(child, ctx, pagePath, composed, ownOpacity, warnings, mode, options, runtime);
|
|
133
174
|
}
|
|
134
175
|
return commands;
|
|
135
176
|
}
|
|
136
|
-
renderElementToWasm(el, matrix, opacity, warnings) {
|
|
177
|
+
renderElementToWasm(el, matrix, opacity, warnings, options, runtime) {
|
|
137
178
|
if (!this.wasm)
|
|
138
179
|
return 0;
|
|
139
180
|
const name = localName(el);
|
|
@@ -147,6 +188,7 @@ export class XpsRenderer {
|
|
|
147
188
|
const fill = extractBrush(el, 'Fill', ownOpacity);
|
|
148
189
|
const stroke = extractBrush(el, 'Stroke', ownOpacity);
|
|
149
190
|
const thickness = Number(getAttr(el, 'StrokeThickness') ?? 1);
|
|
191
|
+
const screenThickness = adaptiveStrokeUserWidth(thickness, composed, options, runtime) * Math.max(1e-12, Math.hypot(composed.a, composed.b));
|
|
150
192
|
const subs = flattenPath(path, 0.5);
|
|
151
193
|
if (fill) {
|
|
152
194
|
for (const sub of subs)
|
|
@@ -155,7 +197,7 @@ export class XpsRenderer {
|
|
|
155
197
|
}
|
|
156
198
|
if (stroke && thickness > 0) {
|
|
157
199
|
for (const sub of subs)
|
|
158
|
-
this.wasm.drawPolyline(sub.points, composed, stroke,
|
|
200
|
+
this.wasm.drawPolyline(sub.points, composed, stroke, screenThickness);
|
|
159
201
|
}
|
|
160
202
|
commands++;
|
|
161
203
|
}
|
|
@@ -164,10 +206,79 @@ export class XpsRenderer {
|
|
|
164
206
|
const childName = localName(child);
|
|
165
207
|
if (childName.includes('.'))
|
|
166
208
|
continue;
|
|
167
|
-
commands += this.renderElementToWasm(child, composed, ownOpacity, warnings);
|
|
209
|
+
commands += this.renderElementToWasm(child, composed, ownOpacity, warnings, options, runtime);
|
|
168
210
|
}
|
|
169
211
|
return commands;
|
|
170
212
|
}
|
|
213
|
+
async drawGlyphs(ctx, pagePath, el, matrix, opacity, warnings, options, runtime) {
|
|
214
|
+
const text = getAttr(el, 'UnicodeString') ?? '';
|
|
215
|
+
if (!text)
|
|
216
|
+
return 0;
|
|
217
|
+
const x = Number(getAttr(el, 'OriginX') ?? 0);
|
|
218
|
+
const y = Number(getAttr(el, 'OriginY') ?? 0);
|
|
219
|
+
const size = Number(getAttr(el, 'FontRenderingEmSize') ?? 12);
|
|
220
|
+
if (!shouldDrawTextByPixelSize(size, matrix, options, runtime))
|
|
221
|
+
return 0;
|
|
222
|
+
const fill = extractBrush(el, 'Fill', opacity) ?? '#000000';
|
|
223
|
+
const family = await this.fontFamilyForGlyphs(pagePath, el, warnings) ?? 'sans-serif';
|
|
224
|
+
ctx.save();
|
|
225
|
+
ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
|
|
226
|
+
ctx.globalAlpha = opacity;
|
|
227
|
+
ctx.fillStyle = fill;
|
|
228
|
+
ctx.font = `${size}px "${family}"`;
|
|
229
|
+
ctx.textBaseline = 'alphabetic';
|
|
230
|
+
const indices = getAttr(el, 'Indices');
|
|
231
|
+
if (indices)
|
|
232
|
+
drawGlyphRunWithIndices(ctx, text, indices, x, y, size);
|
|
233
|
+
else
|
|
234
|
+
ctx.fillText(text, x, y);
|
|
235
|
+
ctx.restore();
|
|
236
|
+
return 1;
|
|
237
|
+
}
|
|
238
|
+
async fontFamilyForGlyphs(pagePath, el, warnings) {
|
|
239
|
+
const uri = getAttr(el, 'FontUri');
|
|
240
|
+
if (!uri)
|
|
241
|
+
return undefined;
|
|
242
|
+
const part = resolvePart(pagePath, uri.replace(/^\//, ''));
|
|
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.
|
|
246
|
+
let cached = this.fontCache.get(part);
|
|
247
|
+
if (!cached) {
|
|
248
|
+
cached = this.loadFontFace(part).catch(err => {
|
|
249
|
+
warnings.push(diag('warning', 'XPS_FONT_LOAD_FAILED', `Failed to load embedded XPS font ${part}: ${String(err)}`, pagePath));
|
|
250
|
+
return undefined;
|
|
251
|
+
});
|
|
252
|
+
this.fontCache.set(part, cached);
|
|
253
|
+
}
|
|
254
|
+
return cached;
|
|
255
|
+
}
|
|
256
|
+
async loadFontFace(part) {
|
|
257
|
+
const FontFaceCtor = globalThis.FontFace;
|
|
258
|
+
const fontSet = document.fonts;
|
|
259
|
+
if (!FontFaceCtor || !fontSet)
|
|
260
|
+
return undefined;
|
|
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
|
+
}
|
|
267
|
+
const family = `dwfv_xps_${hashString(part)}`;
|
|
268
|
+
const blob = new Blob([bytes], { type: mime });
|
|
269
|
+
const url = URL.createObjectURL(blob);
|
|
270
|
+
const face = new FontFaceCtor(family, `url("${url}")`);
|
|
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
|
+
}
|
|
281
|
+
}
|
|
171
282
|
async drawImageResource(ctx, pagePath, source, matrix, opacity, el) {
|
|
172
283
|
const opc = this.document.opc;
|
|
173
284
|
const src = resolvePart(pagePath, source.replace(/^\//, ''));
|
|
@@ -202,6 +313,140 @@ export class XpsRenderer {
|
|
|
202
313
|
ctx.restore();
|
|
203
314
|
}
|
|
204
315
|
}
|
|
316
|
+
function applyStrokeStyle(ctx, el, userLineWidth) {
|
|
317
|
+
const start = (getAttr(el, 'StrokeStartLineCap') ?? '').toLowerCase();
|
|
318
|
+
const end = (getAttr(el, 'StrokeEndLineCap') ?? '').toLowerCase();
|
|
319
|
+
const dashCap = (getAttr(el, 'StrokeDashCap') ?? '').toLowerCase();
|
|
320
|
+
if (start === 'round' || end === 'round' || dashCap === 'round')
|
|
321
|
+
ctx.lineCap = 'round';
|
|
322
|
+
else if (start === 'square' || end === 'square' || dashCap === 'square')
|
|
323
|
+
ctx.lineCap = 'square';
|
|
324
|
+
else
|
|
325
|
+
ctx.lineCap = 'butt';
|
|
326
|
+
const join = (getAttr(el, 'StrokeLineJoin') ?? '').toLowerCase();
|
|
327
|
+
ctx.lineJoin = join === 'round' ? 'round' : join === 'bevel' ? 'bevel' : 'miter';
|
|
328
|
+
const miter = Number(getAttr(el, 'StrokeMiterLimit') ?? 10);
|
|
329
|
+
if (Number.isFinite(miter) && miter > 0)
|
|
330
|
+
ctx.miterLimit = miter;
|
|
331
|
+
const dash = parseNumberList(getAttr(el, 'StrokeDashArray') ?? '');
|
|
332
|
+
if (dash.length > 0) {
|
|
333
|
+
const offset = Number(getAttr(el, 'StrokeDashOffset') ?? 0);
|
|
334
|
+
ctx.setLineDash(dash.map(v => Math.max(0, v * userLineWidth)));
|
|
335
|
+
ctx.lineDashOffset = Number.isFinite(offset) ? offset * userLineWidth : 0;
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
ctx.setLineDash([]);
|
|
339
|
+
ctx.lineDashOffset = 0;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function pathBounds(commands) {
|
|
343
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
344
|
+
const add = (x, y) => {
|
|
345
|
+
if (!Number.isFinite(x) || !Number.isFinite(y))
|
|
346
|
+
return;
|
|
347
|
+
minX = Math.min(minX, x);
|
|
348
|
+
minY = Math.min(minY, y);
|
|
349
|
+
maxX = Math.max(maxX, x);
|
|
350
|
+
maxY = Math.max(maxY, y);
|
|
351
|
+
};
|
|
352
|
+
for (const c of commands) {
|
|
353
|
+
if (c.type === 'M' || c.type === 'L')
|
|
354
|
+
add(c.x, c.y);
|
|
355
|
+
else if (c.type === 'C') {
|
|
356
|
+
add(c.x1, c.y1);
|
|
357
|
+
add(c.x2, c.y2);
|
|
358
|
+
add(c.x, c.y);
|
|
359
|
+
}
|
|
360
|
+
else if (c.type === 'Q') {
|
|
361
|
+
add(c.x1, c.y1);
|
|
362
|
+
add(c.x, c.y);
|
|
363
|
+
}
|
|
364
|
+
else if (c.type === 'A') {
|
|
365
|
+
add(c.x - c.rx, c.y - c.ry);
|
|
366
|
+
add(c.x + c.rx, c.y + c.ry);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : undefined;
|
|
370
|
+
}
|
|
371
|
+
function drawGlyphRunWithIndices(ctx, text, indices, x, y, emSize) {
|
|
372
|
+
const specs = indices.split(';');
|
|
373
|
+
let cursor = x;
|
|
374
|
+
let charIndex = 0;
|
|
375
|
+
for (const spec of specs) {
|
|
376
|
+
const raw = spec.trim();
|
|
377
|
+
if (!raw)
|
|
378
|
+
continue;
|
|
379
|
+
const parts = raw.split(',');
|
|
380
|
+
const advance = Number(parts[1] ?? '');
|
|
381
|
+
const dx = Number(parts[3] ?? 0);
|
|
382
|
+
const dy = Number(parts[4] ?? 0);
|
|
383
|
+
const ch = text[charIndex++] ?? '';
|
|
384
|
+
if (ch)
|
|
385
|
+
ctx.fillText(ch, cursor + (Number.isFinite(dx) ? dx : 0), y + (Number.isFinite(dy) ? dy : 0));
|
|
386
|
+
if (Number.isFinite(advance) && advance > 0)
|
|
387
|
+
cursor += advance * emSize / 100;
|
|
388
|
+
else
|
|
389
|
+
cursor += ctx.measureText(ch || ' ').width;
|
|
390
|
+
}
|
|
391
|
+
if (charIndex < text.length)
|
|
392
|
+
ctx.fillText(text.slice(charIndex), cursor, y);
|
|
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
|
+
}
|
|
442
|
+
function hashString(s) {
|
|
443
|
+
let h = 2166136261;
|
|
444
|
+
for (let i = 0; i < s.length; i++) {
|
|
445
|
+
h ^= s.charCodeAt(i);
|
|
446
|
+
h = Math.imul(h, 16777619);
|
|
447
|
+
}
|
|
448
|
+
return (h >>> 0).toString(36);
|
|
449
|
+
}
|
|
205
450
|
function elementMatrix(el) {
|
|
206
451
|
let m = parseMatrix(getAttr(el, 'RenderTransform') ?? getAttr(el, 'Transform'));
|
|
207
452
|
for (const child of childElements(el)) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Matrix2D } from './style.js';
|
|
2
|
+
export type CadLineWeightMode = 'adaptive' | 'physical' | 'hairline';
|
|
3
|
+
export interface CadLineStyleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* adaptive: CAD-viewer style thin-line overview with physical line weights returning while zooming.
|
|
6
|
+
* physical: preserve source line width exactly.
|
|
7
|
+
* hairline: draw all strokes as one CSS-pixel hairlines.
|
|
8
|
+
*/
|
|
9
|
+
lineWeightMode?: CadLineWeightMode;
|
|
10
|
+
/** Minimum visible stroke width, in CSS pixels. */
|
|
11
|
+
minStrokeCssPx?: number;
|
|
12
|
+
/** Maximum stroke width used around fit-to-page / low zoom, in CSS pixels. */
|
|
13
|
+
maxOverviewStrokeCssPx?: number;
|
|
14
|
+
/** Ignore text smaller than this CSS-pixel height to avoid black blobs in overview. */
|
|
15
|
+
minTextCssPx?: number;
|
|
16
|
+
/** Ignore filled shapes below this CSS-pixel area in adaptive mode. */
|
|
17
|
+
minFilledAreaCssPx?: number;
|
|
18
|
+
}
|
|
19
|
+
export interface CadStrokeRuntime {
|
|
20
|
+
dpr: number;
|
|
21
|
+
zoom?: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function estimateMatrixScale(m: Matrix2D): number;
|
|
24
|
+
export declare function canvasDpr(canvas: HTMLCanvasElement): number;
|
|
25
|
+
export declare function adaptiveStrokeUserWidth(sourceWidth: number, matrix: Matrix2D, opts: CadLineStyleOptions | undefined, runtime: CadStrokeRuntime): number;
|
|
26
|
+
export declare function shouldDrawTextByPixelSize(fontSizeSourceUnits: number, matrix: Matrix2D, opts: CadLineStyleOptions | undefined, runtime: CadStrokeRuntime): boolean;
|
|
27
|
+
export declare function shouldDrawFilledBounds(bounds: {
|
|
28
|
+
minX: number;
|
|
29
|
+
minY: number;
|
|
30
|
+
maxX: number;
|
|
31
|
+
maxY: number;
|
|
32
|
+
} | undefined, matrix: Matrix2D, opts: CadLineStyleOptions | undefined, runtime: CadStrokeRuntime): boolean;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function estimateMatrixScale(m) {
|
|
2
|
+
const sx = Math.hypot(m.a, m.b);
|
|
3
|
+
const sy = Math.hypot(m.c, m.d);
|
|
4
|
+
return Math.max(1e-12, (sx + sy) * 0.5);
|
|
5
|
+
}
|
|
6
|
+
export function canvasDpr(canvas) {
|
|
7
|
+
const rect = canvas.getBoundingClientRect?.();
|
|
8
|
+
const cssW = rect && rect.width > 0 ? rect.width : Number(canvas.style.width?.replace('px', '')) || canvas.width;
|
|
9
|
+
return Math.max(1, canvas.width / Math.max(1, cssW));
|
|
10
|
+
}
|
|
11
|
+
export function adaptiveStrokeUserWidth(sourceWidth, matrix, opts = {}, runtime) {
|
|
12
|
+
const scale = estimateMatrixScale(matrix);
|
|
13
|
+
const physicalDevicePx = Math.max(0, Math.abs(sourceWidth || 0) * scale);
|
|
14
|
+
const mode = opts.lineWeightMode ?? 'adaptive';
|
|
15
|
+
if (mode === 'physical')
|
|
16
|
+
return Math.max(1e-6, Math.abs(sourceWidth || 1));
|
|
17
|
+
const dpr = Math.max(1, runtime.dpr || 1);
|
|
18
|
+
const minDevicePx = Math.max(0.25, (opts.minStrokeCssPx ?? 0.55) * dpr);
|
|
19
|
+
if (mode === 'hairline')
|
|
20
|
+
return minDevicePx / scale;
|
|
21
|
+
const zoom = Math.max(0.05, runtime.zoom ?? 1);
|
|
22
|
+
const overviewMaxCss = opts.maxOverviewStrokeCssPx ?? 1.15;
|
|
23
|
+
// CAD drawing viewers typically keep overview lines as screen-space hairlines,
|
|
24
|
+
// then allow line weights to grow as the user zooms in. The cap grows with
|
|
25
|
+
// zoom, but sub-line details never exceed a readable overview width.
|
|
26
|
+
const adaptiveMaxCss = zoom <= 1
|
|
27
|
+
? overviewMaxCss
|
|
28
|
+
: Math.min(24, overviewMaxCss * (1 + Math.log2(zoom) * 1.6));
|
|
29
|
+
const maxDevicePx = Math.max(minDevicePx, adaptiveMaxCss * dpr);
|
|
30
|
+
const targetDevicePx = Math.max(minDevicePx, Math.min(physicalDevicePx, maxDevicePx));
|
|
31
|
+
return targetDevicePx / scale;
|
|
32
|
+
}
|
|
33
|
+
export function shouldDrawTextByPixelSize(fontSizeSourceUnits, matrix, opts = {}, runtime) {
|
|
34
|
+
if ((opts.lineWeightMode ?? 'adaptive') === 'physical')
|
|
35
|
+
return true;
|
|
36
|
+
const dpr = Math.max(1, runtime.dpr || 1);
|
|
37
|
+
const minCss = opts.minTextCssPx ?? 3.5;
|
|
38
|
+
const devicePx = Math.abs(fontSizeSourceUnits * estimateMatrixScale(matrix));
|
|
39
|
+
return devicePx >= minCss * dpr;
|
|
40
|
+
}
|
|
41
|
+
export function shouldDrawFilledBounds(bounds, matrix, opts = {}, runtime) {
|
|
42
|
+
if (!bounds || (opts.lineWeightMode ?? 'adaptive') === 'physical')
|
|
43
|
+
return true;
|
|
44
|
+
const dpr = Math.max(1, runtime.dpr || 1);
|
|
45
|
+
const minArea = Math.max(0, opts.minFilledAreaCssPx ?? 0.12) * dpr * dpr;
|
|
46
|
+
if (minArea <= 0)
|
|
47
|
+
return true;
|
|
48
|
+
const p1 = transform(matrix, bounds.minX, bounds.minY);
|
|
49
|
+
const p2 = transform(matrix, bounds.maxX, bounds.minY);
|
|
50
|
+
const p3 = transform(matrix, bounds.maxX, bounds.maxY);
|
|
51
|
+
const p4 = transform(matrix, bounds.minX, bounds.maxY);
|
|
52
|
+
const xs = [p1[0], p2[0], p3[0], p4[0]];
|
|
53
|
+
const ys = [p1[1], p2[1], p3[1], p4[1]];
|
|
54
|
+
const area = (Math.max(...xs) - Math.min(...xs)) * (Math.max(...ys) - Math.min(...ys));
|
|
55
|
+
return area >= minArea;
|
|
56
|
+
}
|
|
57
|
+
function transform(m, x, y) {
|
|
58
|
+
return [m.a * x + m.c * y + m.e, m.b * x + m.d * y + m.f];
|
|
59
|
+
}
|
|
@@ -2,6 +2,11 @@ import type { LoadedDwfDocument } from '../format/document.js';
|
|
|
2
2
|
import type { PageRenderOptions, RenderStats } from '../format/types.js';
|
|
3
3
|
export interface DwfViewerOptions {
|
|
4
4
|
wasmUrl?: string;
|
|
5
|
+
lineWeightMode?: 'adaptive' | 'physical' | 'hairline';
|
|
6
|
+
minStrokeCssPx?: number;
|
|
7
|
+
maxOverviewStrokeCssPx?: number;
|
|
8
|
+
minTextCssPx?: number;
|
|
9
|
+
minFilledAreaCssPx?: number;
|
|
5
10
|
preferWebgl?: boolean;
|
|
6
11
|
preferWasm?: boolean;
|
|
7
12
|
background?: string;
|
|
@@ -37,16 +42,24 @@ export declare class DwfViewer {
|
|
|
37
42
|
private maxCanvasPixels;
|
|
38
43
|
private maxGpuCacheBytes?;
|
|
39
44
|
private maxCachedScenes?;
|
|
45
|
+
private lineWeightMode;
|
|
46
|
+
private minStrokeCssPx?;
|
|
47
|
+
private maxOverviewStrokeCssPx?;
|
|
48
|
+
private minTextCssPx?;
|
|
49
|
+
private minFilledAreaCssPx?;
|
|
40
50
|
private drag?;
|
|
41
51
|
private yaw;
|
|
42
52
|
private pitch;
|
|
43
53
|
private pendingRender?;
|
|
54
|
+
private rendering;
|
|
55
|
+
private renderAgain;
|
|
44
56
|
private renderRaf;
|
|
45
57
|
private renderSeq;
|
|
46
58
|
private currentDpr;
|
|
47
59
|
constructor(container: HTMLElement, options?: DwfViewerOptions);
|
|
48
60
|
setPreferWebgl(value: boolean): void;
|
|
49
61
|
setPreferWasm(value: boolean): void;
|
|
62
|
+
setLineWeightMode(value: 'adaptive' | 'physical' | 'hairline'): void;
|
|
50
63
|
load(input: ArrayBuffer | Uint8Array | Blob | File, options?: LoadOptions): Promise<void>;
|
|
51
64
|
render(): Promise<RenderStats | undefined>;
|
|
52
65
|
getDocument(): LoadedDwfDocument | undefined;
|
package/dist/viewer/DwfViewer.js
CHANGED
|
@@ -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;
|
|
@@ -21,6 +23,11 @@ export class DwfViewer {
|
|
|
21
23
|
this.maxCanvasPixels = options.maxCanvasPixels ?? 16777216;
|
|
22
24
|
this.maxGpuCacheBytes = options.maxGpuCacheBytes;
|
|
23
25
|
this.maxCachedScenes = options.maxCachedScenes;
|
|
26
|
+
this.lineWeightMode = options.lineWeightMode ?? 'adaptive';
|
|
27
|
+
this.minStrokeCssPx = options.minStrokeCssPx;
|
|
28
|
+
this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx;
|
|
29
|
+
this.minTextCssPx = options.minTextCssPx;
|
|
30
|
+
this.minFilledAreaCssPx = options.minFilledAreaCssPx;
|
|
24
31
|
this.root = document.createElement('div');
|
|
25
32
|
this.root.className = 'dwfv-root';
|
|
26
33
|
const toolbar = document.createElement('div');
|
|
@@ -69,6 +76,11 @@ export class DwfViewer {
|
|
|
69
76
|
this.preferWasm = value;
|
|
70
77
|
this.requestRender();
|
|
71
78
|
}
|
|
79
|
+
setLineWeightMode(value) {
|
|
80
|
+
this.lineWeightMode = value;
|
|
81
|
+
this.renderer?.dispose();
|
|
82
|
+
this.requestRender();
|
|
83
|
+
}
|
|
72
84
|
async load(input, options = {}) {
|
|
73
85
|
this.setStatus('解析文件中…');
|
|
74
86
|
this.renderer?.dispose();
|
|
@@ -85,6 +97,11 @@ export class DwfViewer {
|
|
|
85
97
|
this.background = options.background ?? this.background;
|
|
86
98
|
this.maxGpuCacheBytes = options.maxGpuCacheBytes ?? this.maxGpuCacheBytes;
|
|
87
99
|
this.maxCachedScenes = options.maxCachedScenes ?? this.maxCachedScenes;
|
|
100
|
+
this.lineWeightMode = options.lineWeightMode ?? this.lineWeightMode;
|
|
101
|
+
this.minStrokeCssPx = options.minStrokeCssPx ?? this.minStrokeCssPx;
|
|
102
|
+
this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx ?? this.maxOverviewStrokeCssPx;
|
|
103
|
+
this.minTextCssPx = options.minTextCssPx ?? this.minTextCssPx;
|
|
104
|
+
this.minFilledAreaCssPx = options.minFilledAreaCssPx ?? this.minFilledAreaCssPx;
|
|
88
105
|
this.populatePages();
|
|
89
106
|
this.populateModelTree();
|
|
90
107
|
await this.render();
|
|
@@ -92,39 +109,58 @@ export class DwfViewer {
|
|
|
92
109
|
async render() {
|
|
93
110
|
if (!this.renderer || !this.doc)
|
|
94
111
|
return undefined;
|
|
95
|
-
this.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const task = this.renderer.render(this.pageIndex, this.canvas, {
|
|
101
|
-
zoom: this.zoom,
|
|
102
|
-
panX: this.panX,
|
|
103
|
-
panY: this.panY,
|
|
104
|
-
preferWebgl: this.preferWebgl,
|
|
105
|
-
preferWasm: this.preferWasm,
|
|
106
|
-
wasmUrl: this.wasmUrl,
|
|
107
|
-
background: this.background,
|
|
108
|
-
maxGpuCacheBytes: this.maxGpuCacheBytes,
|
|
109
|
-
maxCachedScenes: this.maxCachedScenes,
|
|
110
|
-
webglCanvas: this.webglCanvas,
|
|
111
|
-
yaw: this.yaw,
|
|
112
|
-
pitch: this.pitch
|
|
113
|
-
});
|
|
114
|
-
this.pendingRender = task;
|
|
112
|
+
if (this.rendering) {
|
|
113
|
+
this.renderAgain = true;
|
|
114
|
+
return this.pendingRender;
|
|
115
|
+
}
|
|
116
|
+
this.rendering = true;
|
|
115
117
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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;
|
|
121
156
|
}
|
|
122
|
-
return stats;
|
|
123
157
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
158
|
+
finally {
|
|
159
|
+
this.rendering = false;
|
|
160
|
+
if (this.renderAgain) {
|
|
161
|
+
this.renderAgain = false;
|
|
162
|
+
this.requestRender();
|
|
163
|
+
}
|
|
128
164
|
}
|
|
129
165
|
}
|
|
130
166
|
getDocument() {
|