@flyfish-dev/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.
@@ -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 xml = await opc.readText(page.sourcePath);
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
- async renderElementToCanvas(el, ctx, pagePath, matrix, opacity, warnings, mode) {
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
- if (fill) {
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
- ctx.save();
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, thickness * composed.a);
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;
@@ -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.resizeCanvasToDisplaySize();
96
- const page = this.doc.pageData[this.pageIndex];
97
- if (!page)
98
- return undefined;
99
- const seq = ++this.renderSeq;
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
- const stats = await task;
117
- if (this.pendingRender === task && seq === this.renderSeq) {
118
- const warnCount = stats.warnings.filter(w => w.level !== 'info').length;
119
- const dprText = this.currentDpr > 1 ? ` · DPR ${this.currentDpr.toFixed(2)}` : '';
120
- 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;
121
156
  }
122
- return stats;
123
157
  }
124
- catch (err) {
125
- if (seq === this.renderSeq)
126
- this.setStatus(`渲染失败:${String(err)}`, true);
127
- throw err;
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() {