Public: anyone can read this; the crew can change it.
A .dxf drawing opens as a picture on its file page: lines, circles, arcs and polylines #11913
Check · Review · Land · Live — No review yet.
torque/forge:torque/11861-a-dxf-drawing-opens-as-a-picture-on-its → main 2 commits fork
8 files
changed +353
−1
docs/design/areas/render.md +4
| @@ -32,6 +32,10 @@ A .zip, .tar or .tar.gz is drawn by `src/web/render/archive-view.ts` as the exis | ||
| 32 | 32 | |
| 33 | 33 | `src/web/render/geojson.ts` draws a GeoJSON file as one inline SVG in `.gj-view` (same rules as `.gpx-view`: no fixed size, `max-width: 100%`, `currentColor`), and the first 100 features' properties as a plain table in `.gj-table`, which scrolls sideways inside its own box on a phone. |
| 34 | 34 | |
| 35 | ## Drawing (.dxf) | |
| 36 | ||
| 37 | `src/web/render/dxf.ts` draws an ASCII DXF as one inline SVG in `.dxf-view` (same rules as `.gpx-view`: no fixed size, `max-width: 100%`, `currentColor`, no accent), with its entity counts and layer names as the existing `.status-rows`. | |
| 38 | ||
| 35 | 39 | ## Request waterfall (.har) |
| 36 | 40 | |
| 37 | 41 | `src/web/render/har.ts` draws a HAR as the existing `.table` (`data-dense`) inside `.har-view`, which scrolls sideways on a phone. Each row's timing bar is one inline SVG, `.har-url` (the URL cell, wraps anywhere) and `.har-bar` (the full width of the URL cell, under the URL, 10px tall, `--muted-fg` (it is data, not a control, so not the accent), `aria-hidden` since the Time column says the same in words, no inline style). `.har-url` has `min-width: 200px` so a row never squeezes to a sliver; that cannot make the page scroll, because `.har-view` is `overflow-x: auto`, so any excess width scrolls inside the table's own box, not the page. Headers sit in a native `<details>` under the URL. How it works: `docs/features/file-har.md`. |
src/web/render/dxf.ts added +236
| @@ -0,0 +1,236 @@ | ||
| 1 | /** | |
| 2 | * An ASCII DXF drawing (`.dxf`, AutoCAD's exchange format) drawn as inline SVG. | |
| 3 | * The file is a list of (group code, value) line pairs; this reads the | |
| 4 | * ENTITIES section and draws LINE, CIRCLE, ARC and LWPOLYLINE, scaled to their | |
| 5 | * bounding box with the y axis flipped (DXF is y-up, SVG y-down). Angles are | |
| 6 | * degrees counter-clockwise, as in the DXF reference (group codes 10/20/11/21 | |
| 7 | * points, 40 radius, 50/51 arc angles, 70 polyline flags, 8 layer). Bulges | |
| 8 | * (code 42) are ignored: a polyline's segments are drawn straight. TEXT and | |
| 9 | * every other entity are counted as ignored, never drawn. A binary DXF has a | |
| 10 | * NUL in its head and never reaches here; a file with no entities, no EOF | |
| 11 | * or no ENTITIES section falls back to the source view. Every number is | |
| 12 | * finite and clamped, every string escaped. | |
| 13 | */ | |
| 14 | ||
| 15 | import { html, raw } from "../html.ts"; | |
| 16 | import type { Rendered, RenderInput } from "./renderers.ts"; | |
| 17 | import { RENDER_CAP } from "./kinds.ts"; | |
| 18 | import { fileSize } from "../ui.ts"; | |
| 19 | ||
| 20 | /** Entities drawn in all; the rest are counted but not drawn. */ | |
| 21 | export const ENTITY_CAP = 50_000; | |
| 22 | const LAYER_SHOWN = 50; | |
| 23 | const NAME_CHARS = 80; | |
| 24 | /** Coordinates beyond this magnitude are treated as corrupt. */ | |
| 25 | const LIMIT = 1e9; | |
| 26 | ||
| 27 | type Pt = readonly [number, number]; | |
| 28 | export type Shape = | |
| 29 | | { readonly t: "line"; readonly a: Pt; readonly b: Pt } | |
| 30 | | { readonly t: "circle"; readonly c: Pt; readonly r: number } | |
| 31 | | { readonly t: "arc"; readonly c: Pt; readonly r: number; readonly from: number; readonly to: number } | |
| 32 | | { readonly t: "poly"; readonly pts: Pt[]; readonly closed: boolean }; | |
| 33 | ||
| 34 | export interface Drawing { | |
| 35 | readonly shapes: Shape[]; | |
| 36 | /** Entity counts by type, drawn or not. */ | |
| 37 | readonly counts: Map<string, number>; | |
| 38 | readonly layers: string[]; | |
| 39 | } | |
| 40 | ||
| 41 | const num = (s: string | undefined): number | undefined => { | |
| 42 | if (s === undefined || s.trim() === "") return undefined; | |
| 43 | const n = Number(s); | |
| 44 | return Number.isFinite(n) && Math.abs(n) <= LIMIT ? n : undefined; | |
| 45 | }; | |
| 46 | ||
| 47 | /** Parse DXF text. Pure. An error string when it cannot be drawn. */ | |
| 48 | export function parseDxf(text: string): Drawing | { error: string } { | |
| 49 | const lines = text.replace(/^/, "").split(/\r\n|\n|\r/); | |
| 50 | if (lines.length < 4) return { error: "it is too short to be a drawing" }; | |
| 51 | let inEntities = false; | |
| 52 | let sawEntities = false; | |
| 53 | let sawEof = false; | |
| 54 | const counts = new Map<string, number>(); | |
| 55 | const layers = new Set<string>(); | |
| 56 | const shapes: Shape[] = []; | |
| 57 | type Cur = { type: string; layer: string; codes: [number, string][] }; | |
| 58 | let cur: Cur | undefined; | |
| 59 | let expectName = false; | |
| 60 | ||
| 61 | const finish = (): void => { | |
| 62 | if (cur === undefined) return; | |
| 63 | const e = cur; | |
| 64 | cur = undefined; | |
| 65 | counts.set(e.type, (counts.get(e.type) ?? 0) + 1); | |
| 66 | layers.add(e.layer); | |
| 67 | if (shapes.length >= ENTITY_CAP) return; | |
| 68 | const first = (code: number): number | undefined => num(e.codes.find((c) => c[0] === code)?.[1]); | |
| 69 | const x = first(10), y = first(20); | |
| 70 | switch (e.type) { | |
| 71 | case "LINE": { | |
| 72 | const x2 = first(11), y2 = first(21); | |
| 73 | if (x !== undefined && y !== undefined && x2 !== undefined && y2 !== undefined) | |
| 74 | shapes.push({ t: "line", a: [x, y], b: [x2, y2] }); | |
| 75 | break; | |
| 76 | } | |
| 77 | case "CIRCLE": { | |
| 78 | const r = first(40); | |
| 79 | if (x !== undefined && y !== undefined && r !== undefined && r > 0) shapes.push({ t: "circle", c: [x, y], r }); | |
| 80 | break; | |
| 81 | } | |
| 82 | case "ARC": { | |
| 83 | const r = first(40), from = first(50), to = first(51); | |
| 84 | if (x !== undefined && y !== undefined && r !== undefined && r > 0 && from !== undefined && to !== undefined) | |
| 85 | shapes.push({ t: "arc", c: [x, y], r, from, to }); | |
| 86 | break; | |
| 87 | } | |
| 88 | case "LWPOLYLINE": { | |
| 89 | const pts: Pt[] = []; | |
| 90 | let px: number | undefined; | |
| 91 | for (const [code, v] of e.codes) { | |
| 92 | if (code === 10) px = num(v); | |
| 93 | else if (code === 20 && px !== undefined) { | |
| 94 | const py = num(v); | |
| 95 | if (py !== undefined) pts.push([px, py]); | |
| 96 | px = undefined; | |
| 97 | } | |
| 98 | } | |
| 99 | const flags = first(70) ?? 0; | |
| 100 | if (pts.length >= 2) shapes.push({ t: "poly", pts, closed: (flags & 1) === 1 }); | |
| 101 | break; | |
| 102 | } | |
| 103 | } | |
| 104 | }; | |
| 105 | ||
| 106 | for (let i = 0; i + 1 < lines.length; i += 2) { | |
| 107 | const code = Number(lines[i]!.trim()); | |
| 108 | const value = lines[i + 1]!.trim(); | |
| 109 | if (!Number.isInteger(code)) return { error: "it is not group-code, value pairs" }; | |
| 110 | if (code === 0) { | |
| 111 | finish(); | |
| 112 | if (value === "EOF") { | |
| 113 | sawEof = true; | |
| 114 | break; | |
| 115 | } | |
| 116 | if (value === "SECTION") { | |
| 117 | expectName = true; | |
| 118 | inEntities = false; | |
| 119 | } else if (value === "ENDSEC") inEntities = false; | |
| 120 | else if (inEntities) cur = { type: value.slice(0, NAME_CHARS), layer: "0", codes: [] }; | |
| 121 | continue; | |
| 122 | } | |
| 123 | if (expectName && code === 2) { | |
| 124 | expectName = false; | |
| 125 | inEntities = value === "ENTITIES"; | |
| 126 | if (inEntities) sawEntities = true; | |
| 127 | continue; | |
| 128 | } | |
| 129 | if (cur === undefined) continue; | |
| 130 | if (code === 8) cur.layer = value.slice(0, NAME_CHARS); | |
| 131 | else if (cur.codes.length < 100_000) cur.codes.push([code, value]); | |
| 132 | } | |
| 133 | if (!sawEntities) return { error: "it has no ENTITIES section" }; | |
| 134 | if (!sawEof) return { error: "it is cut short (no EOF)" }; | |
| 135 | if (shapes.length === 0) return { error: "it has no line, circle, arc or polyline to draw" }; | |
| 136 | return { shapes, counts, layers: [...layers].sort() }; | |
| 137 | } | |
| 138 | ||
| 139 | const W = 640; | |
| 140 | const MAX_H = 480; | |
| 141 | const PAD = 12; | |
| 142 | const rad = (d: number): number => (d * Math.PI) / 180; | |
| 143 | const norm360 = (d: number): number => ((d % 360) + 360) % 360; | |
| 144 | ||
| 145 | /** Corner points of a shape's extent, for the bounding box. */ | |
| 146 | function extent(s: Shape): Pt[] { | |
| 147 | switch (s.t) { | |
| 148 | case "line": | |
| 149 | return [s.a, s.b]; | |
| 150 | case "poly": | |
| 151 | return s.pts; | |
| 152 | case "circle": | |
| 153 | return [[s.c[0] - s.r, s.c[1] - s.r], [s.c[0] + s.r, s.c[1] + s.r]]; | |
| 154 | case "arc": { | |
| 155 | const span = norm360(s.to - s.from) || 360; | |
| 156 | const out: Pt[] = []; | |
| 157 | const at = (deg: number): void => void out.push([s.c[0] + s.r * Math.cos(rad(deg)), s.c[1] + s.r * Math.sin(rad(deg))]); | |
| 158 | at(s.from); | |
| 159 | at(s.from + span); | |
| 160 | for (const q of [0, 90, 180, 270]) if (norm360(q - s.from) <= span) at(q); | |
| 161 | return out; | |
| 162 | } | |
| 163 | } | |
| 164 | } | |
| 165 | ||
| 166 | /** The SVG body and its size, fitted to the shapes' bounds. */ | |
| 167 | export function draw(shapes: readonly Shape[]): { width: number; height: number; body: string; lines: number; rounds: number } { | |
| 168 | let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; | |
| 169 | for (const s of shapes) | |
| 170 | for (const p of extent(s)) { | |
| 171 | minX = Math.min(minX, p[0]); | |
| 172 | maxX = Math.max(maxX, p[0]); | |
| 173 | minY = Math.min(minY, p[1]); | |
| 174 | maxY = Math.max(maxY, p[1]); | |
| 175 | } | |
| 176 | const spanX = Math.max(maxX - minX, 1e-9); | |
| 177 | const spanY = Math.max(maxY - minY, 1e-9); | |
| 178 | let scale = (W - 2 * PAD) / spanX; | |
| 179 | let height = spanY * scale + 2 * PAD; | |
| 180 | if (height > MAX_H) { | |
| 181 | scale = (MAX_H - 2 * PAD) / spanY; | |
| 182 | height = MAX_H; | |
| 183 | } | |
| 184 | const width = Math.max(spanX * scale + 2 * PAD, 2 * PAD + 1); | |
| 185 | const f = (n: number): string => (Number.isFinite(n) ? n : 0).toFixed(2); | |
| 186 | const x = (v: number): string => f(PAD + (v - minX) * scale); | |
| 187 | const y = (v: number): string => f(PAD + (maxY - v) * scale); | |
| 188 | let d = "", circles = "", lines = 0, rounds = 0; | |
| 189 | for (const s of shapes) { | |
| 190 | if (s.t === "line") { | |
| 191 | d += `M${x(s.a[0])} ${y(s.a[1])}L${x(s.b[0])} ${y(s.b[1])}`; | |
| 192 | lines++; | |
| 193 | } else if (s.t === "poly") { | |
| 194 | d += s.pts.map((p, i) => `${i === 0 ? "M" : "L"}${x(p[0])} ${y(p[1])}`).join("") + (s.closed ? "Z" : ""); | |
| 195 | lines++; | |
| 196 | } else if (s.t === "circle") { | |
| 197 | circles += `<circle cx="${x(s.c[0])}" cy="${y(s.c[1])}" r="${f(s.r * scale)}"/>`; | |
| 198 | rounds++; | |
| 199 | } else { | |
| 200 | const span = norm360(s.to - s.from); | |
| 201 | const r = f(s.r * scale); | |
| 202 | const p = (deg: number): string => `${x(s.c[0] + s.r * Math.cos(rad(deg)))} ${y(s.c[1] + s.r * Math.sin(rad(deg)))}`; | |
| 203 | if (span === 0) circles += `<circle cx="${x(s.c[0])}" cy="${y(s.c[1])}" r="${r}"/>`; | |
| 204 | else d += `M${p(s.from)}A${r} ${r} 0 ${span > 180 ? 1 : 0} 0 ${p(s.from + span)}`; | |
| 205 | rounds++; | |
| 206 | } | |
| 207 | } | |
| 208 | const body = (d === "" ? "" : `<path d="${d}"/>`) + circles; | |
| 209 | return { width: Math.round(width), height: Math.round(height), body, lines, rounds }; | |
| 210 | } | |
| 211 | ||
| 212 | const plural = (n: number, one: string): string => `${n.toLocaleString("en")} ${one}${n === 1 ? "" : "s"}`; | |
| 213 | ||
| 214 | export function dxf(input: RenderInput): Rendered { | |
| 215 | const fall = (why: string): Rendered => ({ content: html``, lines: 0, fallback: `This file is shown as text because ${why}.` }); | |
| 216 | if (input.size > RENDER_CAP.dxf || input.body.length > RENDER_CAP.dxf) return fall("it is too large to draw"); | |
| 217 | const parsed = parseDxf(new TextDecoder().decode(input.body)); | |
| 218 | if ("error" in parsed) return fall(parsed.error); | |
| 219 | const drawn = draw(parsed.shapes); | |
| 220 | const entries = [...parsed.counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])); | |
| 221 | const total = entries.reduce((n, e) => n + e[1], 0); | |
| 222 | const shownLayers = parsed.layers.slice(0, LAYER_SHOWN); | |
| 223 | const more = parsed.layers.length - shownLayers.length; | |
| 224 | const facts: [string, string][] = [ | |
| 225 | ["Entities", `${total.toLocaleString("en")} (${entries.map(([t, n]) => `${n.toLocaleString("en")} ${t}`).join(", ")})`], | |
| 226 | ["Layers", shownLayers.join(", ") + (more > 0 ? ` and ${more} more` : "")], | |
| 227 | ["Units", "drawing units, as stored; the file does not say which"], | |
| 228 | ]; | |
| 229 | const label = `Drawing of ${plural(total, "entity").replace("entitys", "entities")}`; | |
| 230 | const content = html`<figure class="dxf-view"> | |
| 231 | <svg viewBox="0 0 ${drawn.width} ${drawn.height}" role="img" aria-label="${label}" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" fill="none">${raw(drawn.body)}</svg> | |
| 232 | <p class="muted small">Only LINE, CIRCLE, ARC and LWPOLYLINE are drawn, in one colour; polyline bulges are drawn straight. Other entities are counted, not drawn.${total > ENTITY_CAP ? ` Past ${ENTITY_CAP.toLocaleString("en")} entities nothing more is drawn.` : ""}</p> | |
| 233 | <dl class="status-rows">${facts.map(([k, v]) => html`<div class="status-row"><dt>${k}</dt><dd>${v}</dd></div>`)}</dl> | |
| 234 | </figure>`; | |
| 235 | return { content, lines: 0, summary: `${plural(total, "entity").replace("entitys", "entities")} · ${fileSize(input.size)}` }; | |
| 236 | } |
src/web/render/kinds.ts +8 −1
| @@ -38,6 +38,7 @@ export type FileKind = | ||
| 38 | 38 | | "contacts" |
| 39 | 39 | | "mail" |
| 40 | 40 | | "gcode" |
| 41 | | "dxf" | |
| 41 | 42 | | "sarif" |
| 42 | 43 | | "lcov" |
| 43 | 44 | | "junit" |
| @@ -86,6 +87,7 @@ export const RENDER_CAP: Readonly<Record<FileKind, number>> = { | ||
| 86 | 87 | contacts: 5 * MB, |
| 87 | 88 | mail: 5 * MB, |
| 88 | 89 | gcode: 50 * MB, |
| 90 | dxf: 5 * MB, | |
| 89 | 91 | sarif: 5 * MB, |
| 90 | 92 | lcov: 5 * MB, |
| 91 | 93 | junit: 5 * MB, |
| @@ -220,6 +222,7 @@ const TEXTUAL: Readonly<Record<string, FileKind>> = { | ||
| 220 | 222 | eml: "mail", |
| 221 | 223 | gcode: "gcode", |
| 222 | 224 | gco: "gcode", |
| 225 | dxf: "dxf", | |
| 223 | 226 | sarif: "sarif", |
| 224 | 227 | lcov: "lcov", |
| 225 | 228 | sql: "sql", |
| @@ -269,6 +272,10 @@ const isArchive = (path: string, head: Uint8Array): boolean => { | ||
| 269 | 272 | return ARCHIVES[ext]?.(head) === true; |
| 270 | 273 | }; |
| 271 | 274 | |
| 275 | /** An ASCII DXF opens on group code 0, then `SECTION`, whatever the file is named. */ | |
| 276 | const isDxfHead = (head: Uint8Array): boolean => | |
| 277 | /^\uFEFF?\s*0\r?\nSECTION\r?\n/.test(new TextDecoder().decode(head.subarray(0, 64))); | |
| 278 | ||
| 272 | 279 | /** For a caller that has only a name: the kind its extension gives. */ |
| 273 | 280 | export const NO_BYTES: Uint8Array = new Uint8Array(0); |
| 274 | 281 | |
| @@ -325,7 +332,7 @@ export function fileKind(path: string, head: Uint8Array): FileKind { | ||
| 325 | 332 | if (isDockerfileName(path)) return "dockerfile"; |
| 326 | 333 | if (isWorkflowName(path)) return "workflow"; |
| 327 | 334 | if (isComposeName(path)) return "compose"; |
| 328 | return TEXTUAL[extension(path)] ?? "text"; | |
| 335 | return TEXTUAL[extension(path)] ?? (isDxfHead(head) ? "dxf" : "text"); | |
| 329 | 336 | } |
| 330 | 337 | |
| 331 | 338 | /** Kinds whose renderer links paths to files in the repository, so the page reads the ref's file list. */ |
src/web/render/renderers.ts +2
| @@ -16,6 +16,7 @@ import { graphql } from "./graphql.ts"; | ||
| 16 | 16 | import { eml } from "./eml.ts"; |
| 17 | 17 | import { csv } from "./csv.ts"; |
| 18 | 18 | import { geojson } from "./geojson.ts"; |
| 19 | import { dxf } from "./dxf.ts"; | |
| 19 | 20 | import { gcode } from "./gcode.ts"; |
| 20 | 21 | import { gpx } from "./gpx.ts"; |
| 21 | 22 | import { har } from "./har.ts"; |
| @@ -102,6 +103,7 @@ export const RENDERERS: Partial<Readonly<Record<FileKind, Renderer>>> = { | ||
| 102 | 103 | gpx, |
| 103 | 104 | har, |
| 104 | 105 | gcode, |
| 106 | dxf, | |
| 105 | 107 | geojson, |
| 106 | 108 | calendar: ics, |
| 107 | 109 | contacts: vcf, |
src/web/style/content.ts +3
| @@ -524,6 +524,9 @@ form.inline { display: inline; margin-left: auto; } | ||
| 524 | 524 | .har-view { overflow-x: auto; } |
| 525 | 525 | .har-view .har-bar { display: block; width: 100%; height: 10px; margin-top: 4px; color: var(--muted-fg); } |
| 526 | 526 | .har-view .har-url { overflow-wrap: anywhere; min-width: 200px; } |
| 527 | .dxf-view { margin: 0; } | |
| 528 | .dxf-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } | |
| 529 | .dxf-view .status-rows { margin-top: 12px; } | |
| 527 | 530 | .gj-view { margin: 0; } |
| 528 | 531 | .gj-view svg { display: block; max-width: 100%; height: auto; margin: 0 auto; color: var(--fg); } |
| 529 | 532 | .gj-table { overflow-x: auto; max-width: 100%; } |
test/fuzz-renderers.test.ts +6
| @@ -15,6 +15,7 @@ import { ics } from "../src/web/render/ics.ts"; | ||
| 15 | 15 | import { eml } from "../src/web/render/eml.ts"; |
| 16 | 16 | import { vcf } from "../src/web/render/vcf.ts"; |
| 17 | 17 | import { gpx } from "../src/web/render/gpx.ts"; |
| 18 | import { dxf } from "../src/web/render/dxf.ts"; | |
| 18 | 19 | import { geojson } from "../src/web/render/geojson.ts"; |
| 19 | 20 | import { subtitles } from "../src/web/render/subtitles.ts"; |
| 20 | 21 | import { patch } from "../src/web/render/patch.ts"; |
| @@ -105,6 +106,11 @@ const SAMPLES: Record<string, { path: string; render: Renderer; files: readonly | ||
| 105 | 106 | '<?xml version="1.0"?><gpx version="1.1" creator="x"><metadata><name>Walk</name><link href="https://a.test"/></metadata><trk><name>T</name><trkseg><trkpt lat="51.5" lon="-0.1"><ele>10</ele><time>2026-01-01T10:00:00Z</time></trkpt><trkpt lat="51.6" lon="-0.2"><ele>12</ele></trkpt></trkseg></trk><wpt lat="51.5" lon="-0.1"><name>W</name><desc>d</desc></wpt></gpx>', |
| 106 | 107 | ], |
| 107 | 108 | }, |
| 109 | dxf: { | |
| 110 | path: "part.dxf", | |
| 111 | render: dxf, | |
| 112 | files: ["0\nSECTION\n2\nENTITIES\n0\nLINE\n8\n<script>alert(1)</script>\n10\n0\n20\n0\n11\n5\n21\n5\n0\nCIRCLE\n10\n1\n20\n1\n40\n2\n0\nENDSEC\n0\nEOF\n"], | |
| 113 | }, | |
| 108 | 114 | geojson: { |
| 109 | 115 | path: "map.geojson", |
| 110 | 116 | render: geojson, |
test/render-dxf.test.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** DXF drawings as inline SVG. */ | |
| 2 | import { describe, expect, test } from "bun:test"; | |
| 3 | import { dxf, parseDxf } from "../src/web/render/dxf.ts"; | |
| 4 | import { fileKind, RENDER_CAP } from "../src/web/render/kinds.ts"; | |
| 5 | import { RENDERERS } from "../src/web/render/renderers.ts"; | |
| 6 | ||
| 7 | const enc = (s: string): Uint8Array => new TextEncoder().encode(s); | |
| 8 | const pairs = (...p: (string | number)[]): string => p.join("\n") + "\n"; | |
| 9 | const file = (entities: string, eof = true): string => | |
| 10 | pairs(0, "SECTION", 2, "HEADER", 0, "ENDSEC", 0, "SECTION", 2, "ENTITIES") + entities + pairs(0, "ENDSEC") + (eof ? pairs(0, "EOF") : ""); | |
| 11 | const draw = (text: string) => dxf({ path: "r/a.dxf", size: text.length, body: enc(text), media: "/m", download: "/d" }); | |
| 12 | ||
| 13 | const ALL = file( | |
| 14 | pairs(0, "LINE", 8, "Walls", 10, 0, 20, 0, 11, 100, 21, 0) + | |
| 15 | pairs(0, "CIRCLE", 8, "Holes", 10, 50, 20, 50, 40, 10) + | |
| 16 | pairs(0, "ARC", 8, "Walls", 10, 0, 20, 0, 40, 20, 50, 0, 51, 90) + | |
| 17 | pairs(0, "LWPOLYLINE", 8, "Outline", 70, 1, 10, 0, 20, 0, 10, 10, 20, 0, 10, 10, 20, 10) + | |
| 18 | pairs(0, "TEXT", 8, "Notes", 10, 1, 20, 1, 1, "hello"), | |
| 19 | ); | |
| 20 | ||
| 21 | describe("dxf", () => { | |
| 22 | test("registered, by name and by head", () => { | |
| 23 | expect(fileKind("a.dxf", enc(ALL))).toBe("dxf"); | |
| 24 | expect(fileKind("drawing.txt", enc(ALL))).toBe("dxf"); | |
| 25 | expect(fileKind("notes.txt", enc("hello\n"))).toBe("text"); | |
| 26 | expect(RENDERERS.dxf).toBe(dxf); | |
| 27 | expect(RENDER_CAP.dxf).toBeGreaterThan(0); | |
| 28 | }); | |
| 29 | ||
| 30 | test("one of each entity gives its elements and facts", () => { | |
| 31 | const out = String(draw(ALL).content); | |
| 32 | expect(out.match(/<svg/g)?.length).toBe(1); | |
| 33 | expect(out.match(/<circle/g)?.length).toBe(1); | |
| 34 | expect(out.match(/<path/g)?.length).toBe(1); | |
| 35 | // line + arc + polyline subpaths: three M commands | |
| 36 | expect(out.match(/M/g)?.length).toBe(3); | |
| 37 | expect(out).toContain("A"); // the arc command | |
| 38 | expect(out).toContain("Z"); // closed polyline | |
| 39 | expect(out).toContain("5 (1 ARC, 1 CIRCLE, 1 LINE, 1 LWPOLYLINE, 1 TEXT)"); | |
| 40 | expect(out).toContain("Holes, Notes, Outline, Walls"); | |
| 41 | expect(out).not.toMatch(/<svg[^>]*\s(width|height)=/); | |
| 42 | }); | |
| 43 | ||
| 44 | test("the drawing is scaled to its bounding box, y flipped", () => { | |
| 45 | const out = String(draw(file(pairs(0, "LINE", 10, 0, 20, 0, 11, 100, 21, 50))).content); | |
| 46 | expect(out).toContain("M12.00 "); // west edge at the padding | |
| 47 | expect(out).toContain('viewBox="0 0 640 '); | |
| 48 | // (0,0) is the bottom: its y is larger than the top end's y (50) | |
| 49 | const m = /M12\.00 ([\d.]+)L[\d.]+ ([\d.]+)/.exec(out)!; | |
| 50 | expect(Number(m[1])).toBeGreaterThan(Number(m[2])); | |
| 51 | }); | |
| 52 | ||
| 53 | test("empty, truncated and header-only files fall back plainly", () => { | |
| 54 | for (const text of ["", "0\nSECTION\n", file("", true), file(pairs(0, "LINE", 10, 0, 20, 0, 11, 1, 21, 1), false), "hello world\nnot dxf\nat all\nreally\n"]) { | |
| 55 | const r = draw(text); | |
| 56 | expect(r.fallback).toBeString(); | |
| 57 | expect(r.fallback!.length).toBeGreaterThan(10); | |
| 58 | } | |
| 59 | }); | |
| 60 | ||
| 61 | test("a 10 MB file is refused, not parsed", () => { | |
| 62 | const body = new Uint8Array(10_000_000).fill(0x30); | |
| 63 | const r = dxf({ path: "a.dxf", size: body.length, body, media: "/m", download: "/d" }); | |
| 64 | expect(r.fallback).toContain("too large"); | |
| 65 | }); | |
| 66 | ||
| 67 | test("NaN, Infinity and absurd numbers never reach the SVG", () => { | |
| 68 | const bad = file( | |
| 69 | pairs(0, "LINE", 10, "NaN", 20, 0, 11, 1, 21, 1) + | |
| 70 | pairs(0, "LINE", 10, "Infinity", 20, 0, 11, 1, 21, 1) + | |
| 71 | pairs(0, "CIRCLE", 10, 0, 20, 0, 40, 1e300) + | |
| 72 | pairs(0, "LINE", 10, 0, 20, 0, 11, 4, 21, 3), | |
| 73 | ); | |
| 74 | const parsed = parseDxf(bad); | |
| 75 | if ("error" in parsed) throw new Error(parsed.error); | |
| 76 | expect(parsed.shapes.length).toBe(1); | |
| 77 | const out = String(draw(bad).content); | |
| 78 | expect(out).not.toMatch(/NaN|Infinity|e\+/); | |
| 79 | }); | |
| 80 | ||
| 81 | test("layer names and entity types are escaped", () => { | |
| 82 | const out = String(draw(file(pairs(0, "LINE", 8, "<img src=x onerror=alert(1)>", 10, 0, 20, 0, 11, 1, 21, 1) + pairs(0, "<script>", 8, "a"))).content); | |
| 83 | expect(out).not.toContain("<img"); | |
| 84 | expect(out).not.toContain("<script"); | |
| 85 | expect(out).toContain("<img"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("an unknown entity is counted, not an error", () => { | |
| 89 | const r = draw(file(pairs(0, "LINE", 10, 0, 20, 0, 11, 1, 21, 1) + pairs(0, "SPLINE", 8, "0"))); | |
| 90 | expect(r.fallback).toBeUndefined(); | |
| 91 | expect(String(r.content)).toContain("1 SPLINE"); | |
| 92 | }); | |
| 93 | }); |
test/render-kinds.test.ts +1
| @@ -81,6 +81,7 @@ const SAMPLES: Record<string, [Uint8Array, FileKind, string]> = { | ||
| 81 | 81 | ics: [enc("BEGIN:VCALENDAR\nEND:VCALENDAR\n"), "calendar", "application/octet-stream"], |
| 82 | 82 | gcode: [enc("G28\nG1 X1 E1\n"), "gcode", "application/octet-stream"], |
| 83 | 83 | gco: [enc("G28\n"), "gcode", "application/octet-stream"], |
| 84 | dxf: [enc("0\nSECTION\n2\nENTITIES\n0\nENDSEC\n0\nEOF\n"), "dxf", "application/octet-stream"], | |
| 84 | 85 | har: [enc(`{"log":{"entries":[]}}`), "har", "application/octet-stream"], |
| 85 | 86 | sql: [enc("CREATE TABLE a (id int);\n"), "sql", "application/octet-stream"], |
| 86 | 87 | graphql: [enc("type A { b: Int }\n"), "graphql", "application/octet-stream"], |
new above
offline