@hraness/slopcamera 3.3.3 → 3.3.5

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.
Files changed (46) hide show
  1. package/README.md +60 -26
  2. package/apps/desktop/application/directing-contract.ts +15 -3
  3. package/apps/desktop/application/directing-plan.ts +2 -1
  4. package/apps/desktop/application/operation.ts +1 -1
  5. package/apps/desktop/cli/args.ts +62 -1
  6. package/apps/desktop/cli/capability-manifest.ts +4 -1
  7. package/apps/desktop/cli/cinema-renderer.ts +9 -6
  8. package/apps/desktop/cli/command-host-resources.ts +7 -0
  9. package/apps/desktop/cli/commands.ts +555 -2
  10. package/apps/desktop/cli/credits.ts +818 -0
  11. package/apps/desktop/cli/directing-blob.ts +17 -12
  12. package/apps/desktop/cli/directing-media.ts +14 -4
  13. package/apps/desktop/cli/directing-quote.ts +29 -11
  14. package/apps/desktop/cli/directing-service.ts +6 -2
  15. package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
  16. package/apps/desktop/cli/gateway-media-service.ts +12 -2
  17. package/apps/desktop/cli/help.ts +51 -8
  18. package/apps/desktop/cli/main.ts +1 -1
  19. package/apps/desktop/cli/project-renderer.ts +23 -9
  20. package/apps/desktop/cli/spatial-scene-service.ts +1 -1
  21. package/apps/desktop/dist/cli/main.js +337 -257
  22. package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
  23. package/dist/cli.js +14 -4
  24. package/dist/index-11cb717y.js +28 -0
  25. package/dist/{index-296qhpf2.js → index-qtn3s421.js} +1 -1
  26. package/dist/{index-0bbesn4m.js → index-sy1n4zfy.js} +2 -2
  27. package/dist/index.js +1 -1
  28. package/dist/operations.js +1 -1
  29. package/dist/workflow.js +1 -1
  30. package/docs/README.md +7 -3
  31. package/docs/studio.md +63 -3
  32. package/package.json +13 -7
  33. package/schema/drawing.schema.json +27 -0
  34. package/skills/slopcamera/SKILL.md +1 -0
  35. package/skills/slopcamera/references/directing-video.md +3 -3
  36. package/skills/slopcamera/references/educational-video.md +1 -1
  37. package/skills/slopcamera/references/install.md +1 -1
  38. package/skills/slopcamera/references/patent-drawings.md +53 -0
  39. package/skills/slopcamera/references/workflows-sdk.md +1 -1
  40. package/src/cli.ts +90 -2
  41. package/src/drawing-pdf.ts +210 -0
  42. package/src/drawing.ts +293 -0
  43. package/src/index.ts +19 -0
  44. package/src/render.ts +6 -3
  45. package/src/version.ts +1 -1
  46. package/dist/index-3e1pcxb5.js +0 -7
@@ -1,6 +1,6 @@
1
1
  # Make an educational film
2
2
 
3
- Use current-source `studio init lesson --template manim-lesson` for Manim visuals, or combine existing diagrams and spatial scenes when they better express the subject. Read [native studio](native-studio.md) for retained jobs and [video projects](video-projects.md) for composition.
3
+ Use `studio init lesson --template manim-lesson` for Manim visuals, or combine existing diagrams and spatial scenes when they better express the subject. Read [native studio](native-studio.md) for retained jobs and [video projects](video-projects.md) for composition.
4
4
 
5
5
  Preserve supplied mathematical facts, units and labels. Separate an explanatory illustration from a measured simulation or formal proof. Use tested lesson helpers for layout, axes, typography, equations and camera framing; inspect the actual rendered notation and endpoints rather than trusting syntactically valid code.
6
6
 
@@ -14,7 +14,7 @@ command -v slopcamera
14
14
 
15
15
  A restricted shell can omit package-manager paths. Check known host installation paths before declaring a tool unavailable. If Bun is genuinely absent, follow its official [installation guide](https://bun.sh/docs/installation) within the user’s authorized setup scope. Do not switch package managers or pipe an unreviewed installer into a shell.
16
16
 
17
- Slopcamera installs from its verified release archive: `bun add --global https://github.com/hraness/slopcamera/releases/download/v3.3.2/hraness-slopcamera-3.3.2.tgz`, then `slopcamera doctor --json`. Building from source is the contributor path. Historical Atet archives do not install the renamed CLI; never substitute `slopcamera` into an old archive URL.
17
+ Slopcamera installs from its verified release archive: `bun add --global https://github.com/hraness/slopcamera/releases/download/v3.3.4/hraness-slopcamera-3.3.4.tgz`, then `slopcamera doctor --json`. Building from source is the contributor path. Historical Atet archives do not install the renamed CLI; never substitute `slopcamera` into an old archive URL.
18
18
 
19
19
  Use an existing compatible source build when available. Otherwise follow the [complete source-install guide](https://github.com/hraness/slopcamera/blob/main/docs/how-to/use-current-source.md): clone into a new directory, record its exact commit, install locked dependencies without lifecycle scripts, build the SDK and source CLI, then define the shell command against that checkout. A clone or skill installation alone does not install the executable. Do not alter an existing active checkout to satisfy this path.
20
20
 
@@ -0,0 +1,53 @@
1
+ # Prepare patent-style drawing sheets
2
+
3
+ Use `diagram sheets` for local monochrome vector drawing sheets with physical
4
+ page dimensions. These commands require current source; do not assume a
5
+ previously published archive includes them. Confirm `slopcamera help diagram`
6
+ lists the commands. If absent, follow [installation](install.md) for an explicit
7
+ source build rather than inventing a flag on ordinary diagram rendering.
8
+
9
+ ## Preserve the authored content
10
+
11
+ Find the existing `.drawing.json` first. It is the authority for labels,
12
+ reference numerals, geometry, relationships, and sheet order. Never infer an
13
+ invention, assign a component's reference numeral, or expand a claim while
14
+ changing its presentation. Keep private drawings and terminology out of public
15
+ examples and remote services.
16
+
17
+ The version-one drawing document contains `name`, `paper: "a4" | "us-letter"`,
18
+ an optional metadata-only `title`, and `sheets`. Each sheet has a consecutive
19
+ `figure` number beginning at one and an ordinary version-one `diagram` object.
20
+ There is one figure per sheet.
21
+
22
+ ```sh
23
+ slopcamera diagram sheets init sensor-control.drawing.json --json
24
+ slopcamera diagram sheets check sensor-control.drawing.json --json
25
+ slopcamera diagram sheets render sensor-control.drawing.json --out-dir drawings --json
26
+ ```
27
+
28
+ Run `init` only for new source; it refuses an existing destination. Rendering
29
+ returns artifact paths for the document-name PDF, per-sheet SVGs, and drawing
30
+ receipt. Retain the JSON and receipt. Make revisions in the source and rerender.
31
+
32
+ ## Fit and review the sheets
33
+
34
+ Coordinates and font sizes use points. The profile does not scale artwork to
35
+ fit. It uses one bundled outlined font, black strokes, a white page, and explicit
36
+ physical bounds. Uppercase ASCII letters and digits must have at least 3.2 mm of
37
+ visible height. Custom fonts, monospace roles, icons, color, and transparency are
38
+ outside this profile. Authored text is limited to 18 pt; sheet and figure numbers
39
+ use 20 pt. Preserve authored meaning when shortening labels or repositioning
40
+ elements.
41
+
42
+ Inspect every SVG and PDF sheet at full size and two-thirds size. Check labels,
43
+ connectors, reference-numeral consistency with the specification, and reading
44
+ order. Outlined text will not be selectable; inspect pixels and preserve the
45
+ source instead of treating text extraction as a visual check.
46
+
47
+ The profile follows physical constraints drawn from
48
+ [37 CFR 1.84](https://www.uspto.gov/web/offices/pac/mpep/consolidated_rules.pdf).
49
+ It uses US-style figure numbering, including omission of `FIG.` for a single
50
+ view. [PCT Rule 11](https://www.wipo.int/en/web/pct-system/texts/rules/r11) has its
51
+ own formatting requirements; A4 paper alone does not establish PCT compliance.
52
+ Report checked geometry and provenance separately from technical disclosure or
53
+ filing approval. A receipt does not establish patentability or legal compliance.
@@ -15,7 +15,7 @@ For custom local authoring, use `slopcamera code init workflow.ts`, inspect the
15
15
 
16
16
  Discover exact local inputs with `slopcamera operations list|show`. No host accepts arbitrary new operation registration or caller-selected shell/argv. The fixed `slopcamera.studio.run` is an explicit exception for previously retained native source: its bundle/job input remains typed, while executable paths and trusted-current-user authority belong to the host invocation. The checked `examples/studio/native-workflow.ts` uses `defineWorkflow` and `StudioRunInputSchema` from the local entrypoint.
17
17
 
18
- `media.ingest` needs an existing ordinary project. There is no public generic arbitrary-file SDK project-create operation. Follow [video projects](video-projects.md) for the supported recording, native, and directing entry paths, or [music videos](music-video.md) for the current-source `html render` CLI that creates a project from an authored scene and optional soundtrack. Never synthesize their receipts or depend on private constructors.
18
+ `media.ingest` needs an existing ordinary project. There is no public generic arbitrary-file SDK project-create operation. Follow [video projects](video-projects.md) for the supported recording, native, and directing entry paths, or [music videos](music-video.md) for the `html render` CLI that creates a project from an authored scene and optional soundtrack. Never synthesize their receipts or depend on private constructors.
19
19
 
20
20
  ## Inspect, approve and resume exact work
21
21
 
package/src/cli.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import type { HostResourceCoordinator } from "./host-resources.js"
37
37
  import { installSkill, type SkillScope, type SkillTarget } from "./skill-install.js"
38
38
  import { pathExists } from "./fs.js"
39
+ import { checkDrawingFile, renderDrawingFile, starterDrawingSource } from "./drawing.js"
39
40
  import { SLOPCAMERA_VERSION } from "./version.js"
40
41
  import { reportUsefulResult, type UsefulResultObserver } from "./support-completion.js"
41
42
  import { runProductSupportCommand, showProductSupportInvitation, standaloneSupportEnvironment } from "./support.js"
@@ -50,6 +51,9 @@ Usage:
50
51
  slopcamera diagram init [file]
51
52
  slopcamera diagram check <file> [--config <file>] [--strict]
52
53
  slopcamera diagram render <file> [--out-dir <directory>] [--config <file>] [--scale <number>]
54
+ slopcamera diagram sheets init <file.drawing.json> [--json]
55
+ slopcamera diagram sheets check <file.drawing.json> [--json]
56
+ slopcamera diagram sheets render <file.drawing.json> [--out-dir <directory>] [--json]
53
57
  slopcamera image vectorize <image> --output <file.svg> [--json] [--duotone <#rgb,#rgb>]
54
58
  slopcamera image generate <prompt> --output <file.png|jpg|webp> [--model <provider/model>] [--json]
55
59
  slopcamera image icon <subject> --output <file.svg> [--purpose <mark|illustration>]
@@ -66,7 +70,7 @@ Usage:
66
70
  slopcamera skill path
67
71
  slopcamera skill install [--target codex|claude|agents] [--scope user|project] [--force]
68
72
 
69
- Render writes the same five replaceable artifacts on every run:
73
+ Diagram render writes the same five replaceable artifacts on every run:
70
74
  <name>.tldr
71
75
  <name>.light.svg
72
76
  <name>.dark.svg
@@ -76,6 +80,11 @@ Render writes the same five replaceable artifacts on every run:
76
80
  The .tldr file is editable interchange for browser-based canvas tooling.
77
81
  Rendering does not require a desktop application or a bundled UI runtime.
78
82
 
83
+ Diagram sheets creates bounded black-and-white drawing sheets from a separate
84
+ .drawing.json source: one vector PDF, one outlined SVG per sheet, and a receipt.
85
+ Check is read-only; init never overwrites. Geometry checks do not certify legal
86
+ compliance. This surface is available through the CLI and root SDK.
87
+
79
88
  Vectorize adaptively traces a raster with a checksum-pinned VTracer binary.
80
89
  It enforces bounded input, decode, time, path, and output budgets and emits a
81
90
  safe path-only SVG (plus an internal vector alpha mask when fidelity requires).
@@ -251,13 +260,88 @@ function hostAdmissionOptions(
251
260
  : { hostResourceCoordinator: dependencies.hostResourceCoordinator }
252
261
  }
253
262
 
263
+ async function runDrawingSheets(
264
+ args: readonly string[],
265
+ dependencies: SlopcameraCliDependencies,
266
+ ): Promise<void> {
267
+ const [command, ...rest] = args
268
+ if (command !== "init" && command !== "check" && command !== "render") {
269
+ throw new Error("Use slopcamera diagram sheets init, check, or render <file.drawing.json>")
270
+ }
271
+ const positionals: string[] = []
272
+ const seen = new Set<string>()
273
+ let outDirectory: string | undefined
274
+ let json = false
275
+ for (let index = 0; index < rest.length; index += 1) {
276
+ const argument = rest[index]!
277
+ if (!argument.startsWith("-")) {
278
+ positionals.push(argument)
279
+ continue
280
+ }
281
+ if (argument !== "--json" && !(command === "render" && argument === "--out-dir")) {
282
+ throw new Error(`Unknown diagram sheets ${command} option: ${argument}`)
283
+ }
284
+ if (seen.has(argument)) throw new Error(`${argument} may be supplied at most once`)
285
+ seen.add(argument)
286
+ if (argument === "--json") {
287
+ json = true
288
+ } else {
289
+ const value = rest[index + 1]
290
+ if (value === undefined || value.startsWith("-") || value.trim().length === 0) {
291
+ throw new Error("--out-dir requires a directory")
292
+ }
293
+ outDirectory = value
294
+ index += 1
295
+ }
296
+ }
297
+ if (positionals.length !== 1 || positionals[0]!.trim().length === 0) {
298
+ throw new Error(`slopcamera diagram sheets ${command} requires exactly one drawing file`)
299
+ }
300
+ const filePath = resolve(positionals[0]!)
301
+ if (!filePath.endsWith(".drawing.json")) throw new Error("Drawing source must end in .drawing.json")
302
+ const log = dependencies.log ?? console.log
303
+ if (command === "init") {
304
+ try {
305
+ await writeFile(filePath, `${JSON.stringify(starterDrawingSource(), null, 2)}\n`, { flag: "wx" })
306
+ } catch (error) {
307
+ if (error instanceof Error && "code" in error && error.code === "EEXIST") {
308
+ throw new Error(`Refusing to overwrite existing file: ${filePath}`)
309
+ }
310
+ throw error
311
+ }
312
+ log(json ? JSON.stringify({ command: "diagram.sheets.init", source: filePath }) : `Created ${filePath}`)
313
+ reportUsefulResult(dependencies.onUsefulResult)
314
+ return
315
+ }
316
+ if (command === "check") {
317
+ const checks = await withSlopcameraOperationHostAdmission(
318
+ "slopcamera.diagram.check",
319
+ async () => await checkDrawingFile({ filePath }),
320
+ hostAdmissionOptions(dependencies),
321
+ )
322
+ log(json
323
+ ? JSON.stringify({ command: "diagram.sheets.check", source: filePath, ...checks })
324
+ : `Valid drawing: ${checks.sheetCount} sheet${checks.sheetCount === 1 ? "" : "s"} (${checks.paper}, ${checks.profile}).`)
325
+ return
326
+ }
327
+ const result = await withSlopcameraOperationHostAdmission(
328
+ "slopcamera.diagram.render",
329
+ async () => await renderDrawingFile({ filePath, ...(outDirectory === undefined ? {} : { outDirectory }) }),
330
+ hostAdmissionOptions(dependencies),
331
+ )
332
+ log(json
333
+ ? JSON.stringify({ command: "diagram.sheets.render", artifacts: result.artifacts, checks: result.checks })
334
+ : [result.artifacts.pdf, ...result.artifacts.sheets, result.artifacts.receipt].join("\n"))
335
+ reportUsefulResult(dependencies.onUsefulResult)
336
+ }
337
+
254
338
  function canonicalArguments(args: readonly string[]): readonly string[] {
255
339
  const [surface, subcommand, ...rest] = args
256
340
  if (surface === "diagram") {
257
341
  if (subcommand === "init" || subcommand === "check" || subcommand === "render") {
258
342
  return [subcommand, ...rest]
259
343
  }
260
- throw new Error("Use slopcamera diagram init, check, or render")
344
+ throw new Error("Use slopcamera diagram init, check, render, or sheets")
261
345
  }
262
346
  if (surface === "image") {
263
347
  if (
@@ -286,6 +370,10 @@ export async function main(
286
370
  args: readonly string[],
287
371
  dependencies: SlopcameraCliDependencies = {},
288
372
  ): Promise<void> {
373
+ if (args[0] === "diagram" && args[1] === "sheets") {
374
+ await runDrawingSheets(args.slice(2), dependencies)
375
+ return
376
+ }
289
377
  if (args[0] === "support") {
290
378
  process.exitCode = await runProductSupportCommand(args.slice(1),
291
379
  dependencies.supportEnvironment === undefined ? {} : { env: dependencies.supportEnvironment })
@@ -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
+ }