@hraness/slopcamera 3.3.4 → 3.3.6
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/README.md +14 -4
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +61 -0
- package/apps/desktop/cli/capability-manifest.ts +4 -1
- package/apps/desktop/cli/command-host-resources.ts +7 -0
- package/apps/desktop/cli/commands.ts +550 -2
- package/apps/desktop/cli/credits.ts +818 -0
- package/apps/desktop/cli/help.ts +47 -4
- package/apps/desktop/dist/cli/main.js +327 -253
- package/dist/cli.js +14 -4
- package/dist/index-10bz03js.js +28 -0
- package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
- package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
- package/dist/index.js +1 -1
- package/dist/operations.js +1 -1
- package/dist/workflow.js +1 -1
- package/docs/README.md +1 -0
- package/package.json +1 -1
- package/schema/drawing.schema.json +27 -0
- package/skills/slopcamera/SKILL.md +1 -0
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/patent-drawings.md +53 -0
- package/src/cli.ts +90 -2
- package/src/drawing-pdf.ts +210 -0
- package/src/drawing.ts +293 -0
- package/src/index.ts +19 -0
- package/src/render.ts +6 -3
- package/src/version.ts +1 -1
- package/dist/index-c8jpst4g.js +0 -7
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** A closed adapter for the path-only SVG produced by the pinned resvg renderer. */
|
|
2
|
+
interface SvgNode { readonly tag: string; readonly attrs: Readonly<Record<string, string>>; readonly children: SvgNode[] }
|
|
3
|
+
|
|
4
|
+
function fail(message: string): never { throw new Error(`Unsupported drawing SVG: ${message}`) }
|
|
5
|
+
const maximumSvgBytes = 2 * 1024 * 1024
|
|
6
|
+
|
|
7
|
+
function attributes(value: string): Record<string, string> {
|
|
8
|
+
const attrs: Record<string, string> = {}
|
|
9
|
+
let rest = value
|
|
10
|
+
while (rest.trim().length > 0) {
|
|
11
|
+
const match = /^\s+([A-Za-z][A-Za-z0-9:-]*)="([^"<>]*)"/u.exec(rest)
|
|
12
|
+
if (match === null) fail("malformed attribute")
|
|
13
|
+
const name = match[1]!
|
|
14
|
+
if (Object.hasOwn(attrs, name)) fail("duplicate attribute")
|
|
15
|
+
attrs[name] = match[2]!
|
|
16
|
+
rest = rest.slice(match[0].length)
|
|
17
|
+
}
|
|
18
|
+
return attrs
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parseSvg(svg: string): SvgNode {
|
|
22
|
+
if (Buffer.byteLength(svg) > maximumSvgBytes) fail("sheet exceeds byte limit")
|
|
23
|
+
const roots: SvgNode[] = []
|
|
24
|
+
const stack: SvgNode[] = []
|
|
25
|
+
let cursor = 0
|
|
26
|
+
let count = 0
|
|
27
|
+
for (const token of svg.matchAll(/<[^>]*>/gu)) {
|
|
28
|
+
if (svg.slice(cursor, token.index).trim() !== "") fail("text outside a path")
|
|
29
|
+
cursor = token.index + token[0].length
|
|
30
|
+
const raw = token[0]
|
|
31
|
+
const closing = /^<\/([A-Za-z]+)>$/u.exec(raw)
|
|
32
|
+
if (closing !== null) {
|
|
33
|
+
if (stack.pop()?.tag !== closing[1]) fail("unbalanced tags")
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const match = /^<([A-Za-z]+)([\s\S]*?)(\/?)>$/u.exec(raw)
|
|
37
|
+
if (match === null) fail("unsupported markup")
|
|
38
|
+
const tag = match[1]!
|
|
39
|
+
if (!["svg", "defs", "clipPath", "g", "path"].includes(tag)) fail(`element ${tag}`)
|
|
40
|
+
if (++count > 50_000 || stack.length > 32) fail("tree limit exceeded")
|
|
41
|
+
const node: SvgNode = { tag, attrs: attributes(match[2]!), children: [] }
|
|
42
|
+
const parent = stack.at(-1)
|
|
43
|
+
if (parent === undefined) roots.push(node)
|
|
44
|
+
else parent.children.push(node)
|
|
45
|
+
if (match[3] !== "/") stack.push(node)
|
|
46
|
+
}
|
|
47
|
+
if (svg.slice(cursor).trim() !== "" || stack.length !== 0 || roots.length !== 1 || roots[0]?.tag !== "svg") fail("invalid document")
|
|
48
|
+
return roots[0]!
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function only(node: SvgNode, allowed: readonly string[]): void {
|
|
52
|
+
for (const key of Object.keys(node.attrs)) if (!allowed.includes(key)) fail(`${node.tag}.${key}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function number(value: string): number {
|
|
56
|
+
if (!/^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/u.test(value)) fail("invalid number")
|
|
57
|
+
const parsed = Number(value)
|
|
58
|
+
if (!Number.isFinite(parsed) || Math.abs(parsed) > 10_000_000) fail("number outside bounds")
|
|
59
|
+
return parsed
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const decimal = (value: number): string => Number(value.toFixed(6)).toString()
|
|
63
|
+
|
|
64
|
+
function pathCommands(data: string): string {
|
|
65
|
+
const tokens = data.match(/[MLCQZ]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/gu) ?? []
|
|
66
|
+
if (data.replace(/[MLCQZ]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?|[\s,]/gu, "") !== "") fail("path command")
|
|
67
|
+
if (tokens.length > 500_000) fail("path limit")
|
|
68
|
+
const result: string[] = []
|
|
69
|
+
let cursor = 0
|
|
70
|
+
let x = 0
|
|
71
|
+
let y = 0
|
|
72
|
+
let startX = 0
|
|
73
|
+
let startY = 0
|
|
74
|
+
const take = (): number => {
|
|
75
|
+
const token = tokens[cursor++]
|
|
76
|
+
if (token === undefined) fail("truncated path")
|
|
77
|
+
return number(token)
|
|
78
|
+
}
|
|
79
|
+
while (cursor < tokens.length) {
|
|
80
|
+
const command = tokens[cursor++]!
|
|
81
|
+
if (command === "M" || command === "L") {
|
|
82
|
+
x = take(); y = take()
|
|
83
|
+
if (command === "M") { startX = x; startY = y }
|
|
84
|
+
result.push(`${decimal(x)} ${decimal(y)} ${command === "M" ? "m" : "l"}`)
|
|
85
|
+
} else if (command === "C") {
|
|
86
|
+
const values = [take(), take(), take(), take(), take(), take()]
|
|
87
|
+
x = values[4]!; y = values[5]!
|
|
88
|
+
result.push(`${values.map(decimal).join(" ")} c`)
|
|
89
|
+
} else if (command === "Q") {
|
|
90
|
+
const qx = take(); const qy = take(); const nextX = take(); const nextY = take()
|
|
91
|
+
result.push(`${[x + 2 * (qx - x) / 3, y + 2 * (qy - y) / 3, nextX + 2 * (qx - nextX) / 3, nextY + 2 * (qy - nextY) / 3, nextX, nextY].map(decimal).join(" ")} c`)
|
|
92
|
+
x = nextX; y = nextY
|
|
93
|
+
} else if (command === "Z") {
|
|
94
|
+
result.push("h"); x = startX; y = startY
|
|
95
|
+
} else fail("non-absolute path command")
|
|
96
|
+
}
|
|
97
|
+
return result.join("\n")
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function color(value: string | undefined): "0" | "1" | undefined {
|
|
101
|
+
if (value === "none") return undefined
|
|
102
|
+
if (value === undefined || value === "#000000") return "0"
|
|
103
|
+
if (value === "#ffffff") return "1"
|
|
104
|
+
return fail("non-monochrome paint")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function svgStream(svg: string, width: number, height: number): string {
|
|
108
|
+
const root = parseSvg(svg)
|
|
109
|
+
only(root, ["width", "height", "viewBox", "xmlns"])
|
|
110
|
+
if (number((root.attrs.width ?? "").replace(/pt$/u, "")) !== width || number((root.attrs.height ?? "").replace(/pt$/u, "")) !== height || root.attrs.viewBox !== `0 0 ${width} ${height}` || root.attrs.xmlns !== "http://www.w3.org/2000/svg") fail("page geometry")
|
|
111
|
+
const clips = new Map<string, SvgNode>()
|
|
112
|
+
for (const defs of root.children.filter(node => node.tag === "defs")) {
|
|
113
|
+
only(defs, [])
|
|
114
|
+
for (const clip of defs.children) {
|
|
115
|
+
only(clip, ["id"])
|
|
116
|
+
if (clip.tag !== "clipPath" || clip.attrs.id === undefined || clips.has(clip.attrs.id) || clip.children.some(child => child.tag !== "path")) fail("clip definition")
|
|
117
|
+
clips.set(clip.attrs.id, clip)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const output: string[] = [`q\n1 0 0 -1 0 ${decimal(height)} cm`]
|
|
121
|
+
const emit = (node: SvgNode): void => {
|
|
122
|
+
if (node.tag === "defs") return
|
|
123
|
+
if (node.tag === "g") {
|
|
124
|
+
only(node, ["transform", "clip-path"])
|
|
125
|
+
output.push("q")
|
|
126
|
+
if (node.attrs.transform !== undefined) {
|
|
127
|
+
const match = /^matrix\(([^)]+)\)$/u.exec(node.attrs.transform)
|
|
128
|
+
if (match === null) fail("transform")
|
|
129
|
+
const values = match[1]!.trim().split(/[\s,]+/u).map(number)
|
|
130
|
+
if (values.length !== 6) fail("matrix")
|
|
131
|
+
output.push(`${values.map(decimal).join(" ")} cm`)
|
|
132
|
+
}
|
|
133
|
+
if (node.attrs["clip-path"] !== undefined) {
|
|
134
|
+
const id = /^url\(#([A-Za-z0-9_-]+)\)$/u.exec(node.attrs["clip-path"])?.[1]
|
|
135
|
+
const clip = id === undefined ? undefined : clips.get(id)
|
|
136
|
+
if (clip === undefined) fail("missing clip")
|
|
137
|
+
for (const path of clip.children) {
|
|
138
|
+
only(path, ["d", "fill", "stroke", "fill-rule", "clip-rule"])
|
|
139
|
+
if (path.children.length > 0) fail("path children")
|
|
140
|
+
output.push(pathCommands(path.attrs.d ?? ""))
|
|
141
|
+
}
|
|
142
|
+
const rules = new Set(clip.children.map(path => path.attrs["clip-rule"] ?? path.attrs["fill-rule"] ?? "nonzero"))
|
|
143
|
+
if (rules.size !== 1 || !["nonzero", "evenodd"].includes([...rules][0]!)) fail("clip rule")
|
|
144
|
+
output.push(rules.has("evenodd") ? "W* n" : "W n")
|
|
145
|
+
}
|
|
146
|
+
node.children.forEach(emit)
|
|
147
|
+
output.push("Q")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (node.tag !== "path") fail("nested non-path element")
|
|
151
|
+
only(node, ["d", "fill", "stroke", "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-miterlimit", "fill-rule", "paint-order"])
|
|
152
|
+
if (node.children.length > 0) fail("path children")
|
|
153
|
+
const fill = color(node.attrs.fill)
|
|
154
|
+
const stroke = color(node.attrs.stroke ?? "none")
|
|
155
|
+
const cap = ["butt", "round", "square"].indexOf(node.attrs["stroke-linecap"] ?? "butt")
|
|
156
|
+
const join = ["miter", "round", "bevel"].indexOf(node.attrs["stroke-linejoin"] ?? "miter")
|
|
157
|
+
const strokeWidth = number(node.attrs["stroke-width"] ?? "1")
|
|
158
|
+
const miter = number(node.attrs["stroke-miterlimit"] ?? "4")
|
|
159
|
+
if (cap < 0 || join < 0 || strokeWidth <= 0 || miter < 1) fail("stroke style")
|
|
160
|
+
const rule = node.attrs["fill-rule"] ?? "nonzero"
|
|
161
|
+
if (rule !== "nonzero" && rule !== "evenodd") fail("fill rule")
|
|
162
|
+
const paintOrder = node.attrs["paint-order"] ?? "normal"
|
|
163
|
+
if (!["normal", "stroke", "stroke fill", "fill stroke", "stroke fill markers", "fill stroke markers"].includes(paintOrder)) fail("paint order")
|
|
164
|
+
output.push("q", `${strokeWidth} w`, `${cap} J`, `${join} j`, `${miter} M`)
|
|
165
|
+
if (fill !== undefined) output.push(`${fill} g`)
|
|
166
|
+
if (stroke !== undefined) output.push(`${stroke} G`)
|
|
167
|
+
const path = pathCommands(node.attrs.d ?? "")
|
|
168
|
+
const fillOperator = rule === "evenodd" ? "f*" : "f"
|
|
169
|
+
if (paintOrder.startsWith("stroke") && stroke !== undefined && fill !== undefined) output.push(path, "S", path, fillOperator)
|
|
170
|
+
else output.push(path, fill === undefined ? (stroke === undefined ? "n" : "S") : stroke === undefined ? fillOperator : rule === "evenodd" ? "B*" : "B")
|
|
171
|
+
output.push("Q")
|
|
172
|
+
}
|
|
173
|
+
root.children.forEach(emit)
|
|
174
|
+
output.push("Q")
|
|
175
|
+
return output.join("\n")
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const pdfString = (value: string): string => `<FEFF${Buffer.from(value, "utf16le").swap16().toString("hex")}>`
|
|
179
|
+
|
|
180
|
+
export function drawingSheetsPdf(
|
|
181
|
+
sheets: readonly { readonly svg: string; readonly width: number; readonly height: number }[],
|
|
182
|
+
metadata: { readonly title: string; readonly sourceSha256: string; readonly rendererVersion: string },
|
|
183
|
+
): Uint8Array {
|
|
184
|
+
if (sheets.length < 1 || sheets.length > 20 || !/^[a-f0-9]{64}$/u.test(metadata.sourceSha256)) throw new Error("Invalid drawing PDF input")
|
|
185
|
+
const objects: string[] = ["", ""]
|
|
186
|
+
const pages: number[] = []
|
|
187
|
+
for (const sheet of sheets) {
|
|
188
|
+
const stream = svgStream(sheet.svg, sheet.width, sheet.height)
|
|
189
|
+
const streamId = objects.push(`<< /Length ${Buffer.byteLength(stream)} >>\nstream\n${stream}\nendstream`)
|
|
190
|
+
pages.push(objects.push(`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${decimal(sheet.width)} ${decimal(sheet.height)}] /Resources << >> /Contents ${streamId} 0 R >>`))
|
|
191
|
+
}
|
|
192
|
+
objects[0] = "<< /Type /Catalog /Pages 2 0 R >>"
|
|
193
|
+
objects[1] = `<< /Type /Pages /Count ${pages.length} /Kids [${pages.map(id => `${id} 0 R`).join(" ")}] >>`
|
|
194
|
+
const infoId = objects.push(`<< /Title ${pdfString(metadata.title)} /Producer ${pdfString(`Slopcamera ${metadata.rendererVersion}`)} /Subject ${pdfString(`source-sha256:${metadata.sourceSha256}; profile:patent-line-art-v1`)} >>`)
|
|
195
|
+
const chunks: string[] = ["%PDF-1.7\n%Slopcamera vector drawings\n"]
|
|
196
|
+
const offsets = [0]
|
|
197
|
+
let offset = Buffer.byteLength(chunks[0]!)
|
|
198
|
+
for (const [index, object] of objects.entries()) {
|
|
199
|
+
offsets.push(offset)
|
|
200
|
+
const chunk = `${index + 1} 0 obj\n${object}\nendobj\n`
|
|
201
|
+
chunks.push(chunk); offset += Buffer.byteLength(chunk)
|
|
202
|
+
}
|
|
203
|
+
// Each cross-reference entry is exactly 20 bytes, including its line ending.
|
|
204
|
+
const freeEntry = "0000000000 65535 f".padEnd(19, " ") + "\n"
|
|
205
|
+
const usedEntries = offsets.slice(1).map(value => `${String(value).padStart(10, "0")} 00000 n`.padEnd(19, " ") + "\n").join("")
|
|
206
|
+
chunks.push(`xref\n0 ${objects.length + 1}\n${freeEntry}${usedEntries}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R /Info ${infoId} 0 R >>\nstartxref\n${offset}\n%%EOF\n`)
|
|
207
|
+
const bytes = Buffer.from(chunks.join(""))
|
|
208
|
+
if (bytes.length > 32 * 1024 * 1024) throw new Error("Drawing PDF exceeds output byte limit")
|
|
209
|
+
return bytes
|
|
210
|
+
}
|
package/src/drawing.ts
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// eslint-disable-next-line @typescript-eslint/triple-slash-reference -- packed source types need Bun asset modules
|
|
2
|
+
/// <reference path="./assets.d.ts" />
|
|
3
|
+
import { createHash } from "node:crypto"
|
|
4
|
+
import { constants } from "node:fs"
|
|
5
|
+
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises"
|
|
6
|
+
import { dirname, isAbsolute, join, resolve } from "node:path"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
import { Resvg } from "@resvg/resvg-js"
|
|
9
|
+
import bookAsset from "./assets/fonts/nebula-sans/NebulaSans-Book.otf" with { type: "file" }
|
|
10
|
+
import boldAsset from "./assets/fonts/nebula-sans/NebulaSans-Bold.otf" with { type: "file" }
|
|
11
|
+
import { drawingSheetsPdf } from "./drawing-pdf.js"
|
|
12
|
+
import { layoutBoxContent } from "./label-layout.js"
|
|
13
|
+
import { parseDiagramSource, parseDiagramSpec } from "./parse.js"
|
|
14
|
+
import { renderSvg } from "./render.js"
|
|
15
|
+
import type { DiagramConfig, DiagramSource, DiagramSpec, ThemeColors } from "./types.js"
|
|
16
|
+
import { SLOPCAMERA_VERSION } from "./version.js"
|
|
17
|
+
|
|
18
|
+
export const drawingProfile = "patent-line-art-v1" as const
|
|
19
|
+
export const drawingSourceByteLimit = 1024 * 1024
|
|
20
|
+
export const drawingMinimumGlyphHeightPt = 3.2 * 72 / 25.4
|
|
21
|
+
export type DrawingPaper = "us-letter" | "a4"
|
|
22
|
+
export interface DrawingSource {
|
|
23
|
+
readonly $schema?: string
|
|
24
|
+
readonly version: 1
|
|
25
|
+
readonly name: string
|
|
26
|
+
readonly title?: string
|
|
27
|
+
readonly paper: DrawingPaper
|
|
28
|
+
readonly sheets: readonly { readonly figure: number; readonly diagram: DiagramSource }[]
|
|
29
|
+
}
|
|
30
|
+
export interface DrawingCheckResult {
|
|
31
|
+
readonly profile: typeof drawingProfile
|
|
32
|
+
readonly paper: DrawingPaper
|
|
33
|
+
readonly sheetCount: number
|
|
34
|
+
readonly minGlyphHeightPt: number
|
|
35
|
+
readonly findings: readonly []
|
|
36
|
+
}
|
|
37
|
+
export interface DrawingReceipt {
|
|
38
|
+
readonly version: 1
|
|
39
|
+
readonly profile: typeof drawingProfile
|
|
40
|
+
readonly rendererVersion: string
|
|
41
|
+
readonly sourceSha256: string
|
|
42
|
+
readonly paper: DrawingPaper
|
|
43
|
+
readonly minGlyphHeightPt: number
|
|
44
|
+
readonly sheets: readonly { readonly figure: number; readonly widthPt: number; readonly heightPt: number; readonly sha256: string }[]
|
|
45
|
+
readonly pdfSha256: string
|
|
46
|
+
}
|
|
47
|
+
export interface DrawingRenderResult {
|
|
48
|
+
readonly artifacts: { readonly source: string; readonly pdf: string; readonly sheets: readonly string[]; readonly receipt: string }
|
|
49
|
+
readonly checks: DrawingCheckResult
|
|
50
|
+
readonly receipt: DrawingReceipt
|
|
51
|
+
}
|
|
52
|
+
export class DrawingValidationError extends Error {
|
|
53
|
+
constructor(message: string) { super(`Invalid drawing source: ${message}`); this.name = "DrawingValidationError" }
|
|
54
|
+
}
|
|
55
|
+
function fail(message: string): never { throw new DrawingValidationError(message) }
|
|
56
|
+
const sha256 = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex")
|
|
57
|
+
const assetPath = (asset: string): string => isAbsolute(asset) ? asset : fileURLToPath(new URL(asset, import.meta.url))
|
|
58
|
+
const fontFiles = [assetPath(bookAsset), assetPath(boldAsset)]
|
|
59
|
+
const monochrome: ThemeColors = {
|
|
60
|
+
background: "#ffffff", foreground: "#000000", muted: "#000000", stroke: "#000000",
|
|
61
|
+
tones: Object.fromEntries(["neutral", "blue", "orange", "green", "red", "purple", "yellow"].map(tone => [tone, { fill: "#ffffff", stroke: "#000000", text: "#000000" }])) as ThemeColors["tones"],
|
|
62
|
+
}
|
|
63
|
+
const config: DiagramConfig = {
|
|
64
|
+
font: { family: "Nebula Sans", files: [{ path: fontFiles[0]!, weight: 400 }, { path: fontFiles[1]!, weight: 700 }] },
|
|
65
|
+
theme: { light: monochrome },
|
|
66
|
+
}
|
|
67
|
+
const escapeXml = (value: string): string => value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """)
|
|
68
|
+
|
|
69
|
+
export function drawingPageGeometry(paper: DrawingPaper) {
|
|
70
|
+
if (paper !== "us-letter" && paper !== "a4") fail("paper must be us-letter or a4")
|
|
71
|
+
const width = paper === "us-letter" ? 612 : 210 * 72 / 25.4
|
|
72
|
+
const height = paper === "us-letter" ? 792 : 297 * 72 / 25.4
|
|
73
|
+
const margins = { left: 25 * 72 / 25.4, top: 25 * 72 / 25.4, right: 15 * 72 / 25.4, bottom: 10 * 72 / 25.4 }
|
|
74
|
+
return { width, height, margins, numberingHeight: 30, body: { x: margins.left, y: margins.top + 30, width: width - margins.left - margins.right, height: height - margins.top - margins.bottom - 60 } }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function record(value: unknown, keys: readonly string[], at: string): Record<string, unknown> {
|
|
78
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(`${at} must be an object`)
|
|
79
|
+
const result = value as Record<string, unknown>
|
|
80
|
+
for (const key of Object.keys(result)) if (!keys.includes(key)) fail(`${at}.${key} is unsupported`)
|
|
81
|
+
return result
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function checkDiagramEnvelope(value: unknown): void {
|
|
85
|
+
const diagram = record(value, ["$schema", "version", "name", "canvas", "layout", "shapes", "edges"], "diagram")
|
|
86
|
+
const bounded = (object: Record<string, unknown>): void => {
|
|
87
|
+
for (const value of Object.values(object)) {
|
|
88
|
+
if (typeof value === "number" && (!Number.isFinite(value) || Math.abs(value) > 10_000)) fail("numeric drawing values must be finite and within 10000 points")
|
|
89
|
+
if (typeof value === "string" && value.length > 1024) fail("drawing strings exceed the bounded text limit")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
bounded(diagram)
|
|
93
|
+
bounded(record(diagram.canvas, ["width", "height", "padding"], "canvas"))
|
|
94
|
+
if (diagram.layout !== undefined) bounded(record(diagram.layout, ["type", "direction", "gap", "align"], "layout"))
|
|
95
|
+
if (!Array.isArray(diagram.shapes) || diagram.shapes.length < 1 || diagram.shapes.length > 100) fail("each sheet needs 1 to 100 shapes")
|
|
96
|
+
for (const value of diagram.shapes) {
|
|
97
|
+
const shape = record(value, ["id", "type", "x", "y", "tone", "opacity", "width", "height", "radius", "icon", "iconSize", "strokeWidth", "fill", "label", "labelFontSize", "labelFontFamily", "labelWeight", "labelRows", "labelRowGap", "text", "fontSize", "fontFamily", "weight", "align", "x2", "y2"], "shape")
|
|
98
|
+
bounded(shape)
|
|
99
|
+
const common = ["id", "type", "x", "y", "tone", "opacity"]
|
|
100
|
+
const extras = shape.type === "text" ? ["text", "width", "fontSize", "fontFamily", "weight", "align"] : shape.type === "line" ? ["x2", "y2", "strokeWidth"] : ["width", "height", "radius", "icon", "iconSize", "strokeWidth", "fill", "label", "labelFontSize", "labelFontFamily", "labelWeight", "labelRows", "labelRowGap"]
|
|
101
|
+
record(value, [...common, ...extras], "shape")
|
|
102
|
+
if (shape.labelRows !== undefined) {
|
|
103
|
+
if (!Array.isArray(shape.labelRows) || shape.labelRows.length > 4) fail("labelRows must be bounded")
|
|
104
|
+
for (const row of shape.labelRows) bounded(record(row, ["text", "fontSize", "fontFamily", "weight"], "label row"))
|
|
105
|
+
}
|
|
106
|
+
for (const key of ["label", "text"] as const) if (typeof shape[key] === "string" && shape[key].length > 500) fail("label exceeds text limit")
|
|
107
|
+
}
|
|
108
|
+
if (diagram.edges !== undefined) {
|
|
109
|
+
if (!Array.isArray(diagram.edges) || diagram.edges.length > 150) fail("at most 150 edges are supported")
|
|
110
|
+
for (const edge of diagram.edges) bounded(record(edge, ["id", "from", "to", "label", "tone", "start", "end", "bend", "arrowhead", "startPosition", "endPosition", "labelFontSize", "labelFontFamily", "labelWeight", "labelPosition", "labelOffset"], "edge"))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function parseDrawingSource(value: unknown): DrawingSource {
|
|
115
|
+
const raw = record(value, ["$schema", "version", "name", "title", "paper", "sheets"], "drawing")
|
|
116
|
+
if (raw.version !== 1) fail("version must be 1")
|
|
117
|
+
if (typeof raw.name !== "string" || raw.name.length > 100 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(raw.name)) fail("name must be a bounded lowercase hyphenated name")
|
|
118
|
+
if (raw.paper !== "us-letter" && raw.paper !== "a4") fail("paper must be us-letter or a4")
|
|
119
|
+
if (raw.title !== undefined && (typeof raw.title !== "string" || raw.title.length < 1 || Buffer.byteLength(raw.title) > 1024 || /[\u0000-\u001f]/u.test(raw.title))) fail("title must be a bounded metadata string")
|
|
120
|
+
if (raw.$schema !== undefined && typeof raw.$schema !== "string") fail("$schema must be a string")
|
|
121
|
+
if (!Array.isArray(raw.sheets) || raw.sheets.length < 1 || raw.sheets.length > 20) fail("sheets must contain 1 to 20 sheets")
|
|
122
|
+
const sheets = raw.sheets.map((sheet: unknown, index: number) => {
|
|
123
|
+
const entry = record(sheet, ["figure", "diagram"], `sheets[${index}]`)
|
|
124
|
+
if (entry.figure !== index + 1) fail("figure numbers must be consecutive beginning at 1")
|
|
125
|
+
checkDiagramEnvelope(entry.diagram)
|
|
126
|
+
const diagram = parseDiagramSource(entry.diagram)
|
|
127
|
+
const spec = parseDiagramSpec(diagram)
|
|
128
|
+
if (spec.shapes.length < 1 || spec.shapes.length > 100 || (spec.edges?.length ?? 0) > 150) fail("each sheet needs 1 to 100 shapes and at most 150 edges")
|
|
129
|
+
return { figure: index + 1, diagram }
|
|
130
|
+
})
|
|
131
|
+
const source: DrawingSource = { version: 1, name: raw.name, paper: raw.paper, sheets, ...(raw.title === undefined ? {} : { title: raw.title as string }), ...(raw.$schema === undefined ? {} : { $schema: raw.$schema as string }) }
|
|
132
|
+
if (Buffer.byteLength(JSON.stringify(source)) > drawingSourceByteLimit) fail("source exceeds 1MiB byte limit")
|
|
133
|
+
return source
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function renderer(svg: string): Resvg {
|
|
137
|
+
return new Resvg(svg, { font: { fontFiles, loadSystemFonts: false, defaultFontFamily: "Nebula Sans" }, logLevel: "off" })
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface InkBounds { readonly x: number; readonly y: number; readonly width: number; readonly height: number }
|
|
141
|
+
function bounds(engine: Resvg): InkBounds {
|
|
142
|
+
const box = engine.getBBox()
|
|
143
|
+
if (box === undefined) fail("empty drawing ink")
|
|
144
|
+
return { x: box.x, y: box.y, width: box.width, height: box.height }
|
|
145
|
+
}
|
|
146
|
+
function inside(ink: InkBounds, box: InkBounds, label: string): void {
|
|
147
|
+
const epsilon = 0.01
|
|
148
|
+
if (ink.x < box.x - epsilon || ink.y < box.y - epsilon || ink.x + ink.width > box.x + box.width + epsilon || ink.y + ink.height > box.y + box.height + epsilon) fail(`${label} ink extends beyond its bounds`)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function sceneSvg(spec: DiagramSpec): Promise<string> {
|
|
152
|
+
return (await renderSvg(spec, "light", config, { edgeStrokeWidth: 1 })).svg.replace('<rect width="100%" height="100%" fill="#ffffff"/>', "")
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function glyphHeight(character: string, fontSize: number, weight: number): number {
|
|
156
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000"><text x="100" y="500" font-family="Nebula Sans" font-size="${fontSize}" font-weight="${weight}">${escapeXml(character)}</text></svg>`
|
|
157
|
+
return bounds(renderer(svg)).height
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function inspectLabels(spec: DiagramSpec, glyphCache: Map<string, number>): number {
|
|
161
|
+
let minimum = Infinity
|
|
162
|
+
const inspect = (text: string, fontSize: number, weight: number, family?: string): void => {
|
|
163
|
+
if (text.length < 1 || text.length > 500 || !Array.from(text).every(character => /^[A-Z0-9 \n]$/u.test(character) || ".,:;()[]/+=_*'!?%-".includes(character))) fail("visible labels must be uppercase ASCII letters, numbers, spaces, or supported punctuation; labels are never rewritten")
|
|
164
|
+
if (family !== undefined && family !== "default") fail("only the bundled default font is supported")
|
|
165
|
+
if (!Number.isFinite(fontSize) || fontSize <= 0 || fontSize > 18) fail("authored font size must be positive and at most 18 points, below the 20 point sheet numbering")
|
|
166
|
+
for (const character of new Set(text.match(/[A-Z0-9]/gu) ?? [])) {
|
|
167
|
+
const key = `${character}:${fontSize}:${weight}`
|
|
168
|
+
const measured = glyphCache.get(key) ?? glyphHeight(character, fontSize, weight)
|
|
169
|
+
glyphCache.set(key, measured)
|
|
170
|
+
if (glyphCache.size > 4096) fail("glyph measurement limit exceeded")
|
|
171
|
+
minimum = Math.min(minimum, measured)
|
|
172
|
+
if (measured + 0.001 < drawingMinimumGlyphHeightPt) fail(`glyph ${character} has ${measured.toFixed(3)}pt ink height; minimum is ${drawingMinimumGlyphHeightPt.toFixed(3)}pt (3.2mm)`)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
for (const shape of spec.shapes) {
|
|
176
|
+
if (shape.tone !== undefined && shape.tone !== "neutral") fail("colored tones are unsupported; author monochrome relationships explicitly")
|
|
177
|
+
if (shape.opacity !== undefined && shape.opacity !== 1) fail("transparency is unsupported")
|
|
178
|
+
if (shape.type === "rect" || shape.type === "ellipse") {
|
|
179
|
+
if (shape.icon !== undefined) fail("icons are unsupported")
|
|
180
|
+
if ((shape.strokeWidth ?? 2) < 0.5 || (shape.strokeWidth ?? 2) > 4) fail("stroke width must be 0.5 to 4 points")
|
|
181
|
+
for (const row of layoutBoxContent(shape).rows) inspect(row.text, row.fontSize, row.weight, row.fontFamily)
|
|
182
|
+
} else if (shape.type === "text") inspect(shape.text, shape.fontSize ?? 24, shape.weight ?? 500, shape.fontFamily)
|
|
183
|
+
else if ((shape.strokeWidth ?? 3) < 0.5 || (shape.strokeWidth ?? 3) > 4) fail("stroke width must be 0.5 to 4 points")
|
|
184
|
+
}
|
|
185
|
+
for (const edge of spec.edges ?? []) {
|
|
186
|
+
if (edge.tone !== undefined && edge.tone !== "neutral") fail("colored edge tones are unsupported")
|
|
187
|
+
if (edge.label !== undefined) inspect(edge.label, edge.labelFontSize ?? 18, edge.labelWeight ?? 600, edge.labelFontFamily)
|
|
188
|
+
}
|
|
189
|
+
return minimum
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function renderDrawing(input: DrawingSource, sourceSha256?: string): Promise<{
|
|
193
|
+
readonly sheets: readonly { readonly svg: string; readonly width: number; readonly height: number; readonly figure: number }[]
|
|
194
|
+
readonly pdf: Uint8Array
|
|
195
|
+
readonly checks: DrawingCheckResult
|
|
196
|
+
}> {
|
|
197
|
+
const source = parseDrawingSource(input)
|
|
198
|
+
const sourceDigest = sourceSha256 ?? sha256(JSON.stringify(source))
|
|
199
|
+
if (!/^[a-f0-9]{64}$/u.test(sourceDigest)) fail("source SHA-256 is invalid")
|
|
200
|
+
const geometry = drawingPageGeometry(source.paper)
|
|
201
|
+
const sheets: { svg: string; width: number; height: number; figure: number }[] = []
|
|
202
|
+
const cache = new Map<string, number>()
|
|
203
|
+
let minGlyphHeightPt = Infinity
|
|
204
|
+
for (const sheet of source.sheets) {
|
|
205
|
+
const spec = parseDiagramSpec(sheet.diagram)
|
|
206
|
+
if (spec.canvas.width > geometry.body.width + 0.001 || spec.canvas.height > geometry.body.height + 0.001) fail(`figure ${sheet.figure} canvas exceeds ${geometry.body.width.toFixed(3)} by ${geometry.body.height.toFixed(3)}pt body; no automatic scaling is performed`)
|
|
207
|
+
minGlyphHeightPt = Math.min(minGlyphHeightPt, inspectLabels(spec, cache))
|
|
208
|
+
const sourceSvg = await sceneSvg(spec)
|
|
209
|
+
inside(bounds(renderer(sourceSvg)), { x: 0, y: 0, width: spec.canvas.width, height: spec.canvas.height }, `figure ${sheet.figure}`)
|
|
210
|
+
for (const shape of spec.shapes) {
|
|
211
|
+
if ((shape.type === "rect" || shape.type === "ellipse") && (shape.label !== undefined || shape.labelRows !== undefined)) {
|
|
212
|
+
const inset = (shape.strokeWidth ?? 2) / 2
|
|
213
|
+
const single = await sceneSvg({ ...spec, shapes: [shape], edges: [] })
|
|
214
|
+
inside(bounds(renderer(single)), { x: shape.x - inset, y: shape.y - inset, width: shape.width + inset * 2, height: shape.height + inset * 2 }, `shape ${shape.id} label`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const diagramBody = sourceSvg.replace(/^<svg[^>]*>/u, "").replace(/<\/svg>$/u, "")
|
|
218
|
+
const x = geometry.body.x + (geometry.body.width - spec.canvas.width) / 2
|
|
219
|
+
const counter = `${sheet.figure}/${source.sheets.length}`
|
|
220
|
+
const figureLabel = source.sheets.length === 1 ? "" : `<text x="${geometry.width / 2}" y="${geometry.height - geometry.margins.bottom - 8}" text-anchor="middle" font-size="20" font-family="Nebula Sans" font-weight="400">FIG. ${sheet.figure}</text>`
|
|
221
|
+
const page = `<svg xmlns="http://www.w3.org/2000/svg" width="${geometry.width}" height="${geometry.height}" viewBox="0 0 ${geometry.width} ${geometry.height}"><rect width="100%" height="100%" fill="#ffffff"/><text x="${geometry.width / 2}" y="${geometry.margins.top + 21}" text-anchor="middle" font-size="20" font-family="Nebula Sans" font-weight="400">${counter}</text><g transform="translate(${x} ${geometry.body.y})">${diagramBody}</g>${figureLabel}</svg>`
|
|
222
|
+
const outlined = renderer(page).toString().replace(/width="[^"]+" height="[^"]+" viewBox="[^"]+"/u, `width="${geometry.width}pt" height="${geometry.height}pt" viewBox="0 0 ${geometry.width} ${geometry.height}"`)
|
|
223
|
+
if (Buffer.byteLength(outlined) > 2 * 1024 * 1024) fail("sheet SVG exceeds output byte limit")
|
|
224
|
+
sheets.push({ svg: outlined, width: geometry.width, height: geometry.height, figure: sheet.figure })
|
|
225
|
+
}
|
|
226
|
+
// Numbering is subject to the same measured-glyph rule as authored content.
|
|
227
|
+
for (const character of "FIG0123456789") minGlyphHeightPt = Math.min(minGlyphHeightPt, glyphHeight(character, 20, 400))
|
|
228
|
+
if (minGlyphHeightPt + 0.001 < drawingMinimumGlyphHeightPt) fail("sheet numbering falls below minimum glyph height")
|
|
229
|
+
const checks: DrawingCheckResult = { profile: drawingProfile, paper: source.paper, sheetCount: source.sheets.length, minGlyphHeightPt, findings: [] }
|
|
230
|
+
return { sheets, checks, pdf: drawingSheetsPdf(sheets, { title: source.title ?? source.name, sourceSha256: sourceDigest, rendererVersion: SLOPCAMERA_VERSION }) }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function readDrawingFile(filePath: string) {
|
|
234
|
+
const absolutePath = resolve(filePath)
|
|
235
|
+
if (!absolutePath.endsWith(".drawing.json")) fail("source filename must end in .drawing.json")
|
|
236
|
+
const descriptor = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
|
237
|
+
let bytes: Buffer
|
|
238
|
+
try {
|
|
239
|
+
const info = await descriptor.stat()
|
|
240
|
+
if (!info.isFile()) fail("source must be a regular file")
|
|
241
|
+
if (info.size > drawingSourceByteLimit) fail("source exceeds 1MiB byte limit")
|
|
242
|
+
const buffer = Buffer.alloc(drawingSourceByteLimit + 1)
|
|
243
|
+
let cursor = 0
|
|
244
|
+
while (cursor < buffer.length) {
|
|
245
|
+
const result = await descriptor.read(buffer, cursor, buffer.length - cursor, cursor)
|
|
246
|
+
if (result.bytesRead === 0) break
|
|
247
|
+
cursor += result.bytesRead
|
|
248
|
+
}
|
|
249
|
+
if (cursor > drawingSourceByteLimit) fail("source exceeds 1MiB byte limit")
|
|
250
|
+
bytes = buffer.subarray(0, cursor)
|
|
251
|
+
} finally { await descriptor.close() }
|
|
252
|
+
const raw: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
|
|
253
|
+
return { absolutePath, source: parseDrawingSource(raw), sourceSha256: sha256(bytes) }
|
|
254
|
+
}
|
|
255
|
+
export async function checkDrawingFile(options: { readonly filePath: string }): Promise<DrawingCheckResult> {
|
|
256
|
+
const { source, sourceSha256 } = await readDrawingFile(options.filePath)
|
|
257
|
+
return (await renderDrawing(source, sourceSha256)).checks
|
|
258
|
+
}
|
|
259
|
+
async function atomicWrite(filePath: string, data: string | Uint8Array): Promise<void> {
|
|
260
|
+
const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`
|
|
261
|
+
try { await writeFile(temporary, data); await rename(temporary, filePath) }
|
|
262
|
+
catch (error) { await rm(temporary, { force: true }); throw error }
|
|
263
|
+
}
|
|
264
|
+
export async function renderDrawingFile(options: { readonly filePath: string; readonly outDirectory?: string }): Promise<DrawingRenderResult> {
|
|
265
|
+
const { absolutePath, source, sourceSha256 } = await readDrawingFile(options.filePath)
|
|
266
|
+
const result = await renderDrawing(source, sourceSha256)
|
|
267
|
+
const outDirectory = resolve(options.outDirectory ?? dirname(absolutePath))
|
|
268
|
+
const artifacts = { source: absolutePath, pdf: join(outDirectory, `${source.name}.pdf`), sheets: result.sheets.map((_, index) => join(outDirectory, `${source.name}.sheet-${String(index + 1).padStart(3, "0")}.svg`)), receipt: join(outDirectory, `${source.name}.drawing-receipt.json`) }
|
|
269
|
+
const receipt: DrawingReceipt = { version: 1, profile: drawingProfile, rendererVersion: SLOPCAMERA_VERSION, sourceSha256, paper: source.paper, minGlyphHeightPt: result.checks.minGlyphHeightPt, sheets: result.sheets.map(sheet => ({ figure: sheet.figure, widthPt: sheet.width, heightPt: sheet.height, sha256: sha256(sheet.svg) })), pdfSha256: sha256(result.pdf) }
|
|
270
|
+
await mkdir(outDirectory, { recursive: true })
|
|
271
|
+
// Validate every sheet before making any derived output visible; write receipt last.
|
|
272
|
+
for (const [index, sheet] of result.sheets.entries()) await atomicWrite(artifacts.sheets[index]!, sheet.svg)
|
|
273
|
+
await atomicWrite(artifacts.pdf, result.pdf)
|
|
274
|
+
await atomicWrite(artifacts.receipt, `${JSON.stringify(receipt, null, 2)}\n`)
|
|
275
|
+
return { artifacts, checks: result.checks, receipt }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function starterDrawingSource(): DrawingSource {
|
|
279
|
+
return {
|
|
280
|
+
$schema: "https://raw.githubusercontent.com/hraness/slopcamera/main/schema/drawing.schema.json",
|
|
281
|
+
version: 1, name: "sensor-control", title: "Fictional sensor and controller drawings", paper: "us-letter",
|
|
282
|
+
sheets: [
|
|
283
|
+
{ figure: 1, diagram: { version: 1, name: "sensor-system", canvas: { width: 480, height: 500 }, shapes: [
|
|
284
|
+
{ id: "sensor", type: "rect", x: 105, y: 40, width: 280, height: 100, radius: 0, label: "SENSOR 100", labelFontSize: 16 },
|
|
285
|
+
{ id: "controller", type: "rect", x: 105, y: 280, width: 280, height: 100, radius: 0, label: "CONTROLLER 200", labelFontSize: 16 },
|
|
286
|
+
], edges: [{ id: "signal", from: "sensor", to: "controller", label: "SIGNAL", labelFontSize: 16 }] } },
|
|
287
|
+
{ figure: 2, diagram: { version: 1, name: "control-flow", canvas: { width: 480, height: 500 }, shapes: [
|
|
288
|
+
{ id: "receive", type: "rect", x: 105, y: 40, width: 280, height: 100, radius: 0, label: "RECEIVE SIGNAL 210", labelFontSize: 16 },
|
|
289
|
+
{ id: "actuate", type: "rect", x: 105, y: 280, width: 280, height: 100, radius: 0, label: "SET OUTPUT 220", labelFontSize: 16 },
|
|
290
|
+
], edges: [{ id: "next", from: "receive", to: "actuate" }] } },
|
|
291
|
+
],
|
|
292
|
+
}
|
|
293
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,16 @@ import {
|
|
|
12
12
|
import { generateSlopcameraIcon } from "./icon.js"
|
|
13
13
|
import { generateSlopcameraImageGallery } from "./image-gallery.js"
|
|
14
14
|
import { builtInIcons } from "./icons.js"
|
|
15
|
+
import {
|
|
16
|
+
checkDrawingFile,
|
|
17
|
+
drawingPageGeometry,
|
|
18
|
+
DrawingValidationError,
|
|
19
|
+
parseDrawingSource,
|
|
20
|
+
readDrawingFile,
|
|
21
|
+
renderDrawing,
|
|
22
|
+
renderDrawingFile,
|
|
23
|
+
starterDrawingSource,
|
|
24
|
+
} from "./drawing.js"
|
|
15
25
|
import {
|
|
16
26
|
resolveDiagramSource,
|
|
17
27
|
resolveStackLayout,
|
|
@@ -57,6 +67,9 @@ export const slopcameraApi = Object.freeze({
|
|
|
57
67
|
builtInIcons,
|
|
58
68
|
bundledSkillPath,
|
|
59
69
|
checkDiagramFile,
|
|
70
|
+
checkDrawingFile,
|
|
71
|
+
drawingPageGeometry,
|
|
72
|
+
DrawingValidationError,
|
|
60
73
|
defineSlopcameraWorkflow,
|
|
61
74
|
DiagramValidationError,
|
|
62
75
|
generateSlopcameraImage,
|
|
@@ -76,8 +89,12 @@ export const slopcameraApi = Object.freeze({
|
|
|
76
89
|
mcpSourceByteLimit,
|
|
77
90
|
parseDiagramSource,
|
|
78
91
|
parseDiagramSpec,
|
|
92
|
+
parseDrawingSource,
|
|
79
93
|
readDiagramFile,
|
|
94
|
+
readDrawingFile,
|
|
80
95
|
renderDiagramFile,
|
|
96
|
+
renderDrawing,
|
|
97
|
+
renderDrawingFile,
|
|
81
98
|
renderPng,
|
|
82
99
|
renderSvg,
|
|
83
100
|
resolveEdge,
|
|
@@ -88,6 +105,7 @@ export const slopcameraApi = Object.freeze({
|
|
|
88
105
|
searchSlopcameraOperations,
|
|
89
106
|
serializeTldr,
|
|
90
107
|
stackLayoutDefaults,
|
|
108
|
+
starterDrawingSource,
|
|
91
109
|
StackLayoutError,
|
|
92
110
|
SlopcameraWorkflowError,
|
|
93
111
|
vectorizeImage,
|
|
@@ -144,6 +162,7 @@ export {
|
|
|
144
162
|
}
|
|
145
163
|
export * from "./capability-manifest.js"
|
|
146
164
|
export * from "./cloud-errors.js"
|
|
165
|
+
export * from "./drawing.js"
|
|
147
166
|
export * from "./generate.js"
|
|
148
167
|
export * from "./host-resources.js"
|
|
149
168
|
export * from "./icon.js"
|
package/src/render.ts
CHANGED
|
@@ -332,7 +332,7 @@ export function resolveEdgeLabel(resolved: ResolvedEdge): {
|
|
|
332
332
|
}
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
-
function edgeSvg(resolved: ResolvedEdge, theme: ThemeColors, families: FontFamilies): string {
|
|
335
|
+
function edgeSvg(resolved: ResolvedEdge, theme: ThemeColors, families: FontFamilies, strokeWidth: number): string {
|
|
336
336
|
const { edge, start, end, control } = resolved
|
|
337
337
|
const toneName = edge.tone ?? "neutral"
|
|
338
338
|
const tone = theme.tones[toneName]
|
|
@@ -348,7 +348,7 @@ function edgeSvg(resolved: ResolvedEdge, theme: ThemeColors, families: FontFamil
|
|
|
348
348
|
edge.label === undefined
|
|
349
349
|
? ""
|
|
350
350
|
: `<text x="${labelPoint.x}" y="${labelPoint.y}" text-anchor="middle" dominant-baseline="central" fill="${tone.text}" stroke="${theme.background}" stroke-width="6" paint-order="stroke" font-family="${escapeXml(families[edge.labelFontFamily ?? "default"])}" font-size="${edge.labelFontSize ?? 18}" font-weight="${edge.labelWeight ?? 600}">${escapeXml(edge.label)}</text>`
|
|
351
|
-
return `<g data-edge-id="${escapeXml(edge.id)}"><path d="${path}" fill="none" stroke="${tone.stroke}" stroke-width="
|
|
351
|
+
return `<g data-edge-id="${escapeXml(edge.id)}"><path d="${path}" fill="none" stroke="${tone.stroke}" stroke-width="${strokeWidth}" stroke-linecap="round" marker-end="${marker}"/>${label}</g>`
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
function shapeSvg(
|
|
@@ -382,7 +382,10 @@ export async function renderSvg(
|
|
|
382
382
|
spec: DiagramSpec,
|
|
383
383
|
mode: ColorMode,
|
|
384
384
|
config: DiagramConfig,
|
|
385
|
+
options: { readonly edgeStrokeWidth?: number } = {},
|
|
385
386
|
): Promise<RenderedDiagram> {
|
|
387
|
+
const edgeStrokeWidth = options.edgeStrokeWidth ?? 3
|
|
388
|
+
if (!Number.isFinite(edgeStrokeWidth) || edgeStrokeWidth < 0.5 || edgeStrokeWidth > 4) throw new Error("Edge stroke width must be between 0.5 and 4")
|
|
386
389
|
const theme = resolveTheme(mode, config)
|
|
387
390
|
const font = config.font ?? defaultFont
|
|
388
391
|
const families: FontFamilies = {
|
|
@@ -400,7 +403,7 @@ export async function renderSvg(
|
|
|
400
403
|
)
|
|
401
404
|
.join("")
|
|
402
405
|
const edgeMarkup = (spec.edges ?? [])
|
|
403
|
-
.map((edge) => edgeSvg(resolveEdge(spec, edge), theme, families))
|
|
406
|
+
.map((edge) => edgeSvg(resolveEdge(spec, edge), theme, families, edgeStrokeWidth))
|
|
404
407
|
.join("")
|
|
405
408
|
const shapeMarkup = spec.shapes
|
|
406
409
|
.map((shape) => shapeSvg(shape, theme, families, icons))
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const SLOPCAMERA_VERSION = "3.3.
|
|
1
|
+
export const SLOPCAMERA_VERSION = "3.3.6" as const
|