dwf-viewer 0.5.0 → 0.5.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,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.1
4
+
5
+ - Published as `dwf-viewer` and `@flyfish-dev/dwf-viewer` with AGPL-3.0-only package metadata.
6
+ - 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.
8
+ - Added embedded XPS TrueType font loading for Glyphs when browsers support the FontFace API.
9
+ - Added demo line-weight mode selector: CAD adaptive, hairline, physical.
10
+
3
11
  ## 0.5.0
4
12
 
5
13
  - Prepared repository for public npm release and Cloudflare Pages demo deployment.
package/README.md CHANGED
@@ -6,6 +6,16 @@ Pure frontend DWF/DWFx viewer for browsers. It parses DWF/DWFx packages locally
6
6
 
7
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`.
8
8
 
9
+ ## Links
10
+
11
+ | Target | URL |
12
+ |---|---|
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 |
18
+
9
19
  Supported paths:
10
20
 
11
21
  | Format / content | Status |
@@ -44,7 +54,11 @@ const viewer = new DwfViewer(document.getElementById('viewer')!, {
44
54
  maxDevicePixelRatio: 2,
45
55
  maxCanvasPixels: 16_777_216,
46
56
  maxGpuCacheBytes: 160 * 1024 * 1024,
47
- maxCachedScenes: 2
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'
48
62
  });
49
63
 
50
64
  await viewer.load(file);
@@ -56,6 +70,31 @@ Copy the WASM asset from the package into your public assets directory:
56
70
  cp node_modules/dwf-viewer/public/dwfv-render.wasm public/dwfv-render.wasm
57
71
  ```
58
72
 
73
+ ## CAD line-weight rendering
74
+
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.
76
+
77
+ Available modes:
78
+
79
+ | Mode | Behavior |
80
+ |---|---|
81
+ | `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. |
84
+
85
+ Related options:
86
+
87
+ ```ts
88
+ new DwfViewer(el, {
89
+ lineWeightMode: 'adaptive',
90
+ minStrokeCssPx: 0.55,
91
+ maxOverviewStrokeCssPx: 1.15,
92
+ minTextCssPx: 3.5
93
+ });
94
+ ```
95
+
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.
97
+
59
98
  ## Three.js integration
60
99
 
61
100
  ```ts
@@ -113,6 +152,7 @@ npm run check:examples
113
152
 
114
153
  ```bash
115
154
  npm run clean
155
+ npm run publish:all -- --dry-run
116
156
  npm run publish:all
117
157
  ```
118
158
 
@@ -127,9 +167,16 @@ GitHub release publishing can use provenance through the included workflow and `
127
167
 
128
168
  ## Cloudflare Pages demo
129
169
 
170
+ Live demo:
171
+
172
+ ```text
173
+ https://dwf-viewer-demo.pages.dev/
174
+ ```
175
+
130
176
  The repository includes `wrangler.toml` with:
131
177
 
132
178
  ```toml
179
+ name = "dwf-viewer-demo"
133
180
  pages_build_output_dir = "./demo-dist"
134
181
  ```
135
182
 
@@ -144,12 +191,13 @@ Root directory: /
144
191
  Direct upload:
145
192
 
146
193
  ```bash
147
- npm run build:demo
148
- npx wrangler pages deploy demo-dist
194
+ npm run deploy:pages
149
195
  ```
150
196
 
151
197
  `build:demo` produces a static directory containing only demo HTML/JS, `dist`, `public/dwfv-render.wasm`, `styles`, and the curated examples.
152
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.
200
+
153
201
  ## Public API
154
202
 
155
203
  Main exports:
@@ -45,6 +45,11 @@ export interface InflateProvider {
45
45
  }
46
46
  export interface PageRenderOptions {
47
47
  pageIndex?: number;
48
+ lineWeightMode?: 'adaptive' | 'physical' | 'hairline';
49
+ minStrokeCssPx?: number;
50
+ maxOverviewStrokeCssPx?: number;
51
+ minTextCssPx?: number;
52
+ minFilledAreaCssPx?: number;
48
53
  preferWebgl?: boolean;
49
54
  preferWasm?: boolean;
50
55
  wasmUrl?: string;
@@ -2,6 +2,11 @@ import { type RenderStats } from '../format/types.js';
2
2
  import type { LoadedDwfDocument } from '../format/document.js';
3
3
  export interface GenericRenderOptions {
4
4
  zoom?: number;
5
+ lineWeightMode?: 'adaptive' | 'physical' | 'hairline';
6
+ minStrokeCssPx?: number;
7
+ maxOverviewStrokeCssPx?: number;
8
+ minTextCssPx?: number;
9
+ minFilledAreaCssPx?: number;
5
10
  panX?: number;
6
11
  panY?: number;
7
12
  preferWebgl?: boolean;
@@ -1,6 +1,7 @@
1
1
  import { type RenderStats } from '../format/types.js';
2
2
  import type { W2dTextPageData } from '../format/document.js';
3
- export interface W2dRenderOptions {
3
+ import { type CadLineStyleOptions } from './cadLineStyle.js';
4
+ export interface W2dRenderOptions extends CadLineStyleOptions {
4
5
  zoom?: number;
5
6
  panX?: number;
6
7
  panY?: number;
@@ -1,6 +1,7 @@
1
1
  import { actionableDiagnostics, diag } from '../format/types.js';
2
2
  import { applyPathToCanvas, flattenPath } from './xpsPath.js';
3
3
  import { multiplyMatrix, parseBrushColor, transformPoint } from './style.js';
4
+ import { adaptiveStrokeUserWidth, canvasDpr, estimateMatrixScale, shouldDrawTextByPixelSize } from './cadLineStyle.js';
4
5
  import { matrixForW2d } from './viewport.js';
5
6
  import { WasmRasterBackend } from '../wasm/WasmRasterBackend.js';
6
7
  import { WebGlW2dBackend } from './WebGlW2dBackend.js';
@@ -30,6 +31,7 @@ export class W2dRenderer {
30
31
  if (!ctx)
31
32
  throw new Error('CanvasRenderingContext2D is not available.');
32
33
  const pageMatrix = matrixForW2d(page, canvas.width, canvas.height, options.zoom, options.panX, options.panY);
34
+ const runtime = { dpr: canvasDpr(canvas), zoom: options.zoom ?? 1 };
33
35
  let commands = 0;
34
36
  if (options.preferWasm) {
35
37
  try {
@@ -37,11 +39,11 @@ export class W2dRenderer {
37
39
  await this.wasm.init();
38
40
  this.wasm.begin(canvas.width, canvas.height, bg);
39
41
  for (const p of page.primitives)
40
- commands += this.drawPrimitiveWasm(p, pageMatrix);
42
+ commands += this.drawPrimitiveWasm(p, pageMatrix, options, runtime);
41
43
  ctx.setTransform(1, 0, 0, 1, 0, 0);
42
44
  ctx.putImageData(this.wasm.toImageData(), 0, 0);
43
45
  for (const p of page.primitives.filter(p => p.type === 'text'))
44
- commands += this.drawPrimitiveCanvas(ctx, p, pageMatrix);
46
+ commands += this.drawPrimitiveCanvas(ctx, p, pageMatrix, options, runtime);
45
47
  return { backend: 'wasm-raster', commands, warnings };
46
48
  }
47
49
  catch (err) {
@@ -54,7 +56,7 @@ export class W2dRenderer {
54
56
  ctx.fillRect(0, 0, canvas.width, canvas.height);
55
57
  ctx.restore();
56
58
  for (const p of page.primitives)
57
- commands += this.drawPrimitiveCanvas(ctx, p, pageMatrix);
59
+ commands += this.drawPrimitiveCanvas(ctx, p, pageMatrix, options, runtime);
58
60
  return { backend: 'canvas2d', commands, warnings };
59
61
  }
60
62
  dispose() {
@@ -70,14 +72,14 @@ export class W2dRenderer {
70
72
  }
71
73
  return this.webgl;
72
74
  }
73
- drawPrimitiveCanvas(ctx, p, pageMatrix) {
75
+ drawPrimitiveCanvas(ctx, p, pageMatrix, options, runtime) {
74
76
  const matrix = multiplyMatrix(pageMatrix, p.matrix ?? { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
75
77
  const stroke = parseBrushColor(p.stroke ?? '#000000') ?? '#000000';
76
78
  const fill = parseBrushColor(p.fill);
77
79
  if (p.type === 'text') {
78
80
  const [x, y] = transformPoint(matrix, p.x, p.y);
79
- const screenSize = Math.max(4, Math.abs((p.size ?? 12) * estimateScale(matrix)));
80
- if (screenSize < 2.5 || x > ctx.canvas.width + 64 || y > ctx.canvas.height + 64 || x < -ctx.canvas.width || y < -ctx.canvas.height)
81
+ const screenSize = Math.max(1, Math.abs((p.size ?? 12) * estimateScale(matrix)));
82
+ if (!shouldDrawTextByPixelSize(p.size ?? 12, matrix, options, runtime) || x > ctx.canvas.width + 64 || y > ctx.canvas.height + 64 || x < -ctx.canvas.width || y < -ctx.canvas.height)
81
83
  return 0;
82
84
  ctx.save();
83
85
  ctx.setTransform(1, 0, 0, 1, 0, 0);
@@ -91,7 +93,7 @@ export class W2dRenderer {
91
93
  }
92
94
  ctx.save();
93
95
  ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
94
- ctx.lineWidth = Math.max(0.1, (p.lineWidth ?? 1));
96
+ ctx.lineWidth = adaptiveStrokeUserWidth(p.lineWidth ?? 1, matrix, options, runtime);
95
97
  if (p.type === 'polyline') {
96
98
  if (p.points.length >= 4) {
97
99
  ctx.beginPath();
@@ -144,25 +146,26 @@ export class W2dRenderer {
144
146
  ctx.restore();
145
147
  return 1;
146
148
  }
147
- drawPrimitiveWasm(p, pageMatrix) {
149
+ drawPrimitiveWasm(p, pageMatrix, options, runtime) {
148
150
  if (!this.wasm || p.type === 'text')
149
151
  return 0;
150
152
  const matrix = multiplyMatrix(pageMatrix, p.matrix ?? { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 });
151
- const scale = estimateScale(matrix);
153
+ const scale = estimateMatrixScale(matrix);
154
+ const screenStroke = (w) => adaptiveStrokeUserWidth(w ?? 1, matrix, options, runtime) * scale;
152
155
  if (p.type === 'polyline') {
153
- this.wasm.drawPolyline(p.points, matrix, parseBrushColor(p.stroke ?? '#000000'), (p.lineWidth ?? 1) * scale);
156
+ this.wasm.drawPolyline(p.points, matrix, parseBrushColor(p.stroke ?? '#000000'), screenStroke(p.lineWidth));
154
157
  }
155
158
  else if (p.type === 'polygon') {
156
159
  this.wasm.drawPolygon(p.points, matrix, parseBrushColor(p.fill));
157
160
  if (p.stroke)
158
- this.wasm.drawPolyline(closePoints(p.points), matrix, parseBrushColor(p.stroke), (p.lineWidth ?? 1) * scale);
161
+ this.wasm.drawPolyline(closePoints(p.points), matrix, parseBrushColor(p.stroke), screenStroke(p.lineWidth));
159
162
  }
160
163
  else if (p.type === 'rect') {
161
164
  const pts = [p.x, p.y, p.x + p.width, p.y, p.x + p.width, p.y + p.height, p.x, p.y + p.height, p.x, p.y];
162
165
  if (p.fill)
163
166
  this.wasm.drawPolygon(pts, matrix, parseBrushColor(p.fill));
164
167
  if (p.stroke)
165
- this.wasm.drawPolyline(pts, matrix, parseBrushColor(p.stroke), (p.lineWidth ?? 1) * scale);
168
+ this.wasm.drawPolyline(pts, matrix, parseBrushColor(p.stroke), screenStroke(p.lineWidth));
166
169
  }
167
170
  else if (p.type === 'path') {
168
171
  const subs = flattenPath(p.commands, 0.5);
@@ -174,7 +177,7 @@ export class W2dRenderer {
174
177
  this.wasm.drawPolygon(s.points, matrix, fill);
175
178
  if (stroke)
176
179
  for (const s of subs)
177
- this.wasm.drawPolyline(s.points, matrix, stroke, (p.lineWidth ?? 1) * scale);
180
+ this.wasm.drawPolyline(s.points, matrix, stroke, screenStroke(p.lineWidth));
178
181
  }
179
182
  return 1;
180
183
  }
@@ -1,6 +1,7 @@
1
1
  import type { Diagnostic } from '../format/types.js';
2
2
  import type { W2dTextPageData } from '../format/document.js';
3
- export interface WebGlW2dRenderOptions {
3
+ import { type CadLineStyleOptions } from './cadLineStyle.js';
4
+ export interface WebGlW2dRenderOptions extends CadLineStyleOptions {
4
5
  zoom?: number;
5
6
  panX?: number;
6
7
  panY?: number;
@@ -1,6 +1,7 @@
1
1
  import { diag } from '../format/types.js';
2
2
  import { flattenPath } from './xpsPath.js';
3
3
  import { colorToRgba32, multiplyMatrix, transformPoint } from './style.js';
4
+ import { adaptiveStrokeUserWidth, canvasDpr, estimateMatrixScale } from './cadLineStyle.js';
4
5
  import { matrixForW2d } from './viewport.js';
5
6
  const VERTEX_STRIDE = 12;
6
7
  const DEFAULT_MAX_GPU_CACHE_BYTES = 96 * 1024 * 1024;
@@ -38,11 +39,13 @@ export class WebGlW2dBackend {
38
39
  return { commands: 0, warnings, gpuBytes: this.gpuBytes, vertexCount: 0, textCount: 0, cacheHit: true };
39
40
  }
40
41
  this.resize(targetCanvas.width, targetCanvas.height);
41
- const key = sceneKey(page);
42
+ const pageMatrix = matrixForW2d(page, this.canvas.width, this.canvas.height, options.zoom, options.panX, options.panY);
43
+ const runtime = { dpr: canvasDpr(targetCanvas), zoom: options.zoom ?? 1 };
44
+ const key = sceneKey(page, pageMatrix, options);
42
45
  let scene = this.scenes.get(key);
43
46
  const cacheHit = !!scene;
44
47
  if (!scene) {
45
- scene = this.compileScene(page, key, options);
48
+ scene = this.compileScene(page, key, options, pageMatrix, runtime);
46
49
  this.scenes.set(key, scene);
47
50
  this.gpuBytes += scene.gpuBytes;
48
51
  this.evictIfNeeded(options);
@@ -59,7 +62,6 @@ export class WebGlW2dBackend {
59
62
  gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
60
63
  gl.clearColor(bg[0], bg[1], bg[2], bg[3]);
61
64
  gl.clear(gl.COLOR_BUFFER_BIT);
62
- const pageMatrix = matrixForW2d(page, this.canvas.width, this.canvas.height, options.zoom, options.panX, options.panY);
63
65
  gl.useProgram(this.program);
64
66
  gl.bindBuffer(gl.ARRAY_BUFFER, scene.buffer);
65
67
  gl.enableVertexAttribArray(this.aPos);
@@ -103,7 +105,7 @@ export class WebGlW2dBackend {
103
105
  this.canvas.height = height;
104
106
  }
105
107
  }
106
- compileScene(page, key, options) {
108
+ compileScene(page, key, options, pageMatrix, runtime) {
107
109
  const writer = new VertexWriter();
108
110
  let primitiveCount = 0;
109
111
  let textCount = 0;
@@ -113,7 +115,7 @@ export class WebGlW2dBackend {
113
115
  continue;
114
116
  }
115
117
  primitiveCount++;
116
- appendPrimitive(writer, p);
118
+ appendPrimitive(writer, p, pageMatrix, options, runtime);
117
119
  }
118
120
  const bufferBytes = writer.byteLength;
119
121
  const maxBytes = options.maxGpuCacheBytes ?? DEFAULT_MAX_GPU_CACHE_BYTES;
@@ -187,8 +189,10 @@ export class WebGlW2dBackend {
187
189
  }
188
190
  }
189
191
  const IDENTITY_MATRIX = { a: 1, b: 0, c: 0, d: 1, e: 0, f: 0 };
190
- function sceneKey(page) {
191
- return `${page.id}|${page.sourcePath}|${page.primitives.length}`;
192
+ function sceneKey(page, pageMatrix, options) {
193
+ const mode = options.lineWeightMode ?? 'adaptive';
194
+ const scaleBucket = mode === 'physical' ? 'physical' : String(Math.round(Math.log2(Math.max(1e-12, estimateMatrixScale(pageMatrix))) * 8));
195
+ return `${page.id}|${page.sourcePath}|${page.primitives.length}|lw:${mode}:${scaleBucket}`;
192
196
  }
193
197
  class VertexWriter {
194
198
  constructor() {
@@ -226,10 +230,11 @@ class VertexWriter {
226
230
  this.view = new DataView(this.buffer);
227
231
  }
228
232
  }
229
- function appendPrimitive(writer, p) {
233
+ function appendPrimitive(writer, p, pageMatrix, options, runtime) {
230
234
  const m = p.matrix ?? IDENTITY_MATRIX;
231
235
  const matrixScale = estimateScale(m);
232
- const lineWidth = Math.max(0.1, (p.lineWidth ?? 1) * matrixScale);
236
+ const fullMatrix = multiplyMatrix(pageMatrix, m);
237
+ const lineWidth = Math.max(0.01, adaptiveStrokeUserWidth(p.lineWidth ?? 1, fullMatrix, options, runtime) * matrixScale);
233
238
  if (p.type === 'polyline') {
234
239
  const color = rgbaBytes(p.stroke ?? '#000000');
235
240
  appendPolyline(writer, transformPointsArray(p.points, m), lineWidth, color);
@@ -1,6 +1,7 @@
1
1
  import { type RenderStats } from '../format/types.js';
2
2
  import type { LoadedDwfDocument, XpsPageData } from '../format/document.js';
3
- export interface XpsRenderOptions {
3
+ import { type CadLineStyleOptions } from './cadLineStyle.js';
4
+ export interface XpsRenderOptions extends CadLineStyleOptions {
4
5
  zoom?: number;
5
6
  panX?: number;
6
7
  panY?: number;
@@ -11,10 +12,14 @@ export interface XpsRenderOptions {
11
12
  export declare class XpsRenderer {
12
13
  private readonly document;
13
14
  private wasm?;
15
+ private readonly fontCache;
14
16
  constructor(document: LoadedDwfDocument);
15
17
  render(page: XpsPageData, canvas: HTMLCanvasElement, options?: XpsRenderOptions): Promise<RenderStats>;
16
18
  private renderElementToCanvas;
17
19
  private renderElementToWasm;
20
+ private drawGlyphs;
21
+ private fontFamilyForGlyphs;
22
+ private loadFontFace;
18
23
  private drawImageResource;
19
24
  private drawImageBrush;
20
25
  }
@@ -2,11 +2,13 @@ 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';
7
8
  export class XpsRenderer {
8
9
  constructor(document) {
9
10
  this.document = document;
11
+ this.fontCache = new Map();
10
12
  }
11
13
  async render(page, canvas, options = {}) {
12
14
  const opc = this.document.opc;
@@ -20,6 +22,7 @@ export class XpsRenderer {
20
22
  if (!ctx)
21
23
  throw new Error('CanvasRenderingContext2D is not available.');
22
24
  const bg = options.background ?? '#ffffff';
25
+ const runtime = { dpr: canvasDpr(canvas), zoom: options.zoom ?? 1 };
23
26
  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
27
  let commands = 0;
25
28
  if (options.preferWasm) {
@@ -27,10 +30,10 @@ export class XpsRenderer {
27
30
  this.wasm ?? (this.wasm = new WasmRasterBackend({ wasmUrl: options.wasmUrl }));
28
31
  await this.wasm.init();
29
32
  this.wasm.begin(canvas.width, canvas.height, bg);
30
- commands += this.renderElementToWasm(root, pageMatrix, 1, warnings);
33
+ commands += this.renderElementToWasm(root, pageMatrix, 1, warnings, options, runtime);
31
34
  ctx.setTransform(1, 0, 0, 1, 0, 0);
32
35
  ctx.putImageData(this.wasm.toImageData(), 0, 0);
33
- commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: false, overlays: true });
36
+ commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: false, overlays: true }, options, runtime);
34
37
  return { backend: 'wasm-raster', commands, warnings };
35
38
  }
36
39
  catch (err) {
@@ -42,10 +45,10 @@ export class XpsRenderer {
42
45
  ctx.fillStyle = bg;
43
46
  ctx.fillRect(0, 0, canvas.width, canvas.height);
44
47
  ctx.restore();
45
- commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: true, overlays: true });
48
+ commands += await this.renderElementToCanvas(root, ctx, page.sourcePath, pageMatrix, 1, warnings, { vectors: true, overlays: true }, options, runtime);
46
49
  return { backend: 'canvas2d', commands, warnings };
47
50
  }
48
- async renderElementToCanvas(el, ctx, pagePath, matrix, opacity, warnings, mode) {
51
+ async renderElementToCanvas(el, ctx, pagePath, matrix, opacity, warnings, mode, options, runtime) {
49
52
  const name = localName(el);
50
53
  const local = elementMatrix(el);
51
54
  const composed = multiplyMatrix(matrix, local);
@@ -68,13 +71,15 @@ export class XpsRenderer {
68
71
  const fill = extractBrush(el, 'Fill', ownOpacity);
69
72
  const stroke = extractBrush(el, 'Stroke', ownOpacity);
70
73
  const thickness = Number(getAttr(el, 'StrokeThickness') ?? 1);
71
- if (fill) {
74
+ const bounds = pathBounds(path);
75
+ if (fill && shouldDrawFilledBounds(bounds, composed, options, runtime)) {
72
76
  ctx.fillStyle = fill;
73
77
  ctx.fill(fillRule(el));
74
78
  }
75
79
  if (stroke && thickness > 0) {
76
80
  ctx.strokeStyle = stroke;
77
- ctx.lineWidth = thickness;
81
+ ctx.lineWidth = adaptiveStrokeUserWidth(thickness, composed, options, runtime);
82
+ applyStrokeStyle(ctx, el, ctx.lineWidth);
78
83
  ctx.stroke();
79
84
  }
80
85
  ctx.restore();
@@ -82,19 +87,7 @@ export class XpsRenderer {
82
87
  }
83
88
  }
84
89
  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++;
90
+ commands += await this.drawGlyphs(ctx, pagePath, el, composed, ownOpacity, warnings, options, runtime);
98
91
  }
99
92
  else if (name === 'Image' && mode.overlays) {
100
93
  const source = getAttr(el, 'Source') ?? getAttr(el, 'ImageSource');
@@ -129,11 +122,11 @@ export class XpsRenderer {
129
122
  const childName = localName(child);
130
123
  if (childName.includes('.'))
131
124
  continue;
132
- commands += await this.renderElementToCanvas(child, ctx, pagePath, composed, ownOpacity, warnings, mode);
125
+ commands += await this.renderElementToCanvas(child, ctx, pagePath, composed, ownOpacity, warnings, mode, options, runtime);
133
126
  }
134
127
  return commands;
135
128
  }
136
- renderElementToWasm(el, matrix, opacity, warnings) {
129
+ renderElementToWasm(el, matrix, opacity, warnings, options, runtime) {
137
130
  if (!this.wasm)
138
131
  return 0;
139
132
  const name = localName(el);
@@ -147,6 +140,7 @@ export class XpsRenderer {
147
140
  const fill = extractBrush(el, 'Fill', ownOpacity);
148
141
  const stroke = extractBrush(el, 'Stroke', ownOpacity);
149
142
  const thickness = Number(getAttr(el, 'StrokeThickness') ?? 1);
143
+ const screenThickness = adaptiveStrokeUserWidth(thickness, composed, options, runtime) * Math.max(1e-12, Math.hypot(composed.a, composed.b));
150
144
  const subs = flattenPath(path, 0.5);
151
145
  if (fill) {
152
146
  for (const sub of subs)
@@ -155,7 +149,7 @@ export class XpsRenderer {
155
149
  }
156
150
  if (stroke && thickness > 0) {
157
151
  for (const sub of subs)
158
- this.wasm.drawPolyline(sub.points, composed, stroke, thickness * composed.a);
152
+ this.wasm.drawPolyline(sub.points, composed, stroke, screenThickness);
159
153
  }
160
154
  commands++;
161
155
  }
@@ -164,10 +158,66 @@ export class XpsRenderer {
164
158
  const childName = localName(child);
165
159
  if (childName.includes('.'))
166
160
  continue;
167
- commands += this.renderElementToWasm(child, composed, ownOpacity, warnings);
161
+ commands += this.renderElementToWasm(child, composed, ownOpacity, warnings, options, runtime);
168
162
  }
169
163
  return commands;
170
164
  }
165
+ async drawGlyphs(ctx, pagePath, el, matrix, opacity, warnings, options, runtime) {
166
+ const text = getAttr(el, 'UnicodeString') ?? '';
167
+ if (!text)
168
+ return 0;
169
+ const x = Number(getAttr(el, 'OriginX') ?? 0);
170
+ const y = Number(getAttr(el, 'OriginY') ?? 0);
171
+ const size = Number(getAttr(el, 'FontRenderingEmSize') ?? 12);
172
+ if (!shouldDrawTextByPixelSize(size, matrix, options, runtime))
173
+ return 0;
174
+ const fill = extractBrush(el, 'Fill', opacity) ?? '#000000';
175
+ const family = await this.fontFamilyForGlyphs(pagePath, el, warnings) ?? 'sans-serif';
176
+ ctx.save();
177
+ ctx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e, matrix.f);
178
+ ctx.globalAlpha = opacity;
179
+ ctx.fillStyle = fill;
180
+ ctx.font = `${size}px "${family}"`;
181
+ ctx.textBaseline = 'alphabetic';
182
+ const indices = getAttr(el, 'Indices');
183
+ if (indices)
184
+ drawGlyphRunWithIndices(ctx, text, indices, x, y, size);
185
+ else
186
+ ctx.fillText(text, x, y);
187
+ ctx.restore();
188
+ return 1;
189
+ }
190
+ async fontFamilyForGlyphs(pagePath, el, warnings) {
191
+ const uri = getAttr(el, 'FontUri');
192
+ if (!uri)
193
+ return undefined;
194
+ const part = resolvePart(pagePath, uri.replace(/^\//, ''));
195
+ if (/\.odttf$/i.test(part))
196
+ return undefined;
197
+ let cached = this.fontCache.get(part);
198
+ if (!cached) {
199
+ cached = this.loadFontFace(part).catch(err => {
200
+ warnings.push(diag('warning', 'XPS_FONT_LOAD_FAILED', `Failed to load embedded XPS font ${part}: ${String(err)}`, pagePath));
201
+ return undefined;
202
+ });
203
+ this.fontCache.set(part, cached);
204
+ }
205
+ return cached;
206
+ }
207
+ async loadFontFace(part) {
208
+ const FontFaceCtor = globalThis.FontFace;
209
+ const fontSet = document.fonts;
210
+ if (!FontFaceCtor || !fontSet)
211
+ return undefined;
212
+ const bytes = await this.document.opc.readBytes(part);
213
+ const family = `dwfv_xps_${hashString(part)}`;
214
+ const blob = new Blob([bytes], { type: mimeFromPath(part) ?? 'font/ttf' });
215
+ const url = URL.createObjectURL(blob);
216
+ const face = new FontFaceCtor(family, `url("${url}")`);
217
+ await face.load();
218
+ fontSet.add(face);
219
+ return family;
220
+ }
171
221
  async drawImageResource(ctx, pagePath, source, matrix, opacity, el) {
172
222
  const opc = this.document.opc;
173
223
  const src = resolvePart(pagePath, source.replace(/^\//, ''));
@@ -202,6 +252,92 @@ export class XpsRenderer {
202
252
  ctx.restore();
203
253
  }
204
254
  }
255
+ function applyStrokeStyle(ctx, el, userLineWidth) {
256
+ const start = (getAttr(el, 'StrokeStartLineCap') ?? '').toLowerCase();
257
+ const end = (getAttr(el, 'StrokeEndLineCap') ?? '').toLowerCase();
258
+ const dashCap = (getAttr(el, 'StrokeDashCap') ?? '').toLowerCase();
259
+ if (start === 'round' || end === 'round' || dashCap === 'round')
260
+ ctx.lineCap = 'round';
261
+ else if (start === 'square' || end === 'square' || dashCap === 'square')
262
+ ctx.lineCap = 'square';
263
+ else
264
+ ctx.lineCap = 'butt';
265
+ const join = (getAttr(el, 'StrokeLineJoin') ?? '').toLowerCase();
266
+ ctx.lineJoin = join === 'round' ? 'round' : join === 'bevel' ? 'bevel' : 'miter';
267
+ const miter = Number(getAttr(el, 'StrokeMiterLimit') ?? 10);
268
+ if (Number.isFinite(miter) && miter > 0)
269
+ ctx.miterLimit = miter;
270
+ const dash = parseNumberList(getAttr(el, 'StrokeDashArray') ?? '');
271
+ if (dash.length > 0) {
272
+ const offset = Number(getAttr(el, 'StrokeDashOffset') ?? 0);
273
+ ctx.setLineDash(dash.map(v => Math.max(0, v * userLineWidth)));
274
+ ctx.lineDashOffset = Number.isFinite(offset) ? offset * userLineWidth : 0;
275
+ }
276
+ else {
277
+ ctx.setLineDash([]);
278
+ ctx.lineDashOffset = 0;
279
+ }
280
+ }
281
+ function pathBounds(commands) {
282
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
283
+ const add = (x, y) => {
284
+ if (!Number.isFinite(x) || !Number.isFinite(y))
285
+ return;
286
+ minX = Math.min(minX, x);
287
+ minY = Math.min(minY, y);
288
+ maxX = Math.max(maxX, x);
289
+ maxY = Math.max(maxY, y);
290
+ };
291
+ for (const c of commands) {
292
+ if (c.type === 'M' || c.type === 'L')
293
+ add(c.x, c.y);
294
+ else if (c.type === 'C') {
295
+ add(c.x1, c.y1);
296
+ add(c.x2, c.y2);
297
+ add(c.x, c.y);
298
+ }
299
+ else if (c.type === 'Q') {
300
+ add(c.x1, c.y1);
301
+ add(c.x, c.y);
302
+ }
303
+ else if (c.type === 'A') {
304
+ add(c.x - c.rx, c.y - c.ry);
305
+ add(c.x + c.rx, c.y + c.ry);
306
+ }
307
+ }
308
+ return Number.isFinite(minX) ? { minX, minY, maxX, maxY } : undefined;
309
+ }
310
+ function drawGlyphRunWithIndices(ctx, text, indices, x, y, emSize) {
311
+ const specs = indices.split(';');
312
+ let cursor = x;
313
+ let charIndex = 0;
314
+ for (const spec of specs) {
315
+ const raw = spec.trim();
316
+ if (!raw)
317
+ continue;
318
+ const parts = raw.split(',');
319
+ const advance = Number(parts[1] ?? '');
320
+ const dx = Number(parts[3] ?? 0);
321
+ const dy = Number(parts[4] ?? 0);
322
+ const ch = text[charIndex++] ?? '';
323
+ if (ch)
324
+ ctx.fillText(ch, cursor + (Number.isFinite(dx) ? dx : 0), y + (Number.isFinite(dy) ? dy : 0));
325
+ if (Number.isFinite(advance) && advance > 0)
326
+ cursor += advance * emSize / 100;
327
+ else
328
+ cursor += ctx.measureText(ch || ' ').width;
329
+ }
330
+ if (charIndex < text.length)
331
+ ctx.fillText(text.slice(charIndex), cursor, y);
332
+ }
333
+ function hashString(s) {
334
+ let h = 2166136261;
335
+ for (let i = 0; i < s.length; i++) {
336
+ h ^= s.charCodeAt(i);
337
+ h = Math.imul(h, 16777619);
338
+ }
339
+ return (h >>> 0).toString(36);
340
+ }
205
341
  function elementMatrix(el) {
206
342
  let m = parseMatrix(getAttr(el, 'RenderTransform') ?? getAttr(el, 'Transform'));
207
343
  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,6 +42,11 @@ 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;
@@ -47,6 +57,7 @@ export declare class DwfViewer {
47
57
  constructor(container: HTMLElement, options?: DwfViewerOptions);
48
58
  setPreferWebgl(value: boolean): void;
49
59
  setPreferWasm(value: boolean): void;
60
+ setLineWeightMode(value: 'adaptive' | 'physical' | 'hairline'): void;
50
61
  load(input: ArrayBuffer | Uint8Array | Blob | File, options?: LoadOptions): Promise<void>;
51
62
  render(): Promise<RenderStats | undefined>;
52
63
  getDocument(): LoadedDwfDocument | undefined;
@@ -21,6 +21,11 @@ export class DwfViewer {
21
21
  this.maxCanvasPixels = options.maxCanvasPixels ?? 16777216;
22
22
  this.maxGpuCacheBytes = options.maxGpuCacheBytes;
23
23
  this.maxCachedScenes = options.maxCachedScenes;
24
+ this.lineWeightMode = options.lineWeightMode ?? 'adaptive';
25
+ this.minStrokeCssPx = options.minStrokeCssPx;
26
+ this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx;
27
+ this.minTextCssPx = options.minTextCssPx;
28
+ this.minFilledAreaCssPx = options.minFilledAreaCssPx;
24
29
  this.root = document.createElement('div');
25
30
  this.root.className = 'dwfv-root';
26
31
  const toolbar = document.createElement('div');
@@ -69,6 +74,11 @@ export class DwfViewer {
69
74
  this.preferWasm = value;
70
75
  this.requestRender();
71
76
  }
77
+ setLineWeightMode(value) {
78
+ this.lineWeightMode = value;
79
+ this.renderer?.dispose();
80
+ this.requestRender();
81
+ }
72
82
  async load(input, options = {}) {
73
83
  this.setStatus('解析文件中…');
74
84
  this.renderer?.dispose();
@@ -85,6 +95,11 @@ export class DwfViewer {
85
95
  this.background = options.background ?? this.background;
86
96
  this.maxGpuCacheBytes = options.maxGpuCacheBytes ?? this.maxGpuCacheBytes;
87
97
  this.maxCachedScenes = options.maxCachedScenes ?? this.maxCachedScenes;
98
+ this.lineWeightMode = options.lineWeightMode ?? this.lineWeightMode;
99
+ this.minStrokeCssPx = options.minStrokeCssPx ?? this.minStrokeCssPx;
100
+ this.maxOverviewStrokeCssPx = options.maxOverviewStrokeCssPx ?? this.maxOverviewStrokeCssPx;
101
+ this.minTextCssPx = options.minTextCssPx ?? this.minTextCssPx;
102
+ this.minFilledAreaCssPx = options.minFilledAreaCssPx ?? this.minFilledAreaCssPx;
88
103
  this.populatePages();
89
104
  this.populateModelTree();
90
105
  await this.render();
@@ -109,7 +124,12 @@ export class DwfViewer {
109
124
  maxCachedScenes: this.maxCachedScenes,
110
125
  webglCanvas: this.webglCanvas,
111
126
  yaw: this.yaw,
112
- pitch: this.pitch
127
+ pitch: this.pitch,
128
+ lineWeightMode: this.lineWeightMode,
129
+ minStrokeCssPx: this.minStrokeCssPx,
130
+ maxOverviewStrokeCssPx: this.maxOverviewStrokeCssPx,
131
+ minTextCssPx: this.minTextCssPx,
132
+ minFilledAreaCssPx: this.minFilledAreaCssPx
113
133
  });
114
134
  this.pendingRender = task;
115
135
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dwf-viewer",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "private": false,
5
5
  "type": "module",
6
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.",
@@ -22,6 +22,8 @@
22
22
  "hsf",
23
23
  "xps",
24
24
  "cad",
25
+ "lineweight",
26
+ "line-width",
25
27
  "viewer",
26
28
  "webgl",
27
29
  "threejs",
@@ -75,7 +77,7 @@
75
77
  "publish:scoped": "node scripts/publish-npm.mjs @flyfish-dev/dwf-viewer",
76
78
  "publish:all": "node scripts/publish-npm.mjs dwf-viewer @flyfish-dev/dwf-viewer",
77
79
  "demo:serve": "npm run build:demo && python3 -m http.server 8080 -d demo-dist",
78
- "deploy:pages": "npm run build:demo && npx wrangler pages deploy demo-dist"
80
+ "deploy:pages": "npm run build:demo && npx wrangler pages deploy demo-dist --project-name=dwf-viewer-demo --branch=main"
79
81
  },
80
82
  "devDependencies": {
81
83
  "typescript": "^4.9.5"