@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.
- package/README.md +60 -26
- package/apps/desktop/application/directing-contract.ts +15 -3
- package/apps/desktop/application/directing-plan.ts +2 -1
- package/apps/desktop/application/operation.ts +1 -1
- package/apps/desktop/cli/args.ts +62 -1
- package/apps/desktop/cli/capability-manifest.ts +4 -1
- package/apps/desktop/cli/cinema-renderer.ts +9 -6
- package/apps/desktop/cli/command-host-resources.ts +7 -0
- package/apps/desktop/cli/commands.ts +555 -2
- package/apps/desktop/cli/credits.ts +818 -0
- package/apps/desktop/cli/directing-blob.ts +17 -12
- package/apps/desktop/cli/directing-media.ts +14 -4
- package/apps/desktop/cli/directing-quote.ts +29 -11
- package/apps/desktop/cli/directing-service.ts +6 -2
- package/apps/desktop/cli/gateway-media-catalog.ts +57 -0
- package/apps/desktop/cli/gateway-media-service.ts +12 -2
- package/apps/desktop/cli/help.ts +51 -8
- package/apps/desktop/cli/main.ts +1 -1
- package/apps/desktop/cli/project-renderer.ts +23 -9
- package/apps/desktop/cli/spatial-scene-service.ts +1 -1
- package/apps/desktop/dist/cli/main.js +337 -257
- package/apps/desktop/html-overlay/spatial-splat-runtime.ts +6 -0
- package/dist/cli.js +14 -4
- package/dist/index-11cb717y.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 +7 -3
- package/docs/studio.md +63 -3
- package/package.json +13 -7
- package/schema/drawing.schema.json +27 -0
- package/skills/slopcamera/SKILL.md +1 -0
- package/skills/slopcamera/references/directing-video.md +3 -3
- package/skills/slopcamera/references/educational-video.md +1 -1
- package/skills/slopcamera/references/install.md +1 -1
- package/skills/slopcamera/references/patent-drawings.md +53 -0
- package/skills/slopcamera/references/workflows-sdk.md +1 -1
- 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-3e1pcxb5.js +0 -7
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Make an educational film
|
|
2
2
|
|
|
3
|
-
Use
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|