@hraness/slopcamera 3.8.1 → 3.9.0

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.
@@ -0,0 +1,695 @@
1
+ import {
2
+ canonicalJson,
3
+ compileProgram,
4
+ CONSTRUCTION_VERSION,
5
+ CONSTRUCTION_VERSION_V1,
6
+ createRecipe,
7
+ parseProgram,
8
+ programDigest,
9
+ RECIPE_VERSION,
10
+ renderConstruction,
11
+ replayRecipe,
12
+ type CompiledDrawing,
13
+ type ConstructionProgram,
14
+ type StyleOptions,
15
+ } from "@hraness/iconplace"
16
+ import { COLLECTION_VERSION } from "@hraness/iconplace/collections"
17
+ import {
18
+ createSceneRecipe,
19
+ DEFAULT_SCENE_RENDER,
20
+ parseScene,
21
+ replaySceneRecipe,
22
+ renderScene,
23
+ SCENE_RECIPE_VERSION,
24
+ SCENE_VERSION,
25
+ sceneFromComposition,
26
+ solveScene,
27
+ type SceneDocument,
28
+ type SceneRecipe,
29
+ type SceneRenderOptions,
30
+ type SceneSource,
31
+ } from "@hraness/iconplace/scene"
32
+ import {
33
+ BoundedFileError,
34
+ decodeUtf8Source,
35
+ hasPathCollision,
36
+ publishReplaceableFile,
37
+ readBoundedFile,
38
+ sha256Hex,
39
+ } from "./bounded-file.js"
40
+
41
+ /**
42
+ * Deterministic vector icon scenes behind the `slopcamera.icon.compose` and
43
+ * `slopcamera.icon.render` operations.
44
+ *
45
+ * Construction, scene solving, drawing and recipe replay come from the
46
+ * exact-version `@hraness/iconplace` library, called in process. Nothing is
47
+ * spawned, fetched or evaluated. Slopcamera owns the operation codes, input
48
+ * bounds, file publication, the inert-SVG gate and the receipts; the library's
49
+ * own document formats are accepted as foreign input and parsed strictly.
50
+ */
51
+
52
+ /** The exact admitted library release. A test pins this to the lockfile. */
53
+ export const slopcameraIconEngine = Object.freeze({
54
+ package: "@hraness/iconplace",
55
+ version: "0.1.0",
56
+ } as const)
57
+
58
+ export const slopcameraIconSceneLimits = Object.freeze({
59
+ sourceBytes: 1024 * 1024,
60
+ outputBytes: 4 * 1024 * 1024,
61
+ sizeMin: 16,
62
+ sizeMax: 1_024,
63
+ diagnostics: 64,
64
+ notes: 32,
65
+ })
66
+
67
+ /** Scene palettes. A test pins this list to the admitted library's. */
68
+ export const slopcameraIconPalettes = Object.freeze(["original", "ink", "earth", "night"] as const)
69
+ export type SlopcameraIconPalette = (typeof slopcameraIconPalettes)[number]
70
+
71
+ /** Construction-program drawing languages. A test pins this list to the admitted library's. */
72
+ export const slopcameraIconLanguages = Object.freeze([
73
+ "outline", "sketch", "pen", "crosshatch", "engraving", "stipple", "halftone",
74
+ "glyph", "flat", "shaded", "figure", "cutpaper", "woodcut", "offset",
75
+ ] as const)
76
+ export type SlopcameraIconLanguage = (typeof slopcameraIconLanguages)[number]
77
+ export const slopcameraIconDefaultLanguage: SlopcameraIconLanguage = "outline"
78
+
79
+ export type SlopcameraIconSourceFormat =
80
+ | "scene"
81
+ | "collection"
82
+ | "program"
83
+ | "scene-recipe"
84
+ | "program-recipe"
85
+
86
+ export type SlopcameraIconForm = "scene" | "program"
87
+
88
+ export class SlopcameraIconError extends Error {
89
+ readonly code: "INVALID_ICON_SOURCE" | "INVALID_ICON_INPUT" | "UNSAFE_ICON_OUTPUT"
90
+
91
+ constructor(code: SlopcameraIconError["code"], message: string) {
92
+ super(message)
93
+ this.name = "SlopcameraIconError"
94
+ this.code = code
95
+ }
96
+ }
97
+
98
+ export interface SlopcameraIconFileRecord {
99
+ readonly path: string
100
+ readonly sha256: string
101
+ readonly bytes: number
102
+ }
103
+
104
+ export interface SlopcameraIconSourceRecord extends SlopcameraIconFileRecord {
105
+ readonly format: SlopcameraIconSourceFormat
106
+ }
107
+
108
+ export interface SlopcameraIconDiagnostic {
109
+ readonly code: string
110
+ readonly message: string
111
+ readonly target: string | null
112
+ }
113
+
114
+ export interface SlopcameraIconGeometry {
115
+ readonly regions: number
116
+ readonly components: number
117
+ readonly apertures: number
118
+ readonly vertices: number
119
+ readonly inkArea: number
120
+ readonly bounds: readonly [number, number, number, number]
121
+ }
122
+
123
+ export interface SlopcameraIconComposeInput {
124
+ readonly sourcePath: string
125
+ readonly outputPath?: string
126
+ }
127
+
128
+ export interface SlopcameraIconComposeReceipt {
129
+ readonly receiptVersion: 1
130
+ readonly operation: "slopcamera.icon.compose"
131
+ readonly engine: typeof slopcameraIconEngine
132
+ readonly source: SlopcameraIconSourceRecord
133
+ readonly form: SlopcameraIconForm
134
+ readonly title: string
135
+ /** Scene nodes or program parts. */
136
+ readonly elementCount: number
137
+ readonly digests: {
138
+ /** Library digest of the canonical scene or construction program. */
139
+ readonly document: string
140
+ /** Library digest of the compiled geometry. */
141
+ readonly geometry: string
142
+ }
143
+ readonly geometry: SlopcameraIconGeometry
144
+ readonly diagnostics: readonly SlopcameraIconDiagnostic[]
145
+ /** The canonical solved document, written only when an output path was supplied. */
146
+ readonly output: SlopcameraIconFileRecord | null
147
+ }
148
+
149
+ export interface SlopcameraIconRenderInput {
150
+ readonly sourcePath: string
151
+ readonly outputPath: string
152
+ readonly recipePath?: string
153
+ readonly size?: number
154
+ readonly background?: boolean
155
+ readonly palette?: SlopcameraIconPalette
156
+ readonly language?: SlopcameraIconLanguage
157
+ }
158
+
159
+ export interface SlopcameraIconRenderReceipt {
160
+ readonly receiptVersion: 1
161
+ readonly operation: "slopcamera.icon.render"
162
+ readonly engine: typeof slopcameraIconEngine
163
+ readonly source: SlopcameraIconSourceRecord
164
+ readonly form: SlopcameraIconForm
165
+ /** True when the source was a recipe whose recorded digests were reproduced. */
166
+ readonly replayed: boolean
167
+ readonly title: string
168
+ readonly render: {
169
+ readonly size: number
170
+ readonly background: boolean
171
+ readonly palette: SlopcameraIconPalette | null
172
+ readonly language: string
173
+ }
174
+ readonly width: number
175
+ readonly height: number
176
+ readonly digests: {
177
+ readonly document: string
178
+ readonly geometry: string
179
+ readonly svg: string
180
+ }
181
+ readonly output: SlopcameraIconFileRecord
182
+ readonly recipe: SlopcameraIconFileRecord | null
183
+ readonly notes: readonly string[]
184
+ }
185
+
186
+ interface SceneReplay {
187
+ readonly recipe: SceneRecipe
188
+ readonly svg: string
189
+ readonly width: number
190
+ readonly height: number
191
+ readonly options: SceneRenderOptions
192
+ readonly style: StyleOptions
193
+ readonly notes: readonly string[]
194
+ }
195
+
196
+ interface ProgramReplay {
197
+ readonly svg: string
198
+ readonly style: StyleOptions
199
+ }
200
+
201
+ type LoadedIconSource =
202
+ | {
203
+ readonly form: "scene"
204
+ readonly format: "scene" | "collection" | "scene-recipe"
205
+ readonly scene: SceneDocument
206
+ /** A recipe's embedded component snapshot, independent of catalog changes. */
207
+ readonly sources?: SceneSource[]
208
+ readonly replay?: SceneReplay
209
+ }
210
+ | {
211
+ readonly form: "program"
212
+ readonly format: "program" | "program-recipe"
213
+ readonly drawing: CompiledDrawing
214
+ readonly replay?: ProgramReplay
215
+ }
216
+
217
+ function invalidSource(message: string): never {
218
+ throw new SlopcameraIconError("INVALID_ICON_SOURCE", message)
219
+ }
220
+
221
+ function invalidInput(message: string): never {
222
+ throw new SlopcameraIconError("INVALID_ICON_INPUT", message)
223
+ }
224
+
225
+ function libraryMessage(error: unknown): string {
226
+ const message = error instanceof Error ? error.message : String(error)
227
+ // Library messages describe the rejected data; keep them short and printable.
228
+ const printable = [...message]
229
+ .filter((character) => {
230
+ const code = character.codePointAt(0) ?? 0
231
+ return code >= 0x20 && code !== 0x7f
232
+ })
233
+ .join("")
234
+ return printable.length > 240 ? `${printable.slice(0, 237)}...` : printable
235
+ }
236
+
237
+ function isRecord(value: unknown): value is Record<string, unknown> {
238
+ return typeof value === "object" && value !== null && !Array.isArray(value)
239
+ }
240
+
241
+ function parseSourceJson(bytes: Uint8Array): Record<string, unknown> {
242
+ let value: unknown
243
+ try {
244
+ value = JSON.parse(decodeUtf8Source(bytes, "Icon source"))
245
+ } catch (error) {
246
+ if (error instanceof BoundedFileError) throw error
247
+ invalidSource("Icon source is not valid JSON.")
248
+ }
249
+ if (!isRecord(value) || typeof value.version !== "string") {
250
+ invalidSource("Icon source must be a JSON object with a version field.")
251
+ }
252
+ return value
253
+ }
254
+
255
+ function loadIconSource(bytes: Uint8Array): LoadedIconSource {
256
+ const value = parseSourceJson(bytes)
257
+ const version = value.version as string
258
+ try {
259
+ if (version === SCENE_VERSION) {
260
+ return { form: "scene", format: "scene", scene: parseScene(value) }
261
+ }
262
+ if (version === COLLECTION_VERSION) {
263
+ return { form: "scene", format: "collection", scene: parseScene(sceneFromComposition(value)) }
264
+ }
265
+ if (version === CONSTRUCTION_VERSION || version === CONSTRUCTION_VERSION_V1) {
266
+ return { form: "program", format: "program", drawing: compileProgram(parseProgram(value)) }
267
+ }
268
+ if (version === SCENE_RECIPE_VERSION) {
269
+ const replayed = replaySceneRecipe(value)
270
+ return {
271
+ form: "scene",
272
+ format: "scene-recipe",
273
+ scene: replayed.recipe.scene,
274
+ sources: replayed.recipe.sources,
275
+ replay: {
276
+ recipe: replayed.recipe,
277
+ svg: replayed.svg,
278
+ width: replayed.width,
279
+ height: replayed.height,
280
+ options: replayed.options,
281
+ style: replayed.style,
282
+ notes: replayed.notes,
283
+ },
284
+ }
285
+ }
286
+ if (version === RECIPE_VERSION) {
287
+ const replayed = replayRecipe(value)
288
+ return {
289
+ form: "program",
290
+ format: "program-recipe",
291
+ drawing: replayed.drawing,
292
+ replay: { svg: replayed.svg, style: replayed.style },
293
+ }
294
+ }
295
+ } catch (error) {
296
+ invalidSource(`Icon source was rejected: ${libraryMessage(error)}`)
297
+ }
298
+ return invalidSource(
299
+ "Icon source version must be a scene, collection, construction program, or recipe version.",
300
+ )
301
+ }
302
+
303
+ function geometryOf(drawing: CompiledDrawing): SlopcameraIconGeometry {
304
+ const { facts } = drawing
305
+ return {
306
+ regions: facts.regions,
307
+ components: facts.components,
308
+ apertures: facts.apertures,
309
+ vertices: facts.vertices,
310
+ inkArea: facts.inkArea,
311
+ bounds: [facts.bounds[0], facts.bounds[1], facts.bounds[2], facts.bounds[3]],
312
+ }
313
+ }
314
+
315
+ /** Key-sorted, indented JSON so written documents diff and hash stably. */
316
+ function stableDocumentText(value: unknown): string {
317
+ return `${JSON.stringify(JSON.parse(canonicalJson(value)), null, 2)}\n`
318
+ }
319
+
320
+ const allowedSvgElements = new Set([
321
+ "svg", "g", "defs", "clipPath", "mask", "title", "desc",
322
+ "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
323
+ ])
324
+ const allowedSvgAttributes = new Set([
325
+ "xmlns", "width", "height", "viewBox", "role", "id", "d", "fill", "fill-rule",
326
+ "fill-opacity", "clip-path", "clip-rule", "mask", "opacity", "stroke",
327
+ "stroke-width", "stroke-linecap", "stroke-linejoin", "stroke-dasharray",
328
+ "stroke-opacity", "stroke-miterlimit", "transform", "x", "y", "rx", "ry",
329
+ "cx", "cy", "r", "x1", "y1", "x2", "y2", "points", "preserveAspectRatio",
330
+ ])
331
+
332
+ /**
333
+ * Admit only a closed vocabulary of static shape elements and presentation
334
+ * attributes. Scripts, styles, links, images, foreign objects, event
335
+ * handlers and non-local URL references fail closed.
336
+ */
337
+ export function assertInertIconSvg(svg: string): void {
338
+ if (Buffer.byteLength(svg, "utf8") > slopcameraIconSceneLimits.outputBytes) {
339
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG exceeds the output byte limit.")
340
+ }
341
+ if (!svg.startsWith("<svg ") || !svg.trimEnd().endsWith("</svg>")) {
342
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered output is not a single SVG document.")
343
+ }
344
+ if (/<!|<\?|&(?!(?:amp|lt|gt|quot|apos|#\d{1,7}|#x[0-9a-f]{1,6});)/iu.test(svg)) {
345
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG contains declarations or unknown entities.")
346
+ }
347
+ const ids = new Set<string>()
348
+ const references: string[] = []
349
+ for (const tag of scanSvgTags(svg)) {
350
+ if (!allowedSvgElements.has(tag.name)) {
351
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", `Rendered SVG contains a disallowed <${tag.name}> element.`)
352
+ }
353
+ for (const [attributeName, value] of tag.attributes) {
354
+ if (!allowedSvgAttributes.has(attributeName)) {
355
+ throw new SlopcameraIconError(
356
+ "UNSAFE_ICON_OUTPUT",
357
+ `Rendered SVG contains a disallowed ${attributeName} attribute.`,
358
+ )
359
+ }
360
+ if (attributeName === "id") ids.add(value)
361
+ references.push(...urlReferences(value))
362
+ }
363
+ }
364
+ for (const target of references) {
365
+ if (!/^#[A-Za-z0-9_-]+$/u.test(target) || !ids.has(target.slice(1))) {
366
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG references a non-local resource.")
367
+ }
368
+ }
369
+ }
370
+
371
+ interface SvgTag {
372
+ readonly name: string
373
+ readonly attributes: readonly (readonly [string, string])[]
374
+ }
375
+
376
+ const isSvgSpace = (character: string | undefined): boolean =>
377
+ character === " " || character === "\t" || character === "\n" || character === "\r"
378
+ const isSvgNameStart = (character: string | undefined): boolean =>
379
+ character !== undefined && /^[A-Za-z]$/u.test(character)
380
+ const isSvgNameCharacter = (character: string | undefined): boolean =>
381
+ character !== undefined && /^[\w:.-]$/u.test(character)
382
+ const isSvgAttributeNameCharacter = (character: string | undefined): boolean =>
383
+ character !== undefined && !isSvgSpace(character) && character !== "=" && character !== "/" && character !== ">"
384
+
385
+ /**
386
+ * Scan every opening tag in one linear pass. Each tag must be a name followed
387
+ * by whitespace-separated `name="value"` attributes and an optional `/`;
388
+ * anything else is malformed. Closing tags are skipped after a name check.
389
+ */
390
+ function scanSvgTags(svg: string): SvgTag[] {
391
+ const malformed = (): never => {
392
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG contains a malformed element.")
393
+ }
394
+ const tags: SvgTag[] = []
395
+ let index = svg.indexOf("<")
396
+ while (index !== -1) {
397
+ let cursor = index + 1
398
+ if (svg[cursor] === "/") {
399
+ cursor += 1
400
+ if (!isSvgNameStart(svg[cursor])) malformed()
401
+ while (isSvgNameCharacter(svg[cursor])) cursor += 1
402
+ while (isSvgSpace(svg[cursor])) cursor += 1
403
+ if (svg[cursor] !== ">") malformed()
404
+ index = svg.indexOf("<", cursor + 1)
405
+ continue
406
+ }
407
+ if (!isSvgNameStart(svg[cursor])) malformed()
408
+ const nameStart = cursor
409
+ while (isSvgNameCharacter(svg[cursor])) cursor += 1
410
+ const name = svg.slice(nameStart, cursor)
411
+ const attributes: (readonly [string, string])[] = []
412
+ for (;;) {
413
+ const spaceStart = cursor
414
+ while (isSvgSpace(svg[cursor])) cursor += 1
415
+ if (svg[cursor] === "/" && svg[cursor + 1] === ">") {
416
+ cursor += 1
417
+ break
418
+ }
419
+ if (svg[cursor] === ">") break
420
+ if (cursor === spaceStart) malformed()
421
+ const attributeStart = cursor
422
+ while (isSvgAttributeNameCharacter(svg[cursor])) cursor += 1
423
+ if (cursor === attributeStart || svg[cursor] !== "=" || svg[cursor + 1] !== "\"") malformed()
424
+ const valueStart = cursor + 2
425
+ const valueEnd = svg.indexOf("\"", valueStart)
426
+ if (valueEnd === -1) malformed()
427
+ const value = svg.slice(valueStart, valueEnd)
428
+ if (value.includes("<")) malformed()
429
+ attributes.push([svg.slice(attributeStart, cursor), value])
430
+ cursor = valueEnd + 1
431
+ }
432
+ tags.push({ name, attributes })
433
+ index = svg.indexOf("<", cursor + 1)
434
+ }
435
+ return tags
436
+ }
437
+
438
+ /** Every `url(...)` target in one attribute value, found without backtracking. */
439
+ function urlReferences(value: string): string[] {
440
+ const targets: string[] = []
441
+ let index = value.indexOf("url(")
442
+ while (index !== -1) {
443
+ const close = value.indexOf(")", index + 4)
444
+ if (close === -1) {
445
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG references a non-local resource.")
446
+ }
447
+ targets.push(value.slice(index + 4, close))
448
+ index = value.indexOf("url(", close + 1)
449
+ }
450
+ return targets
451
+ }
452
+
453
+ function svgDimensions(svg: string): { readonly width: number; readonly height: number } {
454
+ const root = /^<svg\b[^>]*>/u.exec(svg)?.[0] ?? ""
455
+ const width = Number(/\swidth="(\d+)"/u.exec(root)?.[1])
456
+ const height = Number(/\sheight="(\d+)"/u.exec(root)?.[1])
457
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) {
458
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Rendered SVG does not declare integer dimensions.")
459
+ }
460
+ return { width, height }
461
+ }
462
+
463
+ function parseSize(value: number | undefined, fallback: number): number {
464
+ const size = value ?? fallback
465
+ if (
466
+ !Number.isSafeInteger(size)
467
+ || size < slopcameraIconSceneLimits.sizeMin
468
+ || size > slopcameraIconSceneLimits.sizeMax
469
+ ) {
470
+ invalidInput(
471
+ `size must be an integer from ${String(slopcameraIconSceneLimits.sizeMin)} through ${String(slopcameraIconSceneLimits.sizeMax)}.`,
472
+ )
473
+ }
474
+ return size
475
+ }
476
+
477
+ async function readIconSource(path: string): Promise<{
478
+ readonly bytes: Uint8Array
479
+ readonly record: Omit<SlopcameraIconSourceRecord, "format">
480
+ }> {
481
+ const bytes = await readBoundedFile(path, slopcameraIconSceneLimits.sourceBytes, "Icon source")
482
+ return { bytes, record: { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength } }
483
+ }
484
+
485
+ async function publish(path: string, text: string): Promise<SlopcameraIconFileRecord> {
486
+ const bytes = Buffer.from(text, "utf8")
487
+ if (bytes.byteLength > slopcameraIconSceneLimits.outputBytes) {
488
+ throw new SlopcameraIconError("UNSAFE_ICON_OUTPUT", "Icon output exceeds the output byte limit.")
489
+ }
490
+ await publishReplaceableFile(path, bytes)
491
+ return { path, sha256: sha256Hex(bytes), bytes: bytes.byteLength }
492
+ }
493
+
494
+ function requireExtension(path: string, extension: string, label: string): void {
495
+ if (!path.toLowerCase().endsWith(extension)) invalidInput(`${label} must end in ${extension}.`)
496
+ }
497
+
498
+ /**
499
+ * Parse and solve one icon scene, collection, construction program or recipe.
500
+ * With an output path, publish the canonical solved document that render
501
+ * accepts; without one, write nothing.
502
+ */
503
+ export async function composeSlopcameraIcon(
504
+ input: SlopcameraIconComposeInput,
505
+ ): Promise<SlopcameraIconComposeReceipt> {
506
+ if (input.outputPath !== undefined) {
507
+ requireExtension(input.outputPath, ".json", "outputPath")
508
+ if (await hasPathCollision([input.sourcePath, input.outputPath])) {
509
+ invalidInput("outputPath must differ from sourcePath.")
510
+ }
511
+ }
512
+ const { bytes, record } = await readIconSource(input.sourcePath)
513
+ const loaded = loadIconSource(bytes)
514
+ let document: SceneDocument | ConstructionProgram
515
+ let drawing: CompiledDrawing
516
+ let documentDigest: string
517
+ let diagnostics: SlopcameraIconDiagnostic[]
518
+ let elementCount: number
519
+ let title: string
520
+ try {
521
+ if (loaded.form === "scene") {
522
+ const solution = solveScene(loaded.scene, loaded.sources)
523
+ document = solution.scene
524
+ drawing = solution.drawing
525
+ documentDigest = createSceneRecipe(solution.scene, DEFAULT_SCENE_RENDER).digests.scene
526
+ elementCount = solution.scene.nodes.length
527
+ title = solution.scene.title
528
+ diagnostics = solution.diagnostics.map((diagnostic) => ({
529
+ code: diagnostic.code,
530
+ message: libraryMessage(diagnostic.message),
531
+ target: diagnostic.node,
532
+ }))
533
+ } else {
534
+ document = loaded.drawing.program
535
+ drawing = loaded.drawing
536
+ documentDigest = programDigest(loaded.drawing.program)
537
+ elementCount = loaded.drawing.program.parts.length
538
+ title = loaded.drawing.program.title
539
+ diagnostics = loaded.drawing.issues.map((issue) => ({
540
+ code: issue.code,
541
+ message: libraryMessage(issue.message),
542
+ target: issue.part ?? null,
543
+ }))
544
+ }
545
+ } catch (error) {
546
+ if (error instanceof SlopcameraIconError) throw error
547
+ invalidSource(`Icon source could not be solved: ${libraryMessage(error)}`)
548
+ }
549
+ const output = input.outputPath === undefined
550
+ ? null
551
+ : await publish(input.outputPath, stableDocumentText(document))
552
+ return {
553
+ receiptVersion: 1,
554
+ operation: "slopcamera.icon.compose",
555
+ engine: slopcameraIconEngine,
556
+ source: { ...record, format: loaded.format },
557
+ form: loaded.form,
558
+ title,
559
+ elementCount,
560
+ digests: { document: documentDigest, geometry: drawing.digest },
561
+ geometry: geometryOf(drawing),
562
+ diagnostics: diagnostics.slice(0, slopcameraIconSceneLimits.diagnostics),
563
+ output,
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Draw one icon source to inert SVG. A recipe replays under its recorded
569
+ * settings and must reproduce its recorded digests; any other source takes
570
+ * bounded render settings. Optionally publish a replayable recipe.
571
+ */
572
+ export async function renderSlopcameraIcon(
573
+ input: SlopcameraIconRenderInput,
574
+ ): Promise<SlopcameraIconRenderReceipt> {
575
+ requireExtension(input.outputPath, ".svg", "outputPath")
576
+ if (input.recipePath !== undefined) requireExtension(input.recipePath, ".json", "recipePath")
577
+ if (await hasPathCollision([input.sourcePath, input.outputPath, input.recipePath])) {
578
+ invalidInput("sourcePath, outputPath and recipePath must name different files.")
579
+ }
580
+ const { bytes, record } = await readIconSource(input.sourcePath)
581
+ const loaded = loadIconSource(bytes)
582
+ const replayed = loaded.format === "scene-recipe" || loaded.format === "program-recipe"
583
+ if (
584
+ replayed
585
+ && (input.size !== undefined
586
+ || input.background !== undefined
587
+ || input.palette !== undefined
588
+ || input.language !== undefined)
589
+ ) {
590
+ invalidInput("A recipe fixes its render settings; omit size, background, palette and language.")
591
+ }
592
+ if (loaded.form === "scene" && input.language !== undefined) {
593
+ invalidInput("language applies only to construction programs; scenes use their family's language.")
594
+ }
595
+ if (loaded.form === "program" && input.palette !== undefined) {
596
+ invalidInput("palette applies only to scenes.")
597
+ }
598
+ if (input.palette !== undefined && !slopcameraIconPalettes.includes(input.palette)) {
599
+ invalidInput(`palette must be one of ${slopcameraIconPalettes.join(", ")}.`)
600
+ }
601
+ if (input.language !== undefined && !slopcameraIconLanguages.includes(input.language)) {
602
+ invalidInput(`language must be one of ${slopcameraIconLanguages.join(", ")}.`)
603
+ }
604
+
605
+ let svg: string
606
+ let width: number
607
+ let height: number
608
+ let recipeValue: unknown
609
+ let digests: SlopcameraIconRenderReceipt["digests"]
610
+ let render: SlopcameraIconRenderReceipt["render"]
611
+ let notes: readonly string[]
612
+ let title: string
613
+ try {
614
+ if (loaded.form === "scene") {
615
+ let recipe: SceneRecipe
616
+ let options: SceneRenderOptions
617
+ let language: string
618
+ if (loaded.replay !== undefined) {
619
+ ;({ recipe, svg, width, height, options, notes } = loaded.replay)
620
+ language = loaded.replay.style.language
621
+ } else {
622
+ options = {
623
+ size: parseSize(input.size, DEFAULT_SCENE_RENDER.size),
624
+ background: input.background ?? DEFAULT_SCENE_RENDER.background,
625
+ palette: input.palette ?? DEFAULT_SCENE_RENDER.palette,
626
+ }
627
+ const drawn = renderScene(loaded.scene, options)
628
+ recipe = createSceneRecipe(loaded.scene, options, drawn.solution.sources, drawn.style)
629
+ ;({ svg, width, height, notes } = drawn)
630
+ language = drawn.style.language
631
+ }
632
+ recipeValue = recipe
633
+ digests = {
634
+ document: recipe.digests.scene,
635
+ geometry: recipe.digests.geometry,
636
+ svg: recipe.digests.svg,
637
+ }
638
+ render = {
639
+ size: options.size,
640
+ background: options.background,
641
+ palette: options.palette,
642
+ language,
643
+ }
644
+ title = loaded.scene.title
645
+ } else {
646
+ const style: StyleOptions = loaded.replay?.style ?? {
647
+ language: input.language ?? slopcameraIconDefaultLanguage,
648
+ outputPx: parseSize(input.size, 512),
649
+ background: input.background ?? false,
650
+ }
651
+ const drawn = loaded.replay ?? renderConstruction(loaded.drawing, style)
652
+ const recipe = createRecipe(loaded.drawing, style)
653
+ svg = drawn.svg
654
+ ;({ width, height } = svgDimensions(svg))
655
+ recipeValue = recipe
656
+ digests = {
657
+ document: recipe.digests.program,
658
+ geometry: recipe.digests.geometry,
659
+ svg: recipe.digests.svg,
660
+ }
661
+ render = {
662
+ size: style.outputPx ?? width,
663
+ background: style.background ?? false,
664
+ palette: null,
665
+ language: style.language,
666
+ }
667
+ notes = "notes" in drawn ? drawn.notes : []
668
+ title = loaded.drawing.program.title
669
+ }
670
+ } catch (error) {
671
+ if (error instanceof SlopcameraIconError) throw error
672
+ invalidSource(`Icon source could not be drawn: ${libraryMessage(error)}`)
673
+ }
674
+ assertInertIconSvg(svg)
675
+ const output = await publish(input.outputPath, svg)
676
+ const recipe = input.recipePath === undefined
677
+ ? null
678
+ : await publish(input.recipePath, stableDocumentText(recipeValue))
679
+ return {
680
+ receiptVersion: 1,
681
+ operation: "slopcamera.icon.render",
682
+ engine: slopcameraIconEngine,
683
+ source: { ...record, format: loaded.format },
684
+ form: loaded.form,
685
+ replayed,
686
+ title,
687
+ render,
688
+ width,
689
+ height,
690
+ digests,
691
+ output,
692
+ recipe,
693
+ notes: notes.slice(0, slopcameraIconSceneLimits.notes).map(libraryMessage),
694
+ }
695
+ }
package/src/index.ts CHANGED
@@ -166,11 +166,13 @@ export * from "./drawing.js"
166
166
  export * from "./generate.js"
167
167
  export * from "./host-resources.js"
168
168
  export * from "./icon.js"
169
+ export * from "./icon-scene.js"
169
170
  export * from "./icon-set.js"
170
171
  export * from "./image-gallery.js"
171
172
  export * from "./operations.js"
172
173
  export * from "./portable-capability-manifest.js"
173
174
  export * from "./scene-gallery.js"
175
+ export * from "./soundtrack.js"
174
176
  export * from "./studio/index.js"
175
177
  export * from "./visual-style.js"
176
178
  export * from "./workflow.js"