@flyfish-dev/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 +8 -0
- package/README.md +51 -3
- package/dist/format/types.d.ts +5 -0
- package/dist/render/PageRenderer.d.ts +5 -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/XpsRenderer.d.ts +6 -1
- package/dist/render/XpsRenderer.js +159 -23
- package/dist/render/cadLineStyle.d.ts +32 -0
- package/dist/render/cadLineStyle.js +59 -0
- package/dist/viewer/DwfViewer.d.ts +11 -0
- package/dist/viewer/DwfViewer.js +21 -1
- package/package.json +4 -2
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
|
|
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:
|
package/dist/format/types.d.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
80
|
-
if (
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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;
|
package/dist/viewer/DwfViewer.js
CHANGED
|
@@ -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": "@flyfish-dev/dwf-viewer",
|
|
3
|
-
"version": "0.5.
|
|
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"
|