diff --git a/apps/hub/package.json b/apps/hub/package.json index ef66c1f7..b48f5066 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -27,7 +27,9 @@ "@corbits/mcp": "0.4.1", "@corbits/oauth-core": "0.3.4", "@corbits/openai-responses": "0.2.3", + "@corbits/slidedeck": "workspace:*", "@corbits/xai-provider": "0.1.6", + "@corbits/zip": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "0.5.0", diff --git a/apps/hub/src/artifacts/deck-tools.test.ts b/apps/hub/src/artifacts/deck-tools.test.ts new file mode 100644 index 00000000..79a8c91f --- /dev/null +++ b/apps/hub/src/artifacts/deck-tools.test.ts @@ -0,0 +1,410 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Hono } from "hono"; +import { DeckBundle } from "@corbits-builder/schemas/artifact"; +import { unzip } from "@corbits/zip"; +import type { TenantEnv } from "@intx/hub-api"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; + +import { ProjectError } from "../projects/errors.ts"; +import { + DECK_JSON, + DECK_PDF, + DECK_PPTX, + checkDeckInWorkspace, + createDeckTools, +} from "./deck-tools.ts"; +import { mountArtifactRoutes } from "./routes.ts"; +import type { createArtifactService } from "./service.ts"; + +const sampleDeck = { + theme: { + colors: { + primary: "#6D28D9", + secondary: "#0EA5E9", + accent: "#F59E0B", + background: "#FFFFFF", + text: "#1B1B1F", + muted: "#6B7280", + }, + fonts: { heading: "Georgia", body: "Helvetica Neue" }, + }, + footer: { text: "Builder · Deck Tools", slideNumbers: true }, + slides: [ + { + layout: "title" as const, + title: "Quarter Review", + subtitle: "Deterministic slides and PDF", + }, + { + layout: "content" as const, + title: "Key results", + body: [ + { + type: "paragraph" as const, + text: "Every check runs in pure TypeScript.", + }, + { + type: "list" as const, + ordered: true, + items: [ + "Validate schema", + "Check overflow and contrast", + "Render PPTX and PDF", + ], + }, + ], + }, + ], +}; + +const toolEnv = (toolCwd: string, capabilities?: RuntimeCapabilities) => + ({ + toolCwd, + ...(capabilities ? { capabilities } : {}), + }) as unknown as Parameters>[0]; + +function createDeckArtifactHarness() { + const versions = new Map< + string, + { + artifactId: string; + kind: string; + title: string; + version: number; + sha256: string; + filedAt: string; + content: string; + } + >(); + let versionSeq = 0; + const artifactId = "44444444-4444-4444-8444-444444444444"; + + const app = new Hono(); + app.onError((error, c) => { + if (error instanceof ProjectError) + return c.json({ code: error.code, ...error.details }, error.status); + throw error; + }); + app.use("*", async (c, next) => { + c.set("tenant", { id: "tenant-1" } as TenantEnv["Variables"]["tenant"]); + c.set("principal", { + id: "principal-1", + } as TenantEnv["Variables"]["principal"]); + await next(); + }); + + const service = { + write: async ( + _actor: unknown, + _projectId: string, + raw: { kind: string; title: string; content: string }, + ) => { + DeckBundle.assert(JSON.parse(raw.content)); + versionSeq += 1; + const sha256 = createHash("sha256").update(raw.content).digest("hex"); + const stored = { + artifactId, + kind: raw.kind, + title: raw.title, + version: versionSeq, + sha256, + filedAt: new Date(versionSeq * 1000).toISOString(), + content: raw.content, + }; + versions.set(`${artifactId}@${String(versionSeq)}`, stored); + return { + artifact: { + artifactId, + kind: raw.kind, + version: versionSeq, + sha256, + }, + }; + }, + read: async ( + _actor: unknown, + _projectId: string, + raw: { artifactId?: string; version?: number }, + ) => { + const targetVersion = raw.version ?? versionSeq; + const stored = versions.get(`${artifactId}@${String(targetVersion)}`); + if (!stored) throw new ProjectError(404, "artifact_not_found"); + return stored; + }, + } as unknown as ReturnType; + + mountArtifactRoutes(app, service); + + const fetch = async (path: string, init?: RequestInit) => { + if (path === "/api/workflow-project-scope") { + const deployment = { + source: { + kind: "asset" as const, + assetId: "asset-1", + package: { format: "tarball" as const }, + }, + entry: "dist/index.js", + pin: "@corbits-builder/writer@0.1.0", + sourceOfferingIds: ["offering-1"], + defaultSourceOfferingId: "offering-1", + }; + return Response.json({ + tenantId: "tenant-1", + projectId: "project-1", + domain: "example.invalid", + role: "writer", + workflows: { + writer: deployment, + reviewer: deployment, + coder: deployment, + designer: deployment, + }, + openNetwork: false, + build: { + assetDirectory: "/tmp/assets", + browserCli: "/tmp/browser", + childPath: "/tmp/child.js", + dependencyRoots: ["/tmp/deps"], + ripgrepPath: "/usr/bin/rg", + runtimeVersion: "1.0.0", + }, + }); + } + const prefix = "/api/tenants/tenant-1/projects"; + const subPath = path.startsWith(prefix) ? path.slice(prefix.length) : path; + return app.request(subPath, init); + }; + + const capabilities = { + resolve: () => ({ + resolve: async () => ({ kind: "http", fetch }), + }), + } as unknown as RuntimeCapabilities; + + const getArtifactVersion = (id: string, version: number) => + versions.get(`${id}@${String(version)}`); + + return { app, capabilities, versions, getArtifactVersion }; +} + +test("write_deck and check_deck write .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf and file a durable deck artifact on check_deck pass", async () => { + const workspace = await mkdtemp(join(tmpdir(), "deck-tools-")); + const { capabilities, versions, getArtifactVersion } = + createDeckArtifactHarness(); + try { + const tool = createDeckTools()(toolEnv(workspace, capabilities)); + expect(tool.definitions.map((d) => d.name)).toEqual([ + "write_deck", + "check_deck", + ]); + + const writeRes = await tool.run( + { + id: "call-1", + name: "write_deck", + arguments: { deck: sampleDeck }, + }, + new AbortController().signal, + ); + expect(writeRes.isError).toBeUndefined(); + const written = JSON.parse(String(writeRes.content)); + expect(written).toEqual({ + slideCount: 2, + pptxBytes: expect.any(Number), + pdfBytes: expect.any(Number), + passed: true, + issues: [], + }); + // write_deck alone does not file an artifact version before check_deck passes. + expect(versions.size).toBe(0); + + const pptxBytes = new Uint8Array( + await readFile(join(workspace, DECK_PPTX)), + ); + const pdfBytes = new Uint8Array(await readFile(join(workspace, DECK_PDF))); + expect(pptxBytes.byteLength).toBe(written.pptxBytes); + expect(pdfBytes.byteLength).toBe(written.pdfBytes); + const entries = await unzip(pptxBytes); + expect(entries.some((e) => e.path === "ppt/slides/slide1.xml")).toBe(true); + expect(entries.some((e) => e.path === "ppt/slides/slide2.xml")).toBe(true); + expect(new TextDecoder("latin1").decode(pdfBytes)).toContain( + "(Quarter Review) Tj", + ); + + const checkRes = await tool.run( + { + id: "call-2", + name: "check_deck", + arguments: {}, + }, + new AbortController().signal, + ); + expect(checkRes.isError).toBeUndefined(); + const checkedBody = JSON.parse(String(checkRes.content)); + expect(checkedBody).toEqual({ + passed: true, + issues: [], + artifact: { + artifactId: "44444444-4444-4444-8444-444444444444", + kind: "deck", + version: 1, + sha256: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + + // Remove the sidecar workspace completely and verify the filed artifact version remains + // durable and readable via getArtifactVersion. + await rm(workspace, { recursive: true, force: true }); + const durable = getArtifactVersion( + checkedBody.artifact.artifactId, + checkedBody.artifact.version, + ); + expect(durable).toBeDefined(); + const bundle = DeckBundle.assert(JSON.parse(durable!.content)); + expect(bundle.deck).toEqual(sampleDeck); + expect(new Uint8Array(Buffer.from(bundle.pptxBase64, "base64"))).toEqual( + pptxBytes, + ); + expect(new Uint8Array(Buffer.from(bundle.pdfBase64, "base64"))).toEqual( + pdfBytes, + ); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); + +test("write_deck and check_deck return isError: true naming the slide and field on validation or check failures", async () => { + const workspace = await mkdtemp(join(tmpdir(), "deck-tools-err-")); + const { capabilities, versions } = createDeckArtifactHarness(); + try { + const tool = createDeckTools()(toolEnv(workspace, capabilities)); + + const invalidRes = await tool.run( + { + id: "call-bad", + name: "write_deck", + arguments: { + deck: { + ...sampleDeck, + slides: [{ layout: "content", body: [] }], + }, + }, + }, + new AbortController().signal, + ); + expect(invalidRes.isError).toBe(true); + const invalidBody = JSON.parse(String(invalidRes.content)); + expect(invalidBody.passed).toBe(false); + expect(invalidBody.issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].title", + }), + ]); + + // Calling write_deck with a check-only failure (valid schema, low contrast) must refuse with + // isError: true and leave .builder/deck.json, .pptx and .pdf unwritten. + const lowContrastDeck = { + ...sampleDeck, + slides: [ + { + layout: "content", + title: "Low contrast slide", + body: [ + { + type: "paragraph", + text: [{ text: "Unreadable light text", color: "#E5E7EB" }], + }, + ], + }, + ], + }; + const writeContrastRes = await tool.run( + { + id: "call-write-contrast-fail", + name: "write_deck", + arguments: { deck: lowContrastDeck }, + }, + new AbortController().signal, + ); + expect(writeContrastRes.isError).toBe(true); + const writeContrastBody = JSON.parse(String(writeContrastRes.content)); + expect(writeContrastBody.passed).toBe(false); + expect(writeContrastBody.issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].body[0].text[0]", + message: expect.stringContaining("contrast"), + }), + ]); + expect(await Bun.file(join(workspace, DECK_JSON)).exists()).toBe(false); + expect(await Bun.file(join(workspace, DECK_PPTX)).exists()).toBe(false); + expect(await Bun.file(join(workspace, DECK_PDF)).exists()).toBe(false); + + // Seed the failing deck directly on disk to exercise checkDeckInWorkspace and check_deck. + await mkdir(join(workspace, ".builder"), { recursive: true }); + await writeFile( + join(workspace, DECK_JSON), + JSON.stringify(lowContrastDeck), + ); + const directCheck = await checkDeckInWorkspace(workspace); + expect(directCheck.passed).toBe(false); + + const checkRes = await tool.run( + { + id: "call-check-fail", + name: "check_deck", + arguments: {}, + }, + new AbortController().signal, + ); + expect(checkRes.isError).toBe(true); + const checkBody = JSON.parse(String(checkRes.content)); + expect(checkBody.passed).toBe(false); + expect(checkBody.issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].body[0].text[0]", + message: expect.stringContaining("contrast"), + }), + ]); + + // Also verify missing image asset in .builder/deck.json fails check_deck. + await mkdir(join(workspace, ".builder"), { recursive: true }); + await writeFile( + join(workspace, DECK_JSON), + JSON.stringify({ + ...sampleDeck, + slides: [ + { + layout: "image", + title: "Missing image", + image: { path: "does-not-exist.png" }, + }, + ], + }), + ); + const missingImgRes = await tool.run( + { + id: "call-missing-img", + name: "check_deck", + arguments: {}, + }, + new AbortController().signal, + ); + expect(missingImgRes.isError).toBe(true); + expect(JSON.parse(String(missingImgRes.content)).issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].image", + }), + ]); + expect(versions.size).toBe(0); + } finally { + await rm(workspace, { recursive: true, force: true }); + } +}); diff --git a/apps/hub/src/artifacts/deck-tools.ts b/apps/hub/src/artifacts/deck-tools.ts new file mode 100644 index 00000000..62a82b63 --- /dev/null +++ b/apps/hub/src/artifacts/deck-tools.ts @@ -0,0 +1,309 @@ +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; +import { dirname, join, resolve, sep } from "node:path"; +import { + Deck, + DeckError, + assetKey, + assetUses, + checkDeck, + validateDeck, + writeDeckPdf, + writePptx, + type Assets, + type Deck as DeckType, + type DeckIssue, +} from "@corbits/slidedeck"; +import { WorkflowProjectScope } from "@corbits-builder/schemas/installed-workflows"; +import { defineTool, type BaseEnv } from "@intx/agent/authoring"; +import { HUB_CREDENTIAL_HANDLE } from "@intx/types/credential-delivery"; +import type { RuntimeCapabilities } from "@intx/types/runtime-capabilities"; +import { workflowHttpJson } from "../workflows/http-json.ts"; +import { HubArtifactReceipt } from "./tools.ts"; + +interface DeckToolEnv extends BaseEnv { + toolCwd: string; + capabilities?: RuntimeCapabilities; +} + +const HubProjectScope = WorkflowProjectScope.onDeepUndeclaredKey("ignore"); + +export const DECK_JSON = ".builder/deck.json"; +export const DECK_PPTX = ".builder/deck.pptx"; +export const DECK_PDF = ".builder/deck.pdf"; + +async function resolveWorkspaceAssets( + workspace: string, + deck: DeckType, + provided?: Assets, +): Promise { + const merged = new Map>(provided); + const root = await realpath(workspace); + for (const { ref } of assetUses(deck)) { + const key = assetKey(ref); + if (merged.has(key) || !("path" in ref)) continue; + const candidate = resolve(root, ref.path); + if (!candidate.startsWith(root + sep)) continue; + try { + const target = await realpath(candidate); + if (!target.startsWith(root + sep)) continue; + const bytes = await readFile(target); + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + merged.set(key, copy); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "EISDIR" && code !== "ENOTDIR") + throw error; + } + } + return merged; +} + +export async function writeDeckToWorkspace( + workspace: string, + deckInput: unknown, + assets?: Assets, +) { + const validated = validateDeck(deckInput); + if (!validated.ok) throw new DeckError(validated.issues); + const { deck } = validated; + const resolvedAssets = await resolveWorkspaceAssets(workspace, deck, assets); + const issues = checkDeck(deck, resolvedAssets); + if (issues.length > 0) throw new DeckError(issues); + const pptx = await writePptx(deck, resolvedAssets); + const pdf = writeDeckPdf(deck, resolvedAssets); + + const jsonTarget = join(workspace, DECK_JSON); + await mkdir(dirname(jsonTarget), { recursive: true }); + await writeFile(jsonTarget, `${JSON.stringify(deck, null, 2)}\n`, "utf8"); + await writeFile(join(workspace, DECK_PPTX), pptx); + await writeFile(join(workspace, DECK_PDF), pdf); + + return { + deck, + slideCount: deck.slides.length, + pptxBytes: pptx.byteLength, + pdfBytes: pdf.byteLength, + passed: issues.length === 0, + issues, + }; +} + +export async function checkDeckInWorkspace(workspace: string, assets?: Assets) { + const raw = await readFile(join(workspace, DECK_JSON), "utf8"); + const parsed: unknown = JSON.parse(raw); + const validated = validateDeck(parsed); + if (!validated.ok) { + return { + passed: false, + issues: validated.issues, + }; + } + const resolvedAssets = await resolveWorkspaceAssets( + workspace, + validated.deck, + assets, + ); + const issues = checkDeck(validated.deck, resolvedAssets); + if (issues.length > 0) { + return { + passed: false, + issues, + }; + } + const pptx = await writePptx(validated.deck, resolvedAssets); + const pdf = writeDeckPdf(validated.deck, resolvedAssets); + await writeFile(join(workspace, DECK_PPTX), pptx); + await writeFile(join(workspace, DECK_PDF), pdf); + return { + passed: true, + issues, + }; +} + +function deckTitle(deck: DeckType): string { + for (const slide of deck.slides) { + if ("title" in slide && typeof slide.title === "string") { + const trimmed = slide.title.trim(); + if (trimmed.length > 0) return trimmed.slice(0, 512); + } + } + return "Slide deck"; +} + +export async function fileCheckedDeck( + workspace: string, + capabilities: RuntimeCapabilities, + signal: AbortSignal, +) { + const [rawDeck, pptxBytes, pdfBytes] = await Promise.all([ + readFile(join(workspace, DECK_JSON), "utf8"), + readFile(join(workspace, DECK_PPTX)), + readFile(join(workspace, DECK_PDF)), + ]); + const parsed: unknown = JSON.parse(rawDeck); + const validated = validateDeck(parsed); + if (!validated.ok) throw new DeckError(validated.issues); + const content = JSON.stringify({ + deck: validated.deck, + pptxBase64: Buffer.from(pptxBytes).toString("base64"), + pdfBase64: Buffer.from(pdfBytes).toString("base64"), + }); + const scope = HubProjectScope.assert( + await workflowHttpJson({ + capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path: "/api/workflow-project-scope", + maxResponseBytes: 16384, + signal, + }), + ); + const projectPath = + "/api/tenants/" + + encodeURIComponent(scope.tenantId) + + "/projects/" + + encodeURIComponent(scope.projectId); + const { artifact } = HubArtifactReceipt.assert( + await workflowHttpJson({ + capabilities, + handle: HUB_CREDENTIAL_HANDLE, + path: projectPath + "/artifacts", + body: { + kind: "deck", + title: deckTitle(validated.deck), + content, + }, + maxResponseBytes: 16384, + signal, + }), + ); + return artifact; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function errorIssues(path: string, error: unknown): DeckIssue[] { + if (error instanceof DeckError) return error.issues; + return [ + { + path, + message: error instanceof Error ? error.message : String(error), + }, + ]; +} + +export function createDeckTools(assets?: Assets) { + const definitions = [ + { + name: "write_deck", + description: + "Validate, check and render a slide deck to .builder/deck.json, .builder/deck.pptx and .builder/deck.pdf in the workspace.", + inputSchema: { + type: "object", + properties: { + deck: { + ...Deck.toJsonSchema({ + fallback: { default: (ctx) => ctx.base }, + }), + }, + }, + required: ["deck"], + additionalProperties: false, + }, + }, + { + name: "check_deck", + description: + "Check .builder/deck.json in the workspace for empty placeholders, text overflow, broken images and low contrast.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + ]; + + return defineTool({ + id: "@corbits-builder/artifacts/deck-tools", + credentials: [{ handle: HUB_CREDENTIAL_HANDLE }], + requires: ["toolCwd"], + definitions: [{ name: "write_deck" }, { name: "check_deck" }], + factory: (env) => ({ + definitions, + run: async (call, signal) => { + if (call.name === "write_deck") { + const deckArg = + isObjectRecord(call.arguments) && "deck" in call.arguments + ? call.arguments.deck + : call.arguments; + try { + const written = await writeDeckToWorkspace( + env.toolCwd, + deckArg, + assets, + ); + return { + callId: call.id, + content: JSON.stringify({ + slideCount: written.slideCount, + pptxBytes: written.pptxBytes, + pdfBytes: written.pdfBytes, + passed: written.passed, + issues: written.issues, + }), + }; + } catch (error) { + return { + callId: call.id, + content: JSON.stringify({ + passed: false, + issues: errorIssues("deck", error), + }), + isError: true, + }; + } + } + + if (call.name === "check_deck") { + try { + const checked = await checkDeckInWorkspace(env.toolCwd, assets); + if (!checked.passed) { + return { + callId: call.id, + content: JSON.stringify(checked), + isError: true, + }; + } + const artifact = env.capabilities + ? await fileCheckedDeck(env.toolCwd, env.capabilities, signal) + : undefined; + return { + callId: call.id, + content: JSON.stringify({ + ...checked, + ...(artifact ? { artifact } : {}), + }), + }; + } catch (error) { + return { + callId: call.id, + content: JSON.stringify({ + passed: false, + issues: errorIssues(DECK_JSON, error), + }), + isError: true, + }; + } + } + + return { + callId: call.id, + content: "unknown_deck_tool", + isError: true, + }; + }, + }), + }); +} diff --git a/apps/hub/src/artifacts/service.ts b/apps/hub/src/artifacts/service.ts index a44ba985..470981f6 100644 --- a/apps/hub/src/artifacts/service.ts +++ b/apps/hub/src/artifacts/service.ts @@ -39,8 +39,10 @@ import { ArtifactApprovalRecord, AppBundle, AppVersionMetadata, + DeckBundle, DocumentContent, MAX_APP_BYTES, + MAX_DECK_BYTES, MATERIAL_KIND, ArtifactKindWrite, DesignBundle, @@ -153,11 +155,15 @@ const log = getLogger(["corbits-builder", "hub", "artifacts"]); // so no approval covers it. const APPROVAL_COVERS: Record boolean> = { request_approval: (kind) => - kind !== "app" && kind !== "design" && kind !== "design_option", - instruct_coder: (kind) => kind !== "app" && kind !== "design_option", + kind !== "app" && + kind !== "design" && + kind !== "design_option" && + kind !== "deck", + instruct_coder: (kind) => + kind !== "app" && kind !== "design_option" && kind !== "deck", }; -// Whole documents, the filed app among them (up to 1 MiB of source). -const MAX_READ_CHARS = 1024 * 1024; +// Whole documents, the filed app and deck among them. +const MAX_READ_CHARS = MAX_DECK_BYTES; const RECENT_FAILURES = 5; const RunError = type({ "+": "delete", message: "string", "code?": "string" }); const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; @@ -189,6 +195,81 @@ async function isDirectory(path: string) { const artifactResource = (kind: string) => "sb-artifact:" + kind; +function validateArtifactWriteContent( + input: typeof ArtifactKindWrite.infer, +): boolean { + const bundled = + input.kind === "design" || + input.kind === "design_option" || + input.kind === "app" || + input.kind === "deck"; + if (bundled && input.openQuestions) + throw new ProjectError(400, "invalid_artifact_write", { + issues: [ + { + path: "openQuestions", + message: `goes on documents; write this ${input.kind} without it`, + }, + ], + }); + if (input.approaches) { + if (input.kind !== "approaches") + throw new ProjectError(400, "invalid_artifact_write", { + issues: [ + { + path: "approaches", + message: `goes on the approaches document; write this ${input.kind} without it`, + }, + ], + }); + const issue = approachesIssue(input.approaches); + if (issue) + throw new ProjectError(400, "invalid_artifact_write", { + issues: [issue], + }); + } + if ( + input.kind === "design" || + input.kind === "design_option" || + input.kind === "app" + ) { + const bundle = (input.kind === "app" ? AppBundle : DesignBundle)( + parseJson(input.content), + ); + if (bundle instanceof type.errors) + throw new ProjectError(400, "invalid_" + input.kind, { + issues: typeIssues(bundle, "content"), + }); + } + if (input.kind === "app") { + const bytes = Buffer.byteLength(input.content); + if (bytes > MAX_APP_BYTES) + throw new ProjectError(413, "app_too_large", { + bytes, + limit: MAX_APP_BYTES, + }); + } else if (input.kind === "deck") { + const bytes = Buffer.byteLength(input.content); + if (bytes > MAX_DECK_BYTES) + throw new ProjectError(413, "deck_too_large", { + bytes, + limit: MAX_DECK_BYTES, + }); + const bundle = DeckBundle(parseJson(input.content)); + if (bundle instanceof type.errors) + throw new ProjectError(400, "invalid_deck", { + issues: typeIssues(bundle, "content"), + }); + } else { + const document = DocumentContent(input.content); + if (document instanceof type.errors) + throw new ProjectError(400, "invalid_artifact_write", { + issues: typeIssues(document, "content"), + }); + } + return bundled; +} + // A document version records the open questions it leaves for the person; a version filed without // them, before they were recorded or by an import, leaves none. An approaches version may also // record its approaches for a side-by-side comparison. @@ -1261,58 +1342,7 @@ export function createArtifactService( throw new ProjectError(400, "invalid_artifact_write", { issues: input, }); - const bundled = - input.kind === "design" || - input.kind === "design_option" || - input.kind === "app"; - if (bundled && input.openQuestions) - throw new ProjectError(400, "invalid_artifact_write", { - issues: [ - { - path: "openQuestions", - message: `goes on documents; write this ${input.kind} without it`, - }, - ], - }); - if (input.approaches) { - if (input.kind !== "approaches") - throw new ProjectError(400, "invalid_artifact_write", { - issues: [ - { - path: "approaches", - message: `goes on the approaches document; write this ${input.kind} without it`, - }, - ], - }); - const issue = approachesIssue(input.approaches); - if (issue) - throw new ProjectError(400, "invalid_artifact_write", { - issues: [issue], - }); - } - if (bundled) { - const bundle = (input.kind === "app" ? AppBundle : DesignBundle)( - parseJson(input.content), - ); - if (bundle instanceof type.errors) - throw new ProjectError(400, "invalid_" + input.kind, { - issues: typeIssues(bundle, "content"), - }); - } - if (input.kind === "app") { - const bytes = Buffer.byteLength(input.content); - if (bytes > MAX_APP_BYTES) - throw new ProjectError(413, "app_too_large", { - bytes, - limit: MAX_APP_BYTES, - }); - } else { - const document = DocumentContent(input.content); - if (document instanceof type.errors) - throw new ProjectError(400, "invalid_artifact_write", { - issues: typeIssues(document, "content"), - }); - } + const bundled = validateArtifactWriteContent(input); const allowed = await requireProject(actor, projectId, "read"); if (!(await allowed(artifactResource(input.kind), "write"))) throw new ProjectError(403, "artifact_write_refused", { diff --git a/apps/hub/src/projects/errors.test.ts b/apps/hub/src/projects/errors.test.ts index 0d9ba326..1f0532f4 100644 --- a/apps/hub/src/projects/errors.test.ts +++ b/apps/hub/src/projects/errors.test.ts @@ -189,7 +189,7 @@ test("typeIssues names the rule for narrow checks, unions and extra keys without { path: "kind", message: - 'must be "app", "approaches", "build_plan", "constraints", "design", "design_option", "problem_brief" or "product_requirements"', + 'must be "app", "approaches", "build_plan", "constraints", "deck", "design", "design_option", "problem_brief" or "product_requirements"', }, { path: "extra", message: "is not allowed here" }, ]); diff --git a/bun.lock b/bun.lock index 8303ff1a..f1decfbd 100644 --- a/bun.lock +++ b/bun.lock @@ -74,7 +74,9 @@ "@corbits/mcp": "0.4.1", "@corbits/oauth-core": "0.3.4", "@corbits/openai-responses": "0.2.3", + "@corbits/slidedeck": "workspace:*", "@corbits/xai-provider": "0.1.6", + "@corbits/zip": "workspace:*", "@intx/agent": "workspace:*", "@intx/authz": "workspace:*", "@intx/crypto": "0.5.0", @@ -151,6 +153,10 @@ "arktype": "catalog:", }, }, + "packages/pdf": { + "name": "@corbits/pdf", + "version": "0.1.0-rc.2", + }, "packages/schemas": { "name": "@corbits-builder/schemas", "version": "0.1.0-rc.3", @@ -162,6 +168,7 @@ "name": "@corbits/slidedeck", "version": "0.1.0-rc.2", "dependencies": { + "@corbits/pdf": "workspace:*", "@corbits/zip": "workspace:*", "arktype": "catalog:", }, @@ -760,6 +767,8 @@ "@corbits/openai-responses": ["@corbits/openai-responses@0.2.3", "", { "dependencies": { "arktype": "2.2.3" }, "peerDependencies": { "@intx/inference": "^0.5.0", "@intx/types": "^0.5.0" } }, "sha512-9msL1hy3jINpJsdbv2+EZsjVvKIZ8lSO7oJcwKSyphog87U+fePhQtsBq/HNJbKY+zQeHYoaL2MbuYGo/aMIfA=="], + "@corbits/pdf": ["@corbits/pdf@workspace:packages/pdf"], + "@corbits/react-ui": ["@corbits/react-ui@0.5.1", "", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@radix-ui/react-dialog", "@radix-ui/react-dropdown-menu", "@radix-ui/react-tooltip", "sonner"] }, "sha512-kipQmHrAAHFlN+rxS0TXwPpbrtxabWZNi4EGtaZvDPK+xhmnUtDwporw4Vd5871IdJ9YVlNhylisIsiJSnD69Q=="], "@corbits/slidedeck": ["@corbits/slidedeck@workspace:packages/slidedeck"], diff --git a/package.json b/package.json index ef242e5a..c3949535 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "lint": "bun --bun run oxlint --deny-warnings", "format": "bun fmt --no-script", "format:check": "bun fmt --no-script --check", - "typecheck": "bun check --no-script && bun check --no-script -p packages/client && bun check --no-script -p packages/zip && bun check --no-script -p apps/web", + "typecheck": "bun check --no-script && bun check --no-script -p packages/client && bun check --no-script -p packages/pdf && bun check --no-script -p packages/slidedeck && bun check --no-script -p packages/zip && bun check --no-script -p apps/web", "test": "bun --conditions=intx-src test ./apps/hub ./apps/web/src ./packages ./workflows ./test/integration", "test:e2e": "bun --env-file=.env.hub --conditions=intx-src test ./test/e2e", "vendor:verify": "bun scripts/verify-vendor.ts", diff --git a/packages/pdf/README.md b/packages/pdf/README.md new file mode 100644 index 00000000..b923cb45 --- /dev/null +++ b/packages/pdf/README.md @@ -0,0 +1,30 @@ +# PDF + +`@corbits/pdf`: Builder's deterministic PDF 1.4 writer. `writePdf(pages)` +writes vector shapes, styled text and PNG or JPEG images to a standard PDF +document with no third-party dependencies. + +```ts +import { writePdf } from "@corbits/pdf"; + +const bytes = writePdf([ + { + width: 960, + height: 540, + background: "#FFFFFF", + elements: [ + { + type: "text", + x: 48, + y: 48, + w: 864, + h: 64, + text: "Builder overview", + size: 32, + bold: true, + color: "#1B1B1F", + }, + ], + }, +]); +``` diff --git a/packages/pdf/package.json b/packages/pdf/package.json new file mode 100644 index 00000000..33984106 --- /dev/null +++ b/packages/pdf/package.json @@ -0,0 +1,11 @@ +{ + "name": "@corbits/pdf", + "version": "0.1.0-rc.2", + "private": true, + "license": "SEE LICENSE IN ../../LICENSE.md", + "author": "Sawyer Cutler ", + "type": "module", + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/pdf/src/image.ts b/packages/pdf/src/image.ts new file mode 100644 index 00000000..11927047 --- /dev/null +++ b/packages/pdf/src/image.ts @@ -0,0 +1,281 @@ +import { PdfError, deflateZlib, inflateZlib } from "./zlib.ts"; + +export type PdfDecodedImage = { + width: number; + height: number; + colorSpace: "DeviceRGB" | "DeviceGray"; + filter: "DCTDecode" | "FlateDecode"; + data: Uint8Array; + smask?: Uint8Array; +}; + +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +function isPng(bytes: Uint8Array): boolean { + if (bytes.length < 24) return false; + return PNG_SIG.every((b, idx) => bytes[idx] === b); +} + +function isJpeg(bytes: Uint8Array): boolean { + return bytes.length >= 4 && bytes[0] === 0xff && bytes[1] === 0xd8; +} + +function decodeJpeg(bytes: Uint8Array): PdfDecodedImage { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let at = 2; at + 9 < view.byteLength;) { + if (view.getUint8(at) !== 0xff) + throw new PdfError("corrupt JPEG marker prefix"); + const marker = view.getUint8(at + 1); + if (marker === 0xff) { + at++; + continue; + } + if (marker === 0xd9 || marker === 0xda) break; + const segLen = view.getUint16(at + 2); + if (segLen < 2 || at + 2 + segLen > view.byteLength) + throw new PdfError("truncated JPEG segment"); + if ( + marker >= 0xc0 && + marker <= 0xcf && + marker !== 0xc4 && + marker !== 0xc8 && + marker !== 0xcc + ) { + const height = view.getUint16(at + 5); + const width = view.getUint16(at + 7); + const components = view.getUint8(at + 9); + if (width === 0 || height === 0) + throw new PdfError("JPEG has zero dimensions"); + if (components !== 1 && components !== 3) + throw new PdfError(`unsupported JPEG component count ${components}`); + const colorSpace = components === 1 ? "DeviceGray" : "DeviceRGB"; + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return { + width, + height, + colorSpace, + filter: "DCTDecode", + data: copy, + }; + } + at += 2 + segLen; + } + throw new PdfError("JPEG has no start-of-frame header"); +} + +function paethPredictor(a: number, b: number, c: number): number { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + if (pa <= pb && pa <= pc) return a; + if (pb <= pc) return b; + return c; +} + +function unfilterRow( + filterType: number, + raw: Uint8Array, + rowStart: number, + prev: Uint8Array, + curr: Uint8Array, + bpp: number, +) { + for (let i = 0; i < curr.length; i++) { + const x = raw[rowStart + i]!; + const left = i >= bpp ? curr[i - bpp]! : 0; + const up = prev[i]!; + const upLeft = i >= bpp ? prev[i - bpp]! : 0; + if (filterType === 0) curr[i] = x; + else if (filterType === 1) curr[i] = (x + left) & 0xff; + else if (filterType === 2) curr[i] = (x + up) & 0xff; + else if (filterType === 3) curr[i] = (x + ((left + up) >> 1)) & 0xff; + else if (filterType === 4) + curr[i] = (x + paethPredictor(left, up, upLeft)) & 0xff; + else throw new PdfError(`unsupported PNG filter ${filterType}`); + } +} + +function unfilterPng( + raw: Uint8Array, + width: number, + height: number, + bpp: number, +): Uint8Array { + const stride = width * bpp; + if (raw.length < height * (1 + stride)) + throw new PdfError("PNG image data is shorter than expected scanlines"); + const pixels = new Uint8Array(height * stride); + let prev = new Uint8Array(stride); + for (let y = 0; y < height; y++) { + const rowOffset = y * (1 + stride); + const filterType = raw[rowOffset]!; + const curr = pixels.subarray(y * stride, (y + 1) * stride); + unfilterRow(filterType, raw, rowOffset + 1, prev, curr, bpp); + prev = curr; + } + return pixels; +} + +function channelsForColorType(colorType: number): number { + switch (colorType) { + case 0: + case 3: + return 1; + case 2: + return 3; + case 4: + return 2; + case 6: + return 4; + default: + throw new PdfError(`unsupported PNG color type ${colorType}`); + } +} + +function concatChunks(chunks: readonly Uint8Array[]): Uint8Array { + const total = chunks.reduce((sum, c) => sum + c.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + out.set(chunk, offset); + offset += chunk.length; + } + return out; +} + +function splitPixels( + pixels: Uint8Array, + width: number, + height: number, + colorType: number, + palette?: Uint8Array, + trns?: Uint8Array, +) { + const count = width * height; + if (colorType === 2) return { rgb: pixels, alpha: undefined }; + const rgb = new Uint8Array(count * 3); + if (colorType === 0) { + for (let i = 0; i < count; i++) { + const v = pixels[i]!; + rgb[i * 3] = v; + rgb[i * 3 + 1] = v; + rgb[i * 3 + 2] = v; + } + return { rgb, alpha: undefined }; + } + if (colorType === 3) { + if (!palette) throw new PdfError("indexed PNG is missing PLTE chunk"); + const alpha = trns ? new Uint8Array(count) : undefined; + for (let i = 0; i < count; i++) { + const idx = pixels[i]!; + if ((idx + 1) * 3 > palette.length) + throw new PdfError("PNG palette index out of bounds"); + rgb[i * 3] = palette[idx * 3]!; + rgb[i * 3 + 1] = palette[idx * 3 + 1]!; + rgb[i * 3 + 2] = palette[idx * 3 + 2]!; + if (alpha && trns) alpha[i] = trns[idx] ?? 255; + } + return { rgb, alpha }; + } + const alpha = new Uint8Array(count); + if (colorType === 4) { + for (let i = 0; i < count; i++) { + const v = pixels[i * 2]!; + rgb[i * 3] = v; + rgb[i * 3 + 1] = v; + rgb[i * 3 + 2] = v; + alpha[i] = pixels[i * 2 + 1]!; + } + return { rgb, alpha }; + } + for (let i = 0; i < count; i++) { + rgb[i * 3] = pixels[i * 4]!; + rgb[i * 3 + 1] = pixels[i * 4 + 1]!; + rgb[i * 3 + 2] = pixels[i * 4 + 2]!; + alpha[i] = pixels[i * 4 + 3]!; + } + return { rgb, alpha }; +} + +function decodePng(bytes: Uint8Array): PdfDecodedImage { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + let interlace = 0; + let palette: Uint8Array | undefined; + let trns: Uint8Array | undefined; + let sawIend = false; + const idats: Uint8Array[] = []; + + for (let at = 8; at + 8 <= bytes.length;) { + const length = view.getUint32(at); + const type = String.fromCharCode( + bytes[at + 4]!, + bytes[at + 5]!, + bytes[at + 6]!, + bytes[at + 7]!, + ); + const dataStart = at + 8; + const dataEnd = dataStart + length; + if (dataEnd + 4 > bytes.length) + throw new PdfError(`truncated PNG chunk ${type}`); + const chunkData = bytes.subarray(dataStart, dataEnd); + if (type === "IHDR") { + if (length < 13) throw new PdfError("PNG IHDR chunk is too short"); + width = view.getUint32(dataStart); + height = view.getUint32(dataStart + 4); + bitDepth = bytes[dataStart + 8]!; + colorType = bytes[dataStart + 9]!; + interlace = bytes[dataStart + 12]!; + } else if (type === "PLTE") { + palette = chunkData; + } else if (type === "tRNS") { + trns = chunkData; + } else if (type === "IDAT") { + idats.push(chunkData); + } else if (type === "IEND") { + sawIend = true; + break; + } + at = dataEnd + 4; + } + + if (!sawIend) throw new PdfError("PNG is missing IEND chunk"); + if (width === 0 || height === 0) + throw new PdfError("PNG has zero dimensions"); + if (bitDepth !== 8) + throw new PdfError(`unsupported PNG bit depth ${bitDepth}`); + if (interlace !== 0) + throw new PdfError("interlaced PNG images are not supported"); + if (idats.length === 0) throw new PdfError("PNG has no IDAT chunks"); + + const channels = channelsForColorType(colorType); + const inflated = inflateZlib(concatChunks(idats)); + const pixels = unfilterPng(inflated, width, height, channels); + const { rgb, alpha } = splitPixels( + pixels, + width, + height, + colorType, + palette, + trns, + ); + return { + width, + height, + colorSpace: "DeviceRGB", + filter: "FlateDecode", + data: deflateZlib(rgb), + ...(alpha ? { smask: deflateZlib(alpha) } : {}), + }; +} + +export function decodePdfImage(bytes: Uint8Array): PdfDecodedImage { + if (isPng(bytes)) return decodePng(bytes); + if (isJpeg(bytes)) return decodeJpeg(bytes); + throw new PdfError("image is not a valid PNG or JPEG"); +} diff --git a/packages/pdf/src/index.ts b/packages/pdf/src/index.ts new file mode 100644 index 00000000..de9aee50 --- /dev/null +++ b/packages/pdf/src/index.ts @@ -0,0 +1,1188 @@ +import { decodePdfImage, type PdfDecodedImage } from "./image.ts"; +import { PdfError, adler32, deflateZlib, inflateZlib } from "./zlib.ts"; + +export { + PdfError, + adler32, + decodePdfImage, + deflateZlib, + inflateZlib, + type PdfDecodedImage, +}; + +export type PdfRgb = readonly [number, number, number]; +export type PdfColor = string | PdfRgb; + +export type PdfGradientStop = { + color: PdfColor; + at: number; + opacity?: number; +}; + +export type PdfPaint = + | PdfColor + | { + type: "linear"; + angle?: number; + stops: readonly PdfGradientStop[]; + } + | { + type: "radial"; + stops: readonly PdfGradientStop[]; + }; + +export type PdfFontName = + | "Helvetica" + | "Helvetica-Bold" + | "Helvetica-Oblique" + | "Helvetica-BoldOblique"; + +export type PdfBorder = { + width: number; + color: PdfColor; + opacity?: number; + dash?: "solid" | "dash" | "dot"; +}; + +export type PdfRadius = number | readonly [number, number, number, number]; + +export type PdfTextRun = { + text: string; + font?: PdfFontName; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strike?: boolean; + size?: number; + color?: PdfPaint; + letterSpacing?: number; + link?: string | number; +}; + +export type PdfParagraph = { + runs: readonly (string | PdfTextRun)[]; + align?: "left" | "center" | "right" | "justify"; + lineHeight?: number; + spaceBefore?: number; + spaceAfter?: number; + indent?: number; + bullet?: string; + bulletColor?: PdfColor; +}; + +export type PdfRectElement = { + type: "rect"; + x: number; + y: number; + w: number; + h: number; + radius?: PdfRadius; + fill?: PdfPaint; + border?: PdfBorder; + opacity?: number; +}; + +export type PdfRoundRectElement = { + type: "roundRect"; + x: number; + y: number; + w: number; + h: number; + radius: PdfRadius; + fill?: PdfPaint; + border?: PdfBorder; + opacity?: number; +}; + +export type PdfEllipseElement = { + type: "ellipse"; + x: number; + y: number; + w: number; + h: number; + fill?: PdfPaint; + border?: PdfBorder; + opacity?: number; +}; + +export type PdfLineElement = { + type: "line"; + x1: number; + y1: number; + x2: number; + y2: number; + width?: number; + color?: PdfColor; + dash?: "solid" | "dash" | "dot"; + opacity?: number; +}; + +export type PdfTextElement = { + type: "text"; + x: number; + y: number; + w: number; + h: number; + text?: string; + runs?: readonly (string | PdfTextRun)[]; + paragraphs?: readonly PdfParagraph[]; + size?: number; + font?: PdfFontName; + bold?: boolean; + italic?: boolean; + color?: PdfPaint; + align?: "left" | "center" | "right" | "justify"; + anchor?: "top" | "middle" | "bottom"; + wrap?: boolean; + insets?: { top: number; right: number; bottom: number; left: number }; + fill?: PdfPaint; + border?: PdfBorder; + opacity?: number; +}; + +export type PdfImageElement = { + type: "image"; + x: number; + y: number; + w: number; + h: number; + data: Uint8Array; + fit?: "fill" | "contain" | "cover"; + crop?: { top: number; right: number; bottom: number; left: number }; + radius?: PdfRadius; + border?: PdfBorder; + opacity?: number; +}; + +export type PdfElement = + | PdfRectElement + | PdfRoundRectElement + | PdfEllipseElement + | PdfLineElement + | PdfTextElement + | PdfImageElement; + +export type PdfPage = { + width?: number; + height?: number; + background?: PdfPaint; + elements: readonly PdfElement[]; +}; + +const DEFAULT_WIDTH = 960; +const DEFAULT_HEIGHT = 540; +const KAPPA = 0.5522847498; + +const FONT_RESOURCES: Record = { + Helvetica: "F1", + "Helvetica-Bold": "F2", + "Helvetica-Oblique": "F3", + "Helvetica-BoldOblique": "F4", +}; + +const WIN_ANSI_MAP: Record = { + 0x20ac: 0x80, + 0x201a: 0x82, + 0x0192: 0x83, + 0x201e: 0x84, + 0x2026: 0x85, + 0x2020: 0x86, + 0x2021: 0x87, + 0x02c6: 0x88, + 0x2030: 0x89, + 0x0160: 0x8a, + 0x2039: 0x8b, + 0x0152: 0x8c, + 0x017d: 0x8e, + 0x2018: 0x91, + 0x2019: 0x92, + 0x201c: 0x93, + 0x201d: 0x94, + 0x2022: 0x95, + 0x2013: 0x96, + 0x2014: 0x97, + 0x02dc: 0x98, + 0x2122: 0x99, + 0x0161: 0x9a, + 0x203a: 0x9b, + 0x0153: 0x9c, + 0x017e: 0x9e, + 0x0178: 0x9f, +}; + +function fmt(value: number): string { + const rounded = Math.round(value * 1000) / 1000; + if (Object.is(rounded, -0)) return "0"; + return String(rounded); +} + +export function parsePdfColor(color: PdfColor): [number, number, number] { + if (typeof color === "string") { + const clean = color.replace(/^#/, ""); + if (!/^[0-9A-Fa-f]{6}$/.test(clean)) + throw new PdfError(`invalid hex color "${color}"`); + return [ + Number.parseInt(clean.slice(0, 2), 16) / 255, + Number.parseInt(clean.slice(2, 4), 16) / 255, + Number.parseInt(clean.slice(4, 6), 16) / 255, + ]; + } + const [r, g, b] = color; + const scale = r > 1 || g > 1 || b > 1 ? 255 : 1; + return [ + Math.max(0, Math.min(1, r / scale)), + Math.max(0, Math.min(1, g / scale)), + Math.max(0, Math.min(1, b / scale)), + ]; +} + +function isGradient(paint: PdfPaint): paint is Exclude { + return typeof paint === "object" && "stops" in paint; +} + +function primaryColorOf(paint: PdfPaint): [number, number, number] { + if (!isGradient(paint)) return parsePdfColor(paint); + const first = paint.stops[0]; + if (!first) return [0, 0, 0]; + return parsePdfColor(first.color); +} + +function resolveFontName( + font?: PdfFontName, + bold?: boolean, + italic?: boolean, +): PdfFontName { + if (font) return font; + if (bold && italic) return "Helvetica-BoldOblique"; + if (bold) return "Helvetica-Bold"; + if (italic) return "Helvetica-Oblique"; + return "Helvetica"; +} + +function encodePdfText(text: string): string { + let out = "("; + for (let i = 0; i < text.length; i++) { + const code = text.charCodeAt(i); + const mapped = WIN_ANSI_MAP[code] ?? (code <= 0xff ? code : 0x3f); + if (mapped === 0x28) out += "\\("; + else if (mapped === 0x29) out += "\\)"; + else if (mapped === 0x5c) out += "\\\\"; + else if (mapped === 0x0a) out += "\\n"; + else if (mapped === 0x0d) out += "\\r"; + else if (mapped < 0x20 || mapped > 0x7e) + out += `\\${mapped.toString(8).padStart(3, "0")}`; + else out += String.fromCharCode(mapped); + } + return `${out})`; +} + +function charWidthRatio(ch: string, bold: boolean): number { + const boost = bold ? 1.06 : 1; + if (" il.,:;!|'\"`()[]{}".includes(ch)) return 0.28 * boost; + if ("mwMW@%".includes(ch)) return 0.82 * boost; + if (ch >= "A" && ch <= "Z") return 0.66 * boost; + return 0.54 * boost; +} + +export function measureTextWidth( + text: string, + size: number, + bold = false, + letterSpacing = 0, +): number { + let width = 0; + for (const ch of text) + width += charWidthRatio(ch, bold) * size + letterSpacing; + return width; +} + +type StyledSegment = { + text: string; + font: PdfFontName; + bold: boolean; + italic: boolean; + underline: boolean; + strike: boolean; + size: number; + color: [number, number, number]; + letterSpacing: number; +}; + +type WrappedLine = { + segments: StyledSegment[]; + width: number; + maxSize: number; + indent: number; + bullet?: { text: string; color: [number, number, number]; size: number }; +}; + +function normalizeRun( + raw: string | PdfTextRun, + defaults: { + font?: PdfFontName; + bold?: boolean; + italic?: boolean; + size: number; + color: [number, number, number]; + }, +): StyledSegment { + if (typeof raw === "string") { + const bold = defaults.bold ?? false; + const italic = defaults.italic ?? false; + return { + text: raw, + font: resolveFontName(defaults.font, bold, italic), + bold, + italic, + underline: false, + strike: false, + size: defaults.size, + color: defaults.color, + letterSpacing: 0, + }; + } + const bold = raw.bold ?? defaults.bold ?? false; + const italic = raw.italic ?? defaults.italic ?? false; + return { + text: raw.text, + font: resolveFontName(raw.font ?? defaults.font, bold, italic), + bold, + italic, + underline: raw.underline ?? false, + strike: raw.strike ?? false, + size: raw.size ?? defaults.size, + color: raw.color ? primaryColorOf(raw.color) : defaults.color, + letterSpacing: raw.letterSpacing === undefined ? 0 : raw.letterSpacing, + }; +} + +function splitSegmentsByNewline( + segments: readonly StyledSegment[], +): StyledSegment[][] { + const lines: StyledSegment[][] = [[]]; + for (const seg of segments) { + const parts = seg.text.split(/\r?\n/); + for (let i = 0; i < parts.length; i++) { + if (i > 0) lines.push([]); + const part = parts[i]!; + if (part.length > 0) lines.at(-1)!.push({ ...seg, text: part }); + } + } + return lines; +} + +function sameStyle(a: StyledSegment, b: StyledSegment): boolean { + return ( + a.font === b.font && + a.bold === b.bold && + a.italic === b.italic && + a.underline === b.underline && + a.strike === b.strike && + a.size === b.size && + a.letterSpacing === b.letterSpacing && + a.color[0] === b.color[0] && + a.color[1] === b.color[1] && + a.color[2] === b.color[2] + ); +} + +function appendToken(list: StyledSegment[], token: StyledSegment) { + const prev = list.at(-1); + if (prev && sameStyle(prev, token)) prev.text += token.text; + else list.push({ ...token }); +} + +function wrapLogicalLine( + segments: readonly StyledSegment[], + maxWidth: number, + wrap: boolean, + defaultSize: number, +): { segments: StyledSegment[]; width: number; maxSize: number }[] { + if (segments.length === 0) + return [{ segments: [], width: 0, maxSize: defaultSize }]; + if (!wrap || maxWidth <= 0) { + const merged: StyledSegment[] = []; + let width = 0; + let maxSize = 0; + for (const s of segments) { + appendToken(merged, s); + width += measureTextWidth(s.text, s.size, s.bold, s.letterSpacing); + if (s.size > maxSize) maxSize = s.size; + } + return [ + { segments: merged, width, maxSize: maxSize > 0 ? maxSize : defaultSize }, + ]; + } + + const tokens: StyledSegment[] = []; + for (const seg of segments) { + const parts = seg.text.match(/\S+\s*|\s+/g) ?? [seg.text]; + for (const part of parts) tokens.push({ ...seg, text: part }); + } + + const wrapped: { + segments: StyledSegment[]; + width: number; + maxSize: number; + }[] = []; + let current: StyledSegment[] = []; + let currentWidth = 0; + let currentMaxSize = 0; + + for (const token of tokens) { + const tokenWidth = measureTextWidth( + token.text, + token.size, + token.bold, + token.letterSpacing, + ); + if (current.length > 0 && currentWidth + tokenWidth > maxWidth) { + wrapped.push({ + segments: current, + width: currentWidth, + maxSize: currentMaxSize > 0 ? currentMaxSize : defaultSize, + }); + const trimmed = token.text.replace(/^\s+/, ""); + if (trimmed.length === 0) { + current = []; + currentWidth = 0; + currentMaxSize = 0; + continue; + } + const trimmedSeg = { ...token, text: trimmed }; + current = [trimmedSeg]; + currentWidth = measureTextWidth( + trimmed, + token.size, + token.bold, + token.letterSpacing, + ); + currentMaxSize = token.size; + } else { + appendToken(current, token); + currentWidth += tokenWidth; + if (token.size > currentMaxSize) currentMaxSize = token.size; + } + } + if (current.length > 0 || wrapped.length === 0) + wrapped.push({ + segments: current, + width: currentWidth, + maxSize: currentMaxSize > 0 ? currentMaxSize : defaultSize, + }); + return wrapped; +} + +class PageBuilder { + readonly commands: string[] = []; + readonly alphas = new Map(); + readonly shadings = new Map(); + readonly images = new Set(); + + constructor( + readonly pageWidth: number, + readonly pageHeight: number, + readonly registerImage: (bytes: Uint8Array) => { + name: string; + info: PdfDecodedImage; + }, + ) {} + + alphaName(opacity: number): string | undefined { + const clamped = Math.max(0, Math.min(1, opacity)); + if (clamped >= 0.999) return undefined; + const key = Math.round(clamped * 1000); + const existing = this.alphas.get(key); + if (existing) return existing; + const name = `GS${this.alphas.size + 1}`; + this.alphas.set(key, name); + return name; + } + + addLinearShading( + x0: number, + y0: number, + x1: number, + y1: number, + c0: [number, number, number], + c1: [number, number, number], + ): string { + const name = `Sh${this.shadings.size + 1}`; + this.shadings.set( + name, + `<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [${fmt(x0)} ${fmt(y0)} ${fmt(x1)} ${fmt(y1)}] /Function << /FunctionType 2 /Domain [0 1] /C0 [${fmt(c0[0])} ${fmt(c0[1])} ${fmt(c0[2])}] /C1 [${fmt(c1[0])} ${fmt(c1[1])} ${fmt(c1[2])}] /N 1 >> /Extend [true true] >>`, + ); + return name; + } + + addRadialShading( + cx: number, + cy: number, + r: number, + c0: [number, number, number], + c1: [number, number, number], + ): string { + const name = `Sh${this.shadings.size + 1}`; + this.shadings.set( + name, + `<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [${fmt(cx)} ${fmt(cy)} 0 ${fmt(cx)} ${fmt(cy)} ${fmt(r)}] /Function << /FunctionType 2 /Domain [0 1] /C0 [${fmt(c0[0])} ${fmt(c0[1])} ${fmt(c0[2])}] /C1 [${fmt(c1[0])} ${fmt(c1[1])} ${fmt(c1[2])}] /N 1 >> /Extend [true true] >>`, + ); + return name; + } +} + +function rectPath(x: number, y: number, w: number, h: number): string { + return `${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re`; +} + +function roundRectPath( + x: number, + y: number, + w: number, + h: number, + radius: PdfRadius, +): string { + const [rawTl, rawTr, rawBr, rawBl] = + typeof radius === "number" ? [radius, radius, radius, radius] : radius; + const maxR = Math.min(w, h) / 2; + const tl = Math.max(0, Math.min(maxR, rawTl)); + const tr = Math.max(0, Math.min(maxR, rawTr)); + const br = Math.max(0, Math.min(maxR, rawBr)); + const bl = Math.max(0, Math.min(maxR, rawBl)); + if (tl === 0 && tr === 0 && br === 0 && bl === 0) return rectPath(x, y, w, h); + + // In PDF coordinates, y is bottom and y + h is top. + const right = x + w; + const top = y + h; + return [ + `${fmt(x + bl)} ${fmt(y)} m`, + `${fmt(right - br)} ${fmt(y)} l`, + br > 0 + ? `${fmt(right - br * (1 - KAPPA))} ${fmt(y)} ${fmt(right)} ${fmt(y + br * (1 - KAPPA))} ${fmt(right)} ${fmt(y + br)} c` + : "", + `${fmt(right)} ${fmt(top - tr)} l`, + tr > 0 + ? `${fmt(right)} ${fmt(top - tr * (1 - KAPPA))} ${fmt(right - tr * (1 - KAPPA))} ${fmt(top)} ${fmt(right - tr)} ${fmt(top)} c` + : "", + `${fmt(x + tl)} ${fmt(top)} l`, + tl > 0 + ? `${fmt(x + tl * (1 - KAPPA))} ${fmt(top)} ${fmt(x)} ${fmt(top - tl * (1 - KAPPA))} ${fmt(x)} ${fmt(top - tl)} c` + : "", + `${fmt(x)} ${fmt(y + bl)} l`, + bl > 0 + ? `${fmt(x)} ${fmt(y + bl * (1 - KAPPA))} ${fmt(x + bl * (1 - KAPPA))} ${fmt(y)} ${fmt(x + bl)} ${fmt(y)} c` + : "", + "h", + ] + .filter(Boolean) + .join(" "); +} + +function ellipsePath(x: number, y: number, w: number, h: number): string { + const rx = w / 2; + const ry = h / 2; + const cx = x + rx; + const cy = y + ry; + const ox = rx * KAPPA; + const oy = ry * KAPPA; + return [ + `${fmt(cx + rx)} ${fmt(cy)} m`, + `${fmt(cx + rx)} ${fmt(cy + oy)} ${fmt(cx + ox)} ${fmt(cy + ry)} ${fmt(cx)} ${fmt(cy + ry)} c`, + `${fmt(cx - ox)} ${fmt(cy + ry)} ${fmt(cx - rx)} ${fmt(cy + oy)} ${fmt(cx - rx)} ${fmt(cy)} c`, + `${fmt(cx - rx)} ${fmt(cy - oy)} ${fmt(cx - ox)} ${fmt(cy - ry)} ${fmt(cx)} ${fmt(cy - ry)} c`, + `${fmt(cx + ox)} ${fmt(cy - ry)} ${fmt(cx + rx)} ${fmt(cy - oy)} ${fmt(cx + rx)} ${fmt(cy)} c`, + "h", + ].join(" "); +} + +function dashOperator(dash: PdfBorder["dash"], width: number): string { + if (dash === "dash") return `[${fmt(width * 3)} ${fmt(width * 2)}] 0 d`; + if (dash === "dot") return `[${fmt(width)} ${fmt(width * 2)}] 0 d`; + return "[] 0 d"; +} + +function fillAndStrokePath( + builder: PageBuilder, + pathCmd: string, + box: { x: number; y: number; w: number; h: number }, + fill: PdfPaint | undefined, + border: PdfBorder | undefined, + opacity = 1, +) { + builder.commands.push("q"); + const gs = builder.alphaName(opacity); + if (gs) builder.commands.push(`/${gs} gs`); + + if (fill !== undefined) { + if (!isGradient(fill)) { + const [r, g, b] = parsePdfColor(fill); + builder.commands.push(`${fmt(r)} ${fmt(g)} ${fmt(b)} rg ${pathCmd} f`); + } else { + const first = fill.stops[0]; + const last = fill.stops.at(-1); + const c0 = first + ? parsePdfColor(first.color) + : ([0, 0, 0] as [number, number, number]); + const c1 = last ? parsePdfColor(last.color) : c0; + let shName: string; + if (fill.type === "radial") { + shName = builder.addRadialShading( + box.x + box.w / 2, + box.y + box.h / 2, + Math.max(box.w, box.h) / 2, + c0, + c1, + ); + } else { + const rad = (((fill.angle ?? 135) % 360) * Math.PI) / 180; + const vx = Math.sin(rad); + const vy = Math.cos(rad); + const halfLen = (Math.abs(box.w * vx) + Math.abs(box.h * vy)) / 2; + const cx = box.x + box.w / 2; + const cy = box.y + box.h / 2; + shName = builder.addLinearShading( + cx - vx * halfLen, + cy - vy * halfLen, + cx + vx * halfLen, + cy + vy * halfLen, + c0, + c1, + ); + } + builder.commands.push( + `q ${pathCmd} W n ${fmt(c0[0])} ${fmt(c0[1])} ${fmt(c0[2])} rg /${shName} sh Q`, + ); + } + } + + if (border !== undefined && border.width > 0) { + const [r, g, b] = parsePdfColor(border.color); + const borderGs = builder.alphaName(opacity * (border.opacity ?? 1)); + if (borderGs) builder.commands.push(`/${borderGs} gs`); + builder.commands.push( + `${fmt(border.width)} w ${dashOperator(border.dash, border.width)} ${fmt(r)} ${fmt(g)} ${fmt(b)} RG ${pathCmd} S`, + ); + } + builder.commands.push("Q"); +} + +function drawLineElement(builder: PageBuilder, el: PdfLineElement) { + const width = el.width ?? 1; + const [r, g, b] = parsePdfColor(el.color ?? "#000000"); + const y1 = builder.pageHeight - el.y1; + const y2 = builder.pageHeight - el.y2; + builder.commands.push("q"); + const gs = builder.alphaName(el.opacity ?? 1); + if (gs) builder.commands.push(`/${gs} gs`); + builder.commands.push( + `${fmt(width)} w ${dashOperator(el.dash, width)} ${fmt(r)} ${fmt(g)} ${fmt(b)} RG ${fmt(el.x1)} ${fmt(y1)} m ${fmt(el.x2)} ${fmt(y2)} l S`, + ); + builder.commands.push("Q"); +} + +function drawImageElement(builder: PageBuilder, el: PdfImageElement) { + const { name, info } = builder.registerImage(el.data); + builder.images.add(name); + const pdfY = builder.pageHeight - el.y - el.h; + const box = { x: el.x, y: pdfY, w: el.w, h: el.h }; + + let drawBox = box; + if (el.fit === "contain") { + const scale = Math.min(el.w / info.width, el.h / info.height); + const w = info.width * scale; + const h = info.height * scale; + drawBox = { + x: box.x + (box.w - w) / 2, + y: box.y + (box.h - h) / 2, + w, + h, + }; + } else if (el.fit === "cover") { + const scale = Math.max(el.w / info.width, el.h / info.height); + const w = info.width * scale; + const h = info.height * scale; + drawBox = { + x: box.x + (box.w - w) / 2, + y: box.y + (box.h - h) / 2, + w, + h, + }; + } else if (el.crop) { + const { top, right, bottom, left } = el.crop; + const visW = Math.max(1e-6, 1 - left - right); + const visH = Math.max(1e-6, 1 - top - bottom); + const w = box.w / visW; + const h = box.h / visH; + drawBox = { + x: box.x - left * w, + y: box.y - bottom * h, + w, + h, + }; + } + + const clipPath = el.radius + ? roundRectPath(box.x, box.y, box.w, box.h, el.radius) + : rectPath(box.x, box.y, box.w, box.h); + + builder.commands.push("q"); + const gs = builder.alphaName(el.opacity ?? 1); + if (gs) builder.commands.push(`/${gs} gs`); + if (el.fit === "cover" || el.crop !== undefined || el.radius !== undefined) + builder.commands.push(`${clipPath} W n`); + builder.commands.push( + `q ${fmt(drawBox.w)} 0 0 ${fmt(drawBox.h)} ${fmt(drawBox.x)} ${fmt(drawBox.y)} cm /${name} Do Q`, + ); + builder.commands.push("Q"); + + if (el.border) + fillAndStrokePath( + builder, + clipPath, + box, + undefined, + el.border, + el.opacity ?? 1, + ); +} + +function paragraphsOfTextElement(el: PdfTextElement): PdfParagraph[] { + if (el.paragraphs && el.paragraphs.length > 0) return [...el.paragraphs]; + if (el.runs && el.runs.length > 0) { + return [ + { + runs: el.runs, + ...(el.align ? { align: el.align } : {}), + }, + ]; + } + if (el.text !== undefined) { + return [ + { + runs: [el.text], + ...(el.align ? { align: el.align } : {}), + }, + ]; + } + return []; +} + +function drawTextElement(builder: PageBuilder, el: PdfTextElement) { + const pdfY = builder.pageHeight - el.y - el.h; + const box = { x: el.x, y: pdfY, w: el.w, h: el.h }; + if (el.fill !== undefined || el.border !== undefined) { + fillAndStrokePath( + builder, + rectPath(box.x, box.y, box.w, box.h), + box, + el.fill, + el.border, + el.opacity ?? 1, + ); + } + + const insets = el.insets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const innerX = el.x + insets.left; + const innerY = el.y + insets.top; + const innerW = Math.max(0, el.w - insets.left - insets.right); + const innerH = Math.max(0, el.h - insets.top - insets.bottom); + const defaultSize = el.size ?? 18; + const defaultColor = el.color + ? primaryColorOf(el.color) + : ([0, 0, 0] as [number, number, number]); + const wrap = el.wrap ?? true; + + type LayoutBlock = { + align: "left" | "center" | "right" | "justify"; + lineHeight: number; + spaceBefore: number; + spaceAfter: number; + lines: WrappedLine[]; + }; + + const blocks: LayoutBlock[] = []; + for (const para of paragraphsOfTextElement(el)) { + const indent = para.indent === undefined ? 0 : para.indent; + const segments = para.runs.map((r) => + normalizeRun(r, { + ...(el.font ? { font: el.font } : {}), + ...(el.bold !== undefined ? { bold: el.bold } : {}), + ...(el.italic !== undefined ? { italic: el.italic } : {}), + size: defaultSize, + color: defaultColor, + }), + ); + const logical = splitSegmentsByNewline(segments); + const paraLines: WrappedLine[] = []; + for (const [lineIdx, lineSegs] of logical.entries()) { + const wrapped = wrapLogicalLine( + lineSegs, + Math.max(1, innerW - indent), + wrap, + defaultSize, + ); + for (const [wrapIdx, wLine] of wrapped.entries()) { + const isFirst = lineIdx === 0 && wrapIdx === 0; + paraLines.push({ + segments: wLine.segments, + width: wLine.width, + maxSize: wLine.maxSize, + indent, + ...(isFirst && para.bullet + ? { + bullet: { + text: para.bullet, + color: para.bulletColor + ? parsePdfColor(para.bulletColor) + : defaultColor, + size: wLine.maxSize, + }, + } + : {}), + }); + } + } + const maxParaSize = + paraLines.length > 0 + ? Math.max(...paraLines.map((l) => l.maxSize)) + : defaultSize; + blocks.push({ + align: para.align ?? el.align ?? "left", + lineHeight: para.lineHeight ?? maxParaSize * 1.25, + spaceBefore: para.spaceBefore === undefined ? 0 : para.spaceBefore, + spaceAfter: para.spaceAfter === undefined ? 0 : para.spaceAfter, + lines: paraLines, + }); + } + + let totalHeight = 0; + for (const b of blocks) + totalHeight += b.spaceBefore + b.lines.length * b.lineHeight + b.spaceAfter; + + let cursorTop = innerY; + if (el.anchor === "middle") + cursorTop = innerY + Math.max(0, (innerH - totalHeight) / 2); + else if (el.anchor === "bottom") + cursorTop = innerY + Math.max(0, innerH - totalHeight); + + builder.commands.push("q"); + const gs = builder.alphaName(el.opacity ?? 1); + if (gs) builder.commands.push(`/${gs} gs`); + + for (const b of blocks) { + cursorTop += b.spaceBefore; + for (const line of b.lines) { + const baselineY = builder.pageHeight - (cursorTop + line.maxSize * 0.82); + let startX = innerX + line.indent; + if (b.align === "center") + startX = + innerX + + line.indent + + Math.max(0, (innerW - line.indent - line.width) / 2); + else if (b.align === "right") + startX = innerX + Math.max(line.indent, innerW - line.width); + + if (line.bullet) { + const [br, bg, bb] = line.bullet.color; + const bulletX = Math.max(innerX, startX - line.bullet.size * 0.9); + builder.commands.push( + `BT /F1 ${fmt(line.bullet.size)} Tf ${fmt(br)} ${fmt(bg)} ${fmt(bb)} rg 1 0 0 1 ${fmt(bulletX)} ${fmt(baselineY)} Tm ${encodePdfText(line.bullet.text)} Tj ET`, + ); + } + + let x = startX; + for (const seg of line.segments) { + if (seg.text.length === 0) continue; + const fontRes = FONT_RESOURCES[seg.font]; + const [cr, cg, cb] = seg.color; + const tc = + seg.letterSpacing !== 0 ? ` ${fmt(seg.letterSpacing)} Tc` : ""; + const resetTc = seg.letterSpacing !== 0 ? " 0 Tc" : ""; + builder.commands.push( + `BT /${fontRes} ${fmt(seg.size)} Tf ${fmt(cr)} ${fmt(cg)} ${fmt(cb)} rg${tc} 1 0 0 1 ${fmt(x)} ${fmt(baselineY)} Tm ${encodePdfText(seg.text)} Tj${resetTc} ET`, + ); + const segW = measureTextWidth( + seg.text, + seg.size, + seg.bold, + seg.letterSpacing, + ); + if (seg.underline) { + const uy = baselineY - seg.size * 0.1; + builder.commands.push( + `${fmt(Math.max(0.75, seg.size * 0.05))} w [] 0 d ${fmt(cr)} ${fmt(cg)} ${fmt(cb)} RG ${fmt(x)} ${fmt(uy)} m ${fmt(x + segW)} ${fmt(uy)} l S`, + ); + } + if (seg.strike) { + const sy = baselineY + seg.size * 0.28; + builder.commands.push( + `${fmt(Math.max(0.75, seg.size * 0.05))} w [] 0 d ${fmt(cr)} ${fmt(cg)} ${fmt(cb)} RG ${fmt(x)} ${fmt(sy)} m ${fmt(x + segW)} ${fmt(sy)} l S`, + ); + } + x += segW; + } + cursorTop += b.lineHeight; + } + cursorTop += b.spaceAfter; + } + + builder.commands.push("Q"); +} + +function drawElement(builder: PageBuilder, el: PdfElement) { + switch (el.type) { + case "rect": { + const pdfY = builder.pageHeight - el.y - el.h; + const box = { x: el.x, y: pdfY, w: el.w, h: el.h }; + const path = el.radius + ? roundRectPath(box.x, box.y, box.w, box.h, el.radius) + : rectPath(box.x, box.y, box.w, box.h); + fillAndStrokePath( + builder, + path, + box, + el.fill, + el.border, + el.opacity ?? 1, + ); + break; + } + case "roundRect": { + const pdfY = builder.pageHeight - el.y - el.h; + const box = { x: el.x, y: pdfY, w: el.w, h: el.h }; + fillAndStrokePath( + builder, + roundRectPath(box.x, box.y, box.w, box.h, el.radius), + box, + el.fill, + el.border, + el.opacity ?? 1, + ); + break; + } + case "ellipse": { + const pdfY = builder.pageHeight - el.y - el.h; + const box = { x: el.x, y: pdfY, w: el.w, h: el.h }; + fillAndStrokePath( + builder, + ellipsePath(box.x, box.y, box.w, box.h), + box, + el.fill, + el.border, + el.opacity ?? 1, + ); + break; + } + case "line": + drawLineElement(builder, el); + break; + case "image": + drawImageElement(builder, el); + break; + case "text": + drawTextElement(builder, el); + break; + } +} + +const asciiEncoder = new TextEncoder(); + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const part of parts) { + out.set(part, at); + at += part.length; + } + return out; +} + +function byteHash(bytes: Uint8Array): string { + return `${bytes.byteLength}:${adler32(bytes)}`; +} + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +/** + * Writes pages as a deterministic PDF 1.4 document. Equal input always produces + * identical bytes. + */ +export function writePdf(pages: readonly PdfPage[]): Uint8Array { + if (pages.length === 0) + throw new PdfError("PDF document must have at least one page"); + + type ImageRecord = { + bytes: Uint8Array; + name: string; + info: PdfDecodedImage; + objId: number; + smaskObjId?: number; + }; + + let nextObjId = 7; + const allocateObj = () => nextObjId++; + + const allImages: ImageRecord[] = []; + const imageByHash = new Map(); + const registerImage = (bytes: Uint8Array) => { + const key = byteHash(bytes); + const existingBucket = imageByHash.get(key); + const bucket = existingBucket === undefined ? [] : existingBucket; + const existing = bucket.find((entry) => bytesEqual(entry.bytes, bytes)); + if (existing) return { name: existing.name, info: existing.info }; + const info = decodePdfImage(bytes); + const name = `Im${allImages.length + 1}`; + const smaskObjId = info.smask ? allocateObj() : undefined; + const objId = allocateObj(); + const record: ImageRecord = { + bytes, + name, + info, + objId, + ...(smaskObjId !== undefined ? { smaskObjId } : {}), + }; + bucket.push(record); + imageByHash.set(key, bucket); + allImages.push(record); + return { name, info }; + }; + + const builtPages = pages.map((page) => { + const width = page.width ?? DEFAULT_WIDTH; + const height = page.height ?? DEFAULT_HEIGHT; + const builder = new PageBuilder(width, height, registerImage); + if (page.background !== undefined) { + const box = { x: 0, y: 0, w: width, h: height }; + fillAndStrokePath( + builder, + rectPath(0, 0, width, height), + box, + page.background, + undefined, + 1, + ); + } + for (const el of page.elements) drawElement(builder, el); + const contentObjId = allocateObj(); + const pageObjId = allocateObj(); + return { width, height, builder, contentObjId, pageObjId }; + }); + + const objects = new Map(); + const setAsciiObj = (id: number, body: string) => { + objects.set(id, asciiEncoder.encode(`${id} 0 obj\n${body}\nendobj\n`)); + }; + const setStreamObj = (id: number, dict: string, stream: Uint8Array) => { + objects.set( + id, + concatBytes([ + asciiEncoder.encode( + `${id} 0 obj\n<< ${dict} /Length ${stream.byteLength} >>\nstream\n`, + ), + stream, + asciiEncoder.encode("\nendstream\nendobj\n"), + ]), + ); + }; + + setAsciiObj(1, "<< /Type /Catalog /Pages 2 0 R >>"); + const kids = builtPages.map((p) => `${p.pageObjId} 0 R`).join(" "); + setAsciiObj( + 2, + `<< /Type /Pages /Kids [${kids}] /Count ${builtPages.length} >>`, + ); + + const fontEntries = Object.entries(FONT_RESOURCES) as [PdfFontName, string][]; + fontEntries.forEach(([fontName], idx) => { + setAsciiObj( + 3 + idx, + `<< /Type /Font /Subtype /Type1 /BaseFont /${fontName} /Encoding /WinAnsiEncoding >>`, + ); + }); + + for (const record of allImages) { + const { info, objId, smaskObjId } = record; + if (smaskObjId !== undefined && info.smask) { + setStreamObj( + smaskObjId, + `/Type /XObject /Subtype /Image /Width ${info.width} /Height ${info.height} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode`, + info.smask, + ); + } + const smaskRef = + smaskObjId !== undefined ? ` /SMask ${smaskObjId} 0 R` : ""; + setStreamObj( + objId, + `/Type /XObject /Subtype /Image /Width ${info.width} /Height ${info.height} /ColorSpace /${info.colorSpace} /BitsPerComponent 8 /Filter /${info.filter}${smaskRef}`, + info.data, + ); + } + + const imageByName = new Map(); + for (const rec of allImages) imageByName.set(rec.name, rec); + + for (const p of builtPages) { + const streamBytes = asciiEncoder.encode(p.builder.commands.join("\n")); + setStreamObj(p.contentObjId, "", streamBytes); + + const fontDict = fontEntries + .map(([, resName], idx) => `/${resName} ${3 + idx} 0 R`) + .join(" "); + const xobjects = [...p.builder.images] + .map((name) => `/${name} ${imageByName.get(name)!.objId} 0 R`) + .join(" "); + const extGStates = [...p.builder.alphas.entries()] + .map( + ([key, name]) => + `/${name} << /Type /ExtGState /ca ${fmt(key / 1000)} /CA ${fmt(key / 1000)} >>`, + ) + .join(" "); + const shadings = [...p.builder.shadings.entries()] + .map(([name, dict]) => `/${name} ${dict}`) + .join(" "); + + const resourceParts = [ + `/Font << ${fontDict} >>`, + xobjects ? `/XObject << ${xobjects} >>` : "", + extGStates ? `/ExtGState << ${extGStates} >>` : "", + shadings ? `/Shading << ${shadings} >>` : "", + ] + .filter(Boolean) + .join(" "); + + setAsciiObj( + p.pageObjId, + `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${fmt(p.width)} ${fmt(p.height)}] /Resources << ${resourceParts} >> /Contents ${p.contentObjId} 0 R >>`, + ); + } + + const totalObjects = nextObjId - 1; + const chunks: Uint8Array[] = [asciiEncoder.encode("%PDF-1.4\n")]; + const offsets = new Uint32Array(totalObjects + 1); + let byteOffset = chunks[0]!.byteLength; + + for (let id = 1; id <= totalObjects; id++) { + const chunk = objects.get(id); + if (!chunk) throw new PdfError(`missing PDF object ${id}`); + offsets[id] = byteOffset; + chunks.push(chunk); + byteOffset += chunk.byteLength; + } + + const xrefOffset = byteOffset; + const xrefLines = [`xref\n0 ${totalObjects + 1}\n0000000000 65535 f \n`]; + for (let id = 1; id <= totalObjects; id++) + xrefLines.push(`${String(offsets[id]!).padStart(10, "0")} 00000 n \n`); + xrefLines.push( + `trailer\n<< /Size ${totalObjects + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`, + ); + chunks.push(asciiEncoder.encode(xrefLines.join(""))); + return concatBytes(chunks); +} diff --git a/packages/pdf/src/pdf.test.ts b/packages/pdf/src/pdf.test.ts new file mode 100644 index 00000000..60e9cc6c --- /dev/null +++ b/packages/pdf/src/pdf.test.ts @@ -0,0 +1,312 @@ +import { expect, test } from "bun:test"; +import { crc32, deflateSync, inflateSync } from "node:zlib"; + +import { + PdfError, + deflateZlib, + inflateZlib, + writePdf, + type PdfPage, +} from "./index.ts"; + +function pngChunk(kind: string, data: Uint8Array) { + const body = Buffer.concat([Buffer.from(kind), data]); + const length = Buffer.alloc(4); + length.writeUInt32BE(data.length); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(body)); + return Buffer.concat([length, body, crc]); +} + +function makeRgbPng( + width: number, + height: number, + rgb: [number, number, number], +) { + const row = Buffer.alloc(1 + width * 3); + for (let x = 0; x < width; x++) row.set(rgb, 1 + x * 3); + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 2; + return new Uint8Array( + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk( + "IDAT", + deflateSync(Buffer.concat(Array.from({ length: height }, () => row))), + ), + pngChunk("IEND", new Uint8Array()), + ]), + ); +} + +function makeRgbaPng( + width: number, + height: number, + rgba: [number, number, number, number], +) { + const row = Buffer.alloc(1 + width * 4); + row[0] = 1; // Sub filter to exercise PNG unfiltering + for (let x = 0; x < width; x++) { + for (let c = 0; c < 4; c++) { + const prev = x > 0 ? rgba[c]! : 0; + row[1 + x * 4 + c] = (rgba[c]! - prev) & 0xff; + } + } + const header = Buffer.alloc(13); + header.writeUInt32BE(width, 0); + header.writeUInt32BE(height, 4); + header[8] = 8; + header[9] = 6; + return new Uint8Array( + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + pngChunk("IHDR", header), + pngChunk( + "IDAT", + deflateSync(Buffer.concat(Array.from({ length: height }, () => row))), + ), + pngChunk("IEND", new Uint8Array()), + ]), + ); +} + +function makeMinimalJpeg(width: number, height: number) { + return new Uint8Array([ + 0xff, + 0xd8, + 0xff, + 0xe0, + 0x00, + 0x10, + 0x4a, + 0x46, + 0x49, + 0x46, + 0x00, + 0x01, + 0x01, + 0x00, + 0x00, + 0x01, + 0x00, + 0x01, + 0x00, + 0x00, + 0xff, + 0xc0, + 0x00, + 0x11, + 0x08, + (height >> 8) & 0xff, + height & 0xff, + (width >> 8) & 0xff, + width & 0xff, + 0x03, + 0x01, + 0x11, + 0x00, + 0x02, + 0x11, + 0x01, + 0x03, + 0x11, + 0x01, + 0xff, + 0xd9, + ]); +} + +test("deflateZlib and inflateZlib interoperate with node:zlib", () => { + const sample = new TextEncoder().encode( + "Hello PDF 1.4! ".repeat(64) + "Unique tail 12345", + ); + const compressed = deflateZlib(sample); + expect(new Uint8Array(inflateSync(compressed))).toEqual(sample); + expect(inflateZlib(compressed)).toEqual(sample); + + const nodeCompressed = new Uint8Array(deflateSync(sample)); + expect(inflateZlib(nodeCompressed)).toEqual(sample); +}); + +test("writePdf produces deterministic PDF 1.4 bytes with shapes, text, gradients and images", () => { + const rgbPng = makeRgbPng(16, 8, [109, 40, 217]); + const rgbaPng = makeRgbaPng(10, 10, [14, 165, 233, 128]); + const jpeg = makeMinimalJpeg(32, 24); + + const pages: PdfPage[] = [ + { + width: 960, + height: 540, + background: "#FFFFFF", + elements: [ + { + type: "roundRect", + x: 40, + y: 40, + w: 880, + h: 460, + radius: 16, + fill: { + type: "linear", + angle: 135, + stops: [ + { color: "#6D28D9", at: 0 }, + { color: "#0EA5E9", at: 100 }, + ], + }, + border: { width: 2, color: "#1B1B1F", dash: "dash" }, + }, + { + type: "ellipse", + x: 700, + y: 60, + w: 120, + h: 120, + opacity: 0.75, + fill: { + type: "radial", + stops: [ + { color: "#F59E0B", at: 0 }, + { color: "#FFFFFF", at: 100 }, + ], + }, + }, + { + type: "line", + x1: 60, + y1: 200, + x2: 900, + y2: 200, + width: 1.5, + color: "#6B7280", + dash: "dot", + }, + { + type: "text", + x: 60, + y: 60, + w: 600, + h: 120, + anchor: "middle", + paragraphs: [ + { + align: "left", + runs: [ + { + text: "Slide Title: ", + size: 32, + bold: true, + color: "#1B1B1F", + }, + { + text: "Wrapped & styled (italic)", + size: 24, + italic: true, + underline: true, + color: "#6D28D9", + }, + ], + }, + { + align: "center", + bullet: "•", + indent: 20, + runs: [ + { + text: "A long paragraph that wraps cleanly across lines inside its bounding box so every run is placed deterministically.", + size: 16, + bold: true, + italic: true, + strike: true, + color: "#374151", + }, + ], + }, + ], + }, + { + type: "image", + x: 60, + y: 240, + w: 200, + h: 100, + data: rgbPng, + fit: "contain", + }, + { + type: "image", + x: 300, + y: 240, + w: 120, + h: 120, + data: rgbaPng, + fit: "cover", + radius: [12, 0, 12, 0], + border: { width: 1, color: "#1B1B1F" }, + }, + { + type: "image", + x: 460, + y: 240, + w: 160, + h: 120, + data: jpeg, + fit: "fill", + }, + ], + }, + ]; + + const first = writePdf(pages); + const second = writePdf(pages); + expect(second).toEqual(first); + + const text = new TextDecoder("latin1").decode(first); + expect(text.startsWith("%PDF-1.4\n")).toBe(true); + expect(text.trimEnd().endsWith("%%EOF")).toBe(true); + expect(text).toContain("/MediaBox [0 0 960 540]"); + expect(text).toContain("/BaseFont /Helvetica"); + expect(text).toContain("/BaseFont /Helvetica-Bold"); + expect(text).toContain("/BaseFont /Helvetica-Oblique"); + expect(text).toContain("/BaseFont /Helvetica-BoldOblique"); + expect(text).toContain("/Filter /FlateDecode"); + expect(text).toContain("/Filter /DCTDecode"); + expect(text).toContain("/SMask "); + expect(text).toContain("(Slide Title: ) Tj"); + + // Verify every xref entry points to the exact byte offset of its object. + const xrefAt = Number(text.match(/startxref\n(\d+)\n%%EOF/)?.[1]); + expect(xrefAt).toBeGreaterThan(0); + expect(text.slice(xrefAt, xrefAt + 4)).toBe("xref"); + const entries = [...text.slice(xrefAt).matchAll(/^(\d{10}) 00000 n $/gm)].map( + (m) => Number(m[1]), + ); + expect(entries.length).toBeGreaterThanOrEqual(8); + entries.forEach((offset, index) => { + expect(text.slice(offset, offset + 12)).toStartWith(`${index + 1} 0 obj\n`); + }); +}); + +test("writePdf rejects empty page lists and corrupt images", () => { + expect(() => writePdf([])).toThrow(PdfError); + expect(() => + writePdf([ + { + elements: [ + { + type: "image", + x: 0, + y: 0, + w: 100, + h: 100, + data: new Uint8Array([1, 2, 3, 4]), + }, + ], + }, + ]), + ).toThrow(PdfError); +}); diff --git a/packages/pdf/src/zlib.ts b/packages/pdf/src/zlib.ts new file mode 100644 index 00000000..2278ee90 --- /dev/null +++ b/packages/pdf/src/zlib.ts @@ -0,0 +1,366 @@ +export class PdfError extends Error { + override name = "PdfError"; +} + +const ADLER_MOD = 65521; + +export function adler32(data: Uint8Array): number { + let a = 1; + let b = 0; + for (let i = 0; i < data.length;) { + const end = Math.min(i + 5552, data.length); + for (; i < end; i++) { + a += data[i]!; + b += a; + } + a %= ADLER_MOD; + b %= ADLER_MOD; + } + return ((b << 16) | a) >>> 0; +} + +class BitWriter { + readonly #bytes: number[] = []; + #buf = 0; + #bits = 0; + + writeBits(value: number, count: number) { + this.#buf |= value << this.#bits; + this.#bits += count; + while (this.#bits >= 8) { + this.#bytes.push(this.#buf & 0xff); + this.#buf >>>= 8; + this.#bits -= 8; + } + } + + writeHuffman(code: number, length: number) { + let rev = 0; + for (let i = 0; i < length; i++) rev = (rev << 1) | ((code >>> i) & 1); + this.writeBits(rev, length); + } + + flushByte() { + if (this.#bits > 0) { + this.#bytes.push(this.#buf & 0xff); + this.#buf = 0; + this.#bits = 0; + } + } + + writeUint32BE(value: number) { + this.flushByte(); + this.#bytes.push( + (value >>> 24) & 0xff, + (value >>> 16) & 0xff, + (value >>> 8) & 0xff, + value & 0xff, + ); + } + + toUint8Array(): Uint8Array { + this.flushByte(); + return new Uint8Array(this.#bytes); + } +} + +function writeFixedLiteral(writer: BitWriter, symbol: number) { + if (symbol <= 143) writer.writeHuffman(0x30 + symbol, 8); + else if (symbol <= 255) writer.writeHuffman(0x190 + (symbol - 144), 9); + else if (symbol <= 279) writer.writeHuffman(symbol - 256, 7); + else writer.writeHuffman(0xc0 + (symbol - 280), 8); +} + +const LENGTH_BASE = [ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, + 83, 99, 115, 131, 163, 195, 227, 258, +]; +const LENGTH_EXTRA = [ + 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, + 5, 5, 0, +]; +const DIST_BASE = [ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, + 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577, +]; +const DIST_EXTRA = [ + 0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, + 11, 12, 12, 13, 13, +]; + +function findSlot(table: readonly number[], value: number) { + let slot = 0; + for (let i = 0; i < table.length; i++) { + if (table[i]! <= value) slot = i; + else break; + } + return slot; +} + +function writeMatch(writer: BitWriter, length: number, distance: number) { + const lenSlot = findSlot(LENGTH_BASE, length); + writeFixedLiteral(writer, 257 + lenSlot); + const lenExtra = LENGTH_EXTRA[lenSlot]!; + if (lenExtra > 0) writer.writeBits(length - LENGTH_BASE[lenSlot]!, lenExtra); + + const distSlot = findSlot(DIST_BASE, distance); + writer.writeHuffman(distSlot, 5); + const distExtra = DIST_EXTRA[distSlot]!; + if (distExtra > 0) + writer.writeBits(distance - DIST_BASE[distSlot]!, distExtra); +} + +function hash3(data: Uint8Array, pos: number) { + return ((data[pos]! << 10) ^ (data[pos + 1]! << 5) ^ data[pos + 2]!) & 0x3fff; +} + +function matchLength(data: Uint8Array, a: number, b: number, maxLen: number) { + let len = 0; + while (len < maxLen && data[a + len] === data[b + len]) len++; + return len; +} + +/** Deterministic RFC 1950 zlib compressor using LZ77 and fixed Huffman coding. */ +export function deflateZlib(data: Uint8Array): Uint8Array { + const writer = new BitWriter(); + writer.writeBits(0x78, 8); + writer.writeBits(0x9c, 8); + writer.writeBits(1, 1); + writer.writeBits(1, 2); + + const head = new Int32Array(16384).fill(-1); + let pos = 0; + while (pos < data.length) { + if (pos + 3 <= data.length) { + const h = hash3(data, pos); + const prev = head[h]!; + head[h] = pos; + const dist = pos - prev; + if (prev >= 0 && dist <= 32768) { + const len = matchLength( + data, + prev, + pos, + Math.min(258, data.length - pos), + ); + if (len >= 3) { + writeMatch(writer, len, dist); + for ( + let step = 1; + step < len && pos + step + 3 <= data.length; + step++ + ) + head[hash3(data, pos + step)] = pos + step; + pos += len; + continue; + } + } + } + writeFixedLiteral(writer, data[pos]!); + pos++; + } + writeFixedLiteral(writer, 256); + writer.writeUint32BE(adler32(data)); + return writer.toUint8Array(); +} + +type HuffmanTree = { counts: Uint16Array; symbols: Uint16Array }; + +function buildTree(lengths: Uint8Array): HuffmanTree { + const counts = new Uint16Array(16); + for (const len of lengths) if (len > 0) counts[len]!++; + const offsets = new Uint16Array(16); + let total = 0; + for (let bits = 1; bits <= 15; bits++) { + offsets[bits] = total; + total += counts[bits]!; + } + const symbols = new Uint16Array(total); + for (let sym = 0; sym < lengths.length; sym++) { + const len = lengths[sym]!; + if (len > 0) { + const at = offsets[len]!; + symbols[at] = sym; + offsets[len] = at + 1; + } + } + return { counts, symbols }; +} + +function buildFixedTrees() { + const litLengths = new Uint8Array(288); + litLengths.fill(8, 0, 144); + litLengths.fill(9, 144, 256); + litLengths.fill(7, 256, 280); + litLengths.fill(8, 280, 288); + const distLengths = new Uint8Array(32).fill(5); + return { litTree: buildTree(litLengths), distTree: buildTree(distLengths) }; +} + +const FIXED_TREES = buildFixedTrees(); +const CODE_LENGTH_ORDER = [ + 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15, +]; + +class BitReader { + #pos: number; + #buf = 0; + #bits = 0; + + constructor( + readonly data: Uint8Array, + start: number, + ) { + this.#pos = start; + } + + readBits(count: number): number { + while (this.#bits < count) { + if (this.#pos >= this.data.length) + throw new PdfError("unexpected end of zlib stream"); + this.#buf |= this.data[this.#pos]! << this.#bits; + this.#pos++; + this.#bits += 8; + } + const out = this.#buf & ((1 << count) - 1); + this.#buf >>>= count; + this.#bits -= count; + return out; + } + + alignByte() { + this.#buf = 0; + this.#bits = 0; + } + + readBytes(count: number): Uint8Array { + this.alignByte(); + if (this.#pos + count > this.data.length) + throw new PdfError("unexpected end of stored deflate block"); + const slice = this.data.subarray(this.#pos, this.#pos + count); + this.#pos += count; + return slice; + } + + decodeSymbol(tree: HuffmanTree): number { + let code = 0; + let first = 0; + let index = 0; + for (let len = 1; len <= 15; len++) { + code |= this.readBits(1); + const count = tree.counts[len]!; + if (code - count < first) return tree.symbols[index + (code - first)]!; + index += count; + first = (first + count) << 1; + code <<= 1; + } + throw new PdfError("invalid Huffman code in zlib stream"); + } +} + +function readRepeatCount(reader: BitReader, sym: number): number { + if (sym === 16) return 3 + reader.readBits(2); + if (sym === 17) return 3 + reader.readBits(3); + return 11 + reader.readBits(7); +} + +function readDynamicTrees(reader: BitReader) { + const hlit = reader.readBits(5) + 257; + const hdist = reader.readBits(5) + 1; + const hclen = reader.readBits(4) + 4; + const codeLengths = new Uint8Array(19); + for (let i = 0; i < hclen; i++) + codeLengths[CODE_LENGTH_ORDER[i]!] = reader.readBits(3); + const codeTree = buildTree(codeLengths); + + const lengths = new Uint8Array(hlit + hdist); + for (let i = 0; i < lengths.length;) { + const sym = reader.decodeSymbol(codeTree); + if (sym <= 15) { + lengths[i++] = sym; + continue; + } + const repeat = readRepeatCount(reader, sym); + if ((sym === 16 && i === 0) || i + repeat > lengths.length) + throw new PdfError("invalid deflate code length repeat"); + const fill = sym === 16 ? lengths[i - 1]! : 0; + lengths.fill(fill, i, i + repeat); + i += repeat; + } + return { + litTree: buildTree(lengths.subarray(0, hlit)), + distTree: buildTree(lengths.subarray(hlit)), + }; +} + +function inflateHuffmanBlock( + reader: BitReader, + litTree: HuffmanTree, + distTree: HuffmanTree, + out: number[], +) { + for (;;) { + const sym = reader.decodeSymbol(litTree); + if (sym === 256) return; + if (sym < 256) { + out.push(sym); + continue; + } + const lenSlot = sym - 257; + if (lenSlot >= LENGTH_BASE.length) + throw new PdfError("invalid deflate length symbol"); + const length = + LENGTH_BASE[lenSlot]! + reader.readBits(LENGTH_EXTRA[lenSlot]!); + const distSlot = reader.decodeSymbol(distTree); + if (distSlot >= DIST_BASE.length) + throw new PdfError("invalid deflate distance symbol"); + const distance = + DIST_BASE[distSlot]! + reader.readBits(DIST_EXTRA[distSlot]!); + const start = out.length - distance; + if (start < 0) throw new PdfError("invalid deflate distance"); + for (let i = 0; i < length; i++) out.push(out[start + i]!); + } +} + +/** Decompresses an RFC 1950 zlib stream and checks its Adler-32 checksum. */ +export function inflateZlib(data: Uint8Array): Uint8Array { + if (data.length < 6) throw new PdfError("zlib stream is too short"); + const cmf = data[0]!; + const flg = data[1]!; + if ((cmf & 0x0f) !== 8 || ((cmf << 8) + flg) % 31 !== 0 || (flg & 0x20) !== 0) + throw new PdfError("invalid zlib header"); + + const reader = new BitReader(data.subarray(0, data.length - 4), 2); + const out: number[] = []; + for (let final = 0; final === 0;) { + final = reader.readBits(1); + const btype = reader.readBits(2); + if (btype === 0) { + const header = reader.readBytes(4); + const len = header[0]! | (header[1]! << 8); + const nlen = header[2]! | (header[3]! << 8); + if ((len ^ 0xffff) !== nlen) + throw new PdfError("corrupt stored deflate block length"); + const raw = reader.readBytes(len); + for (let i = 0; i < raw.length; i++) out.push(raw[i]!); + } else if (btype === 1) { + inflateHuffmanBlock( + reader, + FIXED_TREES.litTree, + FIXED_TREES.distTree, + out, + ); + } else if (btype === 2) { + const { litTree, distTree } = readDynamicTrees(reader); + inflateHuffmanBlock(reader, litTree, distTree, out); + } else { + throw new PdfError("invalid deflate block type"); + } + } + const result = new Uint8Array(out); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const expected = view.getUint32(data.byteLength - 4); + if (adler32(result) !== expected) + throw new PdfError("zlib checksum mismatch"); + return result; +} diff --git a/packages/pdf/tsconfig.json b/packages/pdf/tsconfig.json new file mode 100644 index 00000000..f1d5ac2e --- /dev/null +++ b/packages/pdf/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/schemas/src/artifact.ts b/packages/schemas/src/artifact.ts index 337bda9d..42cd1b02 100644 --- a/packages/schemas/src/artifact.ts +++ b/packages/schemas/src/artifact.ts @@ -14,20 +14,21 @@ const DocumentKind = type( "'problem_brief' | 'constraints' | 'approaches' | 'product_requirements' | 'build_plan'", ); const WritableArtifactKind = DocumentKind.or( - "'design' | 'design_option' | 'app'", + "'design' | 'design_option' | 'app' | 'deck'", ); const content = type("string > 0").atMostLength(256 * 1024); export const DocumentContent = content; // A working weather app packed to about 270 KB of source. export const MAX_APP_BYTES = 1024 * 1024; +export const MAX_DECK_BYTES = 4 * 1024 * 1024; -// A filed app runs up to MAX_APP_BYTES, which the hub checks in bytes; it holds every other kind to -// DocumentContent. +// A filed deck runs up to MAX_DECK_BYTES and a filed app up to MAX_APP_BYTES, which the hub checks +// in bytes; it holds every other kind to DocumentContent. export const ArtifactKindWrite = type({ "+": "reject", kind: WritableArtifactKind, title: text, - content: type("string > 0").atMostLength(MAX_APP_BYTES), + content: "string > 0", "openQuestions?": OpenQuestions, "approaches?": ApproachesMeta, }); @@ -41,6 +42,16 @@ export const DocumentWrite = type({ "approaches?": ApproachesMeta, }); +// A filed slide deck's content: the validated deck JSON alongside its rendered PowerPoint and PDF +// binaries in base64, filed when check_deck passes so the deck survives after the sidecar ends. +export const DeckBundle = type({ + "+": "reject", + deck: "Record", + pptxBase64: "string > 0", + pdfBase64: "string > 0", +}); +export type DeckBundle = typeof DeckBundle.infer; + // A design document's content: the designer's .builder files keyed by path, DESIGN.md and // designs/index.html among them. export const DesignBundle = type({ diff --git a/packages/slidedeck/package.json b/packages/slidedeck/package.json index 820c02ef..25be8b68 100644 --- a/packages/slidedeck/package.json +++ b/packages/slidedeck/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts" }, "dependencies": { + "@corbits/pdf": "workspace:*", "@corbits/zip": "workspace:*", "arktype": "catalog:" } diff --git a/packages/slidedeck/src/check.test.ts b/packages/slidedeck/src/check.test.ts new file mode 100644 index 00000000..d563b586 --- /dev/null +++ b/packages/slidedeck/src/check.test.ts @@ -0,0 +1,345 @@ +import { expect, test } from "bun:test"; + +import { assets, deck } from "../test/fixture.ts"; +import { + DeckError, + assetKey, + checkDeck, + contrastRatio, + writeDeckPdf, + writePdf, + type DeckInput, +} from "./index.ts"; + +test("the fixture deck passes checkDeck with accessible theme tokens and renders a 9-page 16:9 PDF deterministically", () => { + const accessibleFixture: DeckInput = { + ...deck, + theme: { + ...deck.theme, + colors: { + ...deck.theme!.colors, + secondary: "#0369A1", + }, + }, + slides: deck.slides.map((slide, idx) => + idx === 8 && slide.layout === "free" + ? { + ...slide, + elements: slide.elements.map((el) => + el.type === "text" && el.id === "headline" + ? { + ...el, + paragraphs: el.paragraphs.map((p, pIdx) => + pIdx === 1 + ? { + ...p, + runs: p.runs.map((r) => + typeof r === "object" && r.color === "#0EA5E9" + ? { ...r, color: "#0369A1" } + : r, + ), + } + : p, + ), + } + : el, + ), + } + : slide, + ), + }; + expect(checkDeck(accessibleFixture, assets)).toEqual([]); + + const first = writePdf(accessibleFixture, assets); + const second = writeDeckPdf(accessibleFixture, assets); + expect(second).toEqual(first); + + const pdfText = new TextDecoder("latin1").decode(first); + expect(pdfText.startsWith("%PDF-1.4\n")).toBe(true); + expect(pdfText).toContain("/Count 9"); + expect(pdfText).toContain("/MediaBox [0 0 960 540]"); + expect(pdfText).toContain("(Builder overview) Tj"); + expect(pdfText).toContain("(Why now) Tj"); + expect(pdfText).toContain("(What Builder does) Tj"); + expect(pdfText).toContain("(Designed) Tj"); +}); + +test("checkDeck reports empty placeholders, empty block regions and empty free slides", () => { + const issues = checkDeck( + { + ...deck, + slides: [ + { layout: "title", title: " " }, + { layout: "content", title: "Empty body", body: [] }, + { + layout: "two-column", + title: "Columns", + left: [{ type: "paragraph", text: " " }], + right: [], + }, + { layout: "free", elements: [] }, + { + layout: "free", + elements: [ + { + type: "text", + x: 40, + y: 40, + w: 300, + h: 100, + paragraphs: [{ runs: [" "] }], + }, + ], + }, + ], + }, + assets, + ); + + expect(issues).toEqual([ + expect.objectContaining({ slide: 0, path: "slides[0].title" }), + expect.objectContaining({ slide: 1, path: "slides[1].body" }), + expect.objectContaining({ slide: 2, path: "slides[2].left[0].text" }), + expect.objectContaining({ slide: 2, path: "slides[2].right" }), + expect.objectContaining({ slide: 3, path: "slides[3].elements" }), + expect.objectContaining({ + slide: 4, + path: "slides[4].elements[0].paragraphs", + }), + ]); +}); + +test("checkDeck reports text overflow in layout placeholders and free text boxes", () => { + const longParagraph = "Overflowing content line with many words. ".repeat(40); + const issues = checkDeck( + { + ...deck, + slides: [ + { + layout: "content", + title: "Line one\nLine two\nLine three", + body: [{ type: "paragraph", text: longParagraph }], + }, + { + layout: "free", + elements: [ + { + type: "text", + x: 40, + y: 40, + w: 160, + h: 40, + paragraphs: [ + { + runs: [ + { + text: "This text is far too long to fit inside a 160x40 pixel box.", + size: 24, + }, + ], + }, + ], + }, + ], + }, + ], + }, + assets, + ); + + expect(issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].title", + message: expect.stringContaining("overflows"), + }), + expect.objectContaining({ + slide: 0, + path: "slides[0].body", + message: expect.stringContaining("overflows"), + }), + expect.objectContaining({ + slide: 1, + path: "slides[1].elements[0]", + message: expect.stringContaining("overflows"), + }), + ]); +}); + +test("checkDeck and writePdf report missing and corrupt image assets with slide index", () => { + const broken = { path: "broken.png" }; + const missing = { path: "missing.png" }; + const badDeck: DeckInput = { + ...deck, + slides: [ + { layout: "image", title: "Broken", image: broken }, + { + layout: "free", + elements: [ + { + type: "image", + x: 10, + y: 10, + w: 100, + h: 100, + image: missing, + }, + ], + }, + ], + }; + const partialAssets = new Map([ + ...assets, + [assetKey(broken), new Uint8Array([1, 2, 3])], + ]); + + const issues = checkDeck(badDeck, partialAssets); + expect(issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].image", + message: expect.stringContaining("not a PNG or JPEG"), + }), + expect.objectContaining({ + slide: 1, + path: "slides[1].elements[0].image", + message: expect.stringContaining("was not provided"), + }), + ]); + expect(() => writePdf(badDeck, partialAssets)).toThrow(DeckError); +}); + +test("checkDeck enforces WCAG contrast thresholds for normal and large text", () => { + expect(contrastRatio("#FFFFFF", "#FFFFFF")).toBeCloseTo(1, 2); + expect(contrastRatio("#000000", "#FFFFFF")).toBeCloseTo(21, 1); + + const issues = checkDeck( + { + ...deck, + slides: [ + { + layout: "content", + title: "Contrast check", + body: [ + { + type: "paragraph", + text: [ + // #EA580C on #FFFFFF has 3.56:1 contrast: passes at 28pt (>= 24pt large text), fails at 16pt (< 4.5:1) + { text: "Large orange passes", size: 28, color: "#EA580C" }, + { text: "Small orange fails", size: 16, color: "#EA580C" }, + ], + }, + ], + }, + { + layout: "free", + background: "#FFFFFF", + elements: [ + { + type: "text", + x: 40, + y: 40, + w: 400, + h: 100, + paragraphs: [ + { + runs: [ + { text: "Light gray on white", size: 36, color: "#D1D5DB" }, + ], + }, + ], + }, + ], + }, + { + layout: "free", + background: "#000000", + elements: [ + { + type: "shape", + geometry: "rect", + x: 20, + y: 20, + w: 500, + h: 200, + fill: "#FFFFFF", + }, + { + type: "text", + x: 40, + y: 40, + w: 400, + h: 100, + paragraphs: [ + { + runs: [ + { + text: "White text on white shape over black slide", + size: 24, + color: "#FFFFFF", + }, + ], + }, + ], + }, + ], + }, + { + layout: "free", + background: { + type: "linear", + angle: 90, + stops: [ + { at: 0, color: "#000000" }, + { at: 100, color: "#FFFFFF" }, + ], + }, + elements: [ + { + type: "text", + x: 40, + y: 40, + w: 400, + h: 100, + paragraphs: [ + { + runs: [ + { + text: "White text over black-to-white gradient", + size: 24, + color: "#FFFFFF", + }, + ], + }, + ], + }, + ], + }, + ], + }, + assets, + ); + + expect(issues).toEqual([ + expect.objectContaining({ + slide: 0, + path: "slides[0].body[0].text[1]", + message: expect.stringContaining("contrast"), + }), + expect.objectContaining({ + slide: 1, + path: "slides[1].elements[0].paragraphs[0].runs[0]", + message: expect.stringContaining("contrast"), + }), + expect.objectContaining({ + slide: 2, + path: "slides[2].elements[1].paragraphs[0].runs[0]", + message: expect.stringContaining("contrast"), + }), + expect.objectContaining({ + slide: 3, + path: "slides[3].elements[0].paragraphs[0].runs[0]", + message: expect.stringContaining("contrast"), + }), + ]); +}); diff --git a/packages/slidedeck/src/check.ts b/packages/slidedeck/src/check.ts new file mode 100644 index 00000000..81af266a --- /dev/null +++ b/packages/slidedeck/src/check.ts @@ -0,0 +1,786 @@ +import { decodePdfImage, measureTextWidth } from "@corbits/pdf"; + +import { + assetKey, + freeElements, + slideRegions, + validateDeck, + type Deck, + type DeckIssue, + type FreeParagraph, + type ListItem, + type Paint, + type RichText, + type Run, + type Slide, + type TextBlock, + type TextElement, + type Theme, + type ThemeColor, +} from "./deck.ts"; +import { isSvg, readImage } from "./image.ts"; +import { + FREE_AREA, + layoutPlaceholders, + type Box, + type Placeholder, + type Role, +} from "./layouts.ts"; +import { + STACK_GAP, + assetUses, + segments, + type AssetUse, + type Assets, +} from "./write.ts"; + +const EMU_PER_POINT = 12700; +const LIST_PT_SIZES = [20, 18, 16, 16, 16]; +const LIST_INDENT_PT = 25; +const PLACEHOLDER_PAD_X = 14; + +const THEME_KEYS = new Set([ + "primary", + "secondary", + "accent", + "background", + "text", + "muted", +]); + +function isThemeColor(value: string): value is ThemeColor { + return THEME_KEYS.has(value as ThemeColor); +} + +export function resolveThemeHex(theme: Theme, color: string): string { + const raw = isThemeColor(color) ? theme.colors[color] : color; + return raw.startsWith("#") ? raw.toUpperCase() : `#${raw.toUpperCase()}`; +} + +export function resolvePaintHex(theme: Theme, paint: Paint): string { + if (typeof paint === "string") return resolveThemeHex(theme, paint); + const first = paint.stops[0]; + if (!first) return resolveThemeHex(theme, "background"); + return resolveThemeHex(theme, first.color); +} + +function srgbChannelLuminance(byte: number): number { + const s = byte / 255; + if (s <= 0.04045) return s / 12.92; + return ((s + 0.055) / 1.055) ** 2.4; +} + +export function relativeLuminance(hex: string): number { + const clean = hex.replace(/^#/, ""); + const r = Number.parseInt(clean.slice(0, 2), 16); + const g = Number.parseInt(clean.slice(2, 4), 16); + const b = Number.parseInt(clean.slice(4, 6), 16); + return ( + 0.2126 * srgbChannelLuminance(r) + + 0.7152 * srgbChannelLuminance(g) + + 0.0722 * srgbChannelLuminance(b) + ); +} + +export function contrastRatio(hexA: string, hexB: string): number { + const l1 = relativeLuminance(hexA); + const l2 = relativeLuminance(hexB); + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + return (lighter + 0.05) / (darker + 0.05); +} + +function formatPathSegment(key: string | number, index: number): string { + if (typeof key === "number") return `[${key}]`; + if (index === 0) return key; + return `.${key}`; +} + +function formatPath(path: readonly (string | number)[]): string { + return path.map(formatPathSegment).join(""); +} + +function slideIssue(slide: number, path: string, detail: string): DeckIssue { + return { slide, path, message: `${path} ${detail}` }; +} + +function assetIssue(use: AssetUse, message: string): DeckIssue { + if (use.slide === undefined) return { path: use.path, message }; + return { slide: use.slide, path: use.path, message }; +} + +function checkMedia(deck: Deck, assets: Assets): DeckIssue[] { + const issues: DeckIssue[] = []; + for (const use of assetUses(deck)) { + const data = assets.get(assetKey(use.ref)); + if (!data) { + issues.push( + assetIssue( + use, + `${use.path} names asset "${assetKey(use.ref)}", which was not provided`, + ), + ); + continue; + } + if (use.svg) { + if (!isSvg(data)) + issues.push(assetIssue(use, `${use.path} is not an SVG image`)); + continue; + } + if (!readImage(data)) { + issues.push( + assetIssue(use, `${use.path} is not a PNG or JPEG image with a size`), + ); + continue; + } + try { + decodePdfImage(data); + } catch (error) { + issues.push( + assetIssue( + use, + `${use.path} has corrupt or unsupported image data (${error instanceof Error ? error.message : String(error)})`, + ), + ); + } + } + return issues; +} + +function richRuns(text: RichText): Run[] { + const list = typeof text === "string" ? [text] : text; + return list.map((run) => (typeof run === "string" ? { text: run } : run)); +} + +function richTextPlain(text: RichText): string { + return richRuns(text) + .map((r) => r.text) + .join(""); +} + +function checkListEmpty( + slide: number, + items: readonly ListItem[], + basePath: string, + issues: DeckIssue[], +) { + for (const [idx, item] of items.entries()) { + const itemPath = `${basePath}[${idx}]`; + if (typeof item === "string" || Array.isArray(item)) { + if (richTextPlain(item).trim() === "") + issues.push(slideIssue(slide, itemPath, "has empty list item text")); + continue; + } + if (richTextPlain(item.text).trim() === "") + issues.push( + slideIssue(slide, `${itemPath}.text`, "has empty list item text"), + ); + checkListEmpty(slide, item.items, `${itemPath}.items`, issues); + } +} + +function checkEmptySlide(slide: Slide, index: number): DeckIssue[] { + const issues: DeckIssue[] = []; + const base = `slides[${index}]`; + if ( + "title" in slide && + slide.title !== undefined && + slide.title.trim() === "" + ) + issues.push(slideIssue(index, `${base}.title`, "has an empty title")); + if ( + "subtitle" in slide && + slide.subtitle !== undefined && + slide.subtitle.trim() === "" + ) + issues.push(slideIssue(index, `${base}.subtitle`, "has an empty subtitle")); + if ( + slide.layout === "image" && + slide.caption !== undefined && + slide.caption.trim() === "" + ) + issues.push(slideIssue(index, `${base}.caption`, "has an empty caption")); + if (slide.layout === "comparison") { + if (slide.left.heading.trim() === "") + issues.push( + slideIssue(index, `${base}.left.heading`, "has an empty heading"), + ); + if (slide.right.heading.trim() === "") + issues.push( + slideIssue(index, `${base}.right.heading`, "has an empty heading"), + ); + } + + for (const region of slideRegions(slide)) { + const regionPath = `${base}.${region.path}`; + if (region.blocks.length === 0) { + issues.push( + slideIssue(index, regionPath, "is empty; add at least one block"), + ); + continue; + } + for (const [bIdx, block] of region.blocks.entries()) { + const bPath = `${regionPath}[${bIdx}]`; + if (block.type === "paragraph") { + if (richTextPlain(block.text).trim() === "") + issues.push( + slideIssue(index, `${bPath}.text`, "has an empty paragraph"), + ); + } else if (block.type === "list") { + checkListEmpty(index, block.items, `${bPath}.items`, issues); + } + } + } + + if (slide.layout === "free") { + if (slide.elements.length === 0) { + issues.push( + slideIssue( + index, + `${base}.elements`, + "is empty; add at least one element", + ), + ); + return issues; + } + for (const [el, path] of freeElements(slide.elements, [ + "slides", + index, + "elements", + ])) { + if (el.type !== "text") continue; + const elPath = formatPath(path); + const combined = el.paragraphs + .flatMap((p) => p.runs.map((r) => (typeof r === "string" ? r : r.text))) + .join(""); + if (combined.trim() === "") + issues.push( + slideIssue(index, `${elPath}.paragraphs`, "has empty text"), + ); + } + } + return issues; +} + +function estimateRichParagraphHeightPt( + text: RichText, + defaultSizePt: number, + availWidthPt: number, + boldDefault = false, + spaceBeforePt = 6, +): number { + const runs = richRuns(text); + const maxSize = + runs.length > 0 + ? Math.max(...runs.map((r) => r.size ?? defaultSizePt)) + : defaultSizePt; + const lineWidths = [0]; + for (const r of runs) { + const size = r.size ?? defaultSizePt; + const bold = boldDefault || Boolean(r.bold); + const parts = r.text.split(/\r?\n/); + for (const [idx, part] of parts.entries()) { + if (idx > 0) lineWidths.push(0); + lineWidths[lineWidths.length - 1]! += measureTextWidth( + part, + size, + bold, + 0, + ); + } + } + const safeAvail = Math.max(1, availWidthPt); + let lines = 0; + for (const w of lineWidths) lines += Math.max(1, Math.ceil(w / safeAvail)); + return spaceBeforePt + (lines - 1) * maxSize * 1.2 + maxSize * 0.95; +} + +function* flattenListItems( + items: readonly ListItem[], + level: number, +): Generator<{ text: RichText; level: number }> { + for (const item of items) { + if (typeof item === "string" || Array.isArray(item)) { + yield { text: item, level }; + continue; + } + yield { text: item.text, level }; + yield* flattenListItems(item.items, level + 1); + } +} + +function estimateTextBandHeightPt( + blocks: readonly TextBlock[], + boxWidthPt: number, +): number { + const contentWidth = Math.max(20, boxWidthPt - PLACEHOLDER_PAD_X); + let height = 0; + for (const [idx, block] of blocks.entries()) { + const spaceBefore = idx === 0 ? 0 : 6; + if (block.type === "paragraph") { + height += estimateRichParagraphHeightPt( + block.text, + 20, + contentWidth, + false, + spaceBefore, + ); + continue; + } + for (const [itemIdx, item] of [ + ...flattenListItems(block.items, 1), + ].entries()) { + const levelSize = LIST_PT_SIZES[item.level - 1] ?? 16; + const itemWidth = Math.max( + 20, + contentWidth - item.level * LIST_INDENT_PT, + ); + const itemSpace = idx === 0 && itemIdx === 0 ? 0 : 4; + height += estimateRichParagraphHeightPt( + item.text, + levelSize, + itemWidth, + false, + itemSpace, + ); + } + } + return height; +} + +function checkLayoutOverflow( + slide: Exclude, + index: number, +): DeckIssue[] { + const issues: DeckIssue[] = []; + const base = `slides[${index}]`; + const byRole = new Map( + layoutPlaceholders(slide.layout).map((ph) => [ph.role, ph]), + ); + + const checkSingleText = ( + role: Role, + fieldPath: string, + value: string | undefined, + defaultSizePt: number, + bold = false, + ) => { + if (!value) return; + const ph = byRole.get(role); + if (!ph) return; + const widthPt = Math.max(20, ph.box.w / EMU_PER_POINT - PLACEHOLDER_PAD_X); + const heightPt = ph.box.h / EMU_PER_POINT; + const sizePt = ph.size ? ph.size / 100 : defaultSizePt; + const needed = estimateRichParagraphHeightPt( + value, + sizePt, + widthPt, + ph.bold ?? bold, + 0, + ); + if (needed > heightPt) + issues.push( + slideIssue( + index, + `${base}.${fieldPath}`, + `overflows its placeholder box (${Math.round(needed)}pt > ${Math.round(heightPt)}pt)`, + ), + ); + }; + + if ("title" in slide) + checkSingleText("title", "title", slide.title, 32, true); + if ("subtitle" in slide) + checkSingleText("subtitle", "subtitle", slide.subtitle, 20); + if (slide.layout === "image") + checkSingleText("caption", "caption", slide.caption, 14); + if (slide.layout === "comparison") { + checkSingleText( + "leftHeading", + "left.heading", + slide.left.heading, + 20, + true, + ); + checkSingleText( + "rightHeading", + "right.heading", + slide.right.heading, + 20, + true, + ); + } + + for (const region of slideRegions(slide)) { + if (region.blocks.length === 0) continue; + const area: Box = + slide.layout === "blank" + ? FREE_AREA + : (byRole.get(region.role)?.box ?? FREE_AREA); + const bands = segments(region.blocks); + const bandHeightEmu = + (area.h - STACK_GAP * (bands.length - 1)) / bands.length; + const bandHeightPt = bandHeightEmu / EMU_PER_POINT; + const bandWidthPt = area.w / EMU_PER_POINT; + for (const band of bands) { + if (!("text" in band)) continue; + const needed = estimateTextBandHeightPt(band.text, bandWidthPt); + if (needed > bandHeightPt) { + issues.push( + slideIssue( + index, + `${base}.${region.path}`, + `overflows its content box (${Math.round(needed)}pt > ${Math.round(bandHeightPt)}pt)`, + ), + ); + break; + } + } + } + return issues; +} + +function estimateFreeParagraphHeightPx( + para: FreeParagraph, + innerW: number, + wrap: boolean, +): { height: number; widerThanBox: boolean } { + const runs = para.runs.map((r) => (typeof r === "string" ? { text: r } : r)); + const maxSize = + runs.length > 0 ? Math.max(...runs.map((r) => r.size ?? 18)) : 18; + const lineWidths = [0]; + for (const r of runs) { + const size = r.size ?? 18; + const bold = r.weight !== undefined && r.weight >= 600; + const spacing = r.letterSpacing === undefined ? 0 : r.letterSpacing; + const parts = r.text.split(/\r?\n/); + for (const [idx, part] of parts.entries()) { + if (idx > 0) lineWidths.push(0); + lineWidths[lineWidths.length - 1]! += measureTextWidth( + part, + size, + bold, + spacing, + ); + } + } + let widerThanBox = false; + let lineCount = 0; + for (const lineW of lineWidths) { + if (!wrap) { + lineCount += 1; + if (lineW > innerW) widerThanBox = true; + } else { + lineCount += Math.max(1, Math.ceil(lineW / Math.max(1, innerW))); + } + } + const lineHeight = para.lineHeight ?? maxSize * 1.2; + const textHeight = + lineCount > 0 ? (lineCount - 1) * lineHeight + maxSize * 0.85 : 0; + return { + height: para.spaceBefore + textHeight + para.spaceAfter, + widerThanBox, + }; +} + +function checkFreeTextOverflow( + el: TextElement, + index: number, + elPath: string, +): DeckIssue | undefined { + const insets = el.insets ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const innerW = el.w - insets.left - insets.right; + const innerH = el.h - insets.top - insets.bottom; + if (innerW <= 0 || innerH <= 0) + return slideIssue(index, elPath, "overflows its empty bounding box"); + + let totalHeight = 0; + for (const para of el.paragraphs) { + const est = estimateFreeParagraphHeightPx(para, innerW, el.wrap); + if (est.widerThanBox) + return slideIssue(index, elPath, "overflows its horizontal bounding box"); + totalHeight += est.height; + } + if (totalHeight > innerH) + return slideIssue( + index, + elPath, + `overflows its bounding box (${Math.round(totalHeight)}px > ${Math.round(innerH)}px)`, + ); + return undefined; +} + +function checkOverflowSlide(slide: Slide, index: number): DeckIssue[] { + if (slide.layout !== "free") return checkLayoutOverflow(slide, index); + const issues: DeckIssue[] = []; + for (const [el, path] of freeElements(slide.elements, [ + "slides", + index, + "elements", + ])) { + if (el.type !== "text") continue; + const elPath = formatPath(path); + const issue = checkFreeTextOverflow(el, index, elPath); + if (issue) issues.push(issue); + } + return issues; +} + +function minContrastFor(sizePt: number, bold: boolean): number { + if (sizePt >= 24 || (sizePt >= 18.66 && bold)) return 3.0; + return 4.5; +} + +function checkColorContrast( + slide: number, + path: string, + fgHex: string, + bgCandidates: readonly string[], + sizePt: number, + bold: boolean, +): DeckIssue | undefined { + const required = minContrastFor(sizePt, bold); + const worst = Math.min(...bgCandidates.map((bg) => contrastRatio(fgHex, bg))); + if (worst < required) { + return slideIssue( + slide, + path, + `has low contrast (${worst.toFixed(2)}:1 < ${required}:1)`, + ); + } + return undefined; +} + +function checkRichTextContrast( + theme: Theme, + slide: number, + text: RichText, + basePath: string, + defaultColor: ThemeColor, + defaultSizePt: number, +): DeckIssue[] { + const issues: DeckIssue[] = []; + const bgHex = resolveThemeHex(theme, "background"); + for (const [idx, run] of richRuns(text).entries()) { + if (run.text.trim() === "") continue; + const fgHex = resolveThemeHex(theme, run.color ?? defaultColor); + const sizePt = run.size ?? defaultSizePt; + const runPath = typeof text === "string" ? basePath : `${basePath}[${idx}]`; + const issue = checkColorContrast( + slide, + runPath, + fgHex, + [bgHex], + sizePt, + run.bold ?? false, + ); + if (issue) issues.push(issue); + } + return issues; +} + +function checkListContrast( + theme: Theme, + slide: number, + items: readonly ListItem[], + basePath: string, + level: number, + issues: DeckIssue[], +) { + const levelSize = LIST_PT_SIZES[level - 1] ?? 16; + for (const [idx, item] of items.entries()) { + const itemPath = `${basePath}[${idx}]`; + if (typeof item === "string" || Array.isArray(item)) { + issues.push( + ...checkRichTextContrast( + theme, + slide, + item, + itemPath, + "text", + levelSize, + ), + ); + continue; + } + issues.push( + ...checkRichTextContrast( + theme, + slide, + item.text, + `${itemPath}.text`, + "text", + levelSize, + ), + ); + checkListContrast( + theme, + slide, + item.items, + `${itemPath}.items`, + level + 1, + issues, + ); + } +} + +function paintCandidateHexes(theme: Theme, paint: Paint | undefined): string[] { + if (paint === undefined) return [resolveThemeHex(theme, "text")]; + if (typeof paint === "string") return [resolveThemeHex(theme, paint)]; + return paint.stops.map((s) => resolveThemeHex(theme, s.color)); +} + +function coveringBackgroundsForFreeText( + theme: Theme, + slide: Slide & { layout: "free" }, + target: TextElement, +): string[] { + if (target.fill !== undefined) return paintCandidateHexes(theme, target.fill); + let coveringPaint: Paint | undefined = slide.background; + const cx = target.x + target.w / 2; + const cy = target.y + target.h / 2; + for (const [el] of freeElements(slide.elements, [])) { + if (el === target) break; + if (el.type === "shape" && "fill" in el && el.fill !== undefined) { + if (cx >= el.x && cx <= el.x + el.w && cy >= el.y && cy <= el.y + el.h) + coveringPaint = el.fill; + } + } + if (coveringPaint === undefined) + return [resolveThemeHex(theme, "background")]; + return paintCandidateHexes(theme, coveringPaint); +} + +function checkFreeSlideContrast( + theme: Theme, + slide: Slide & { layout: "free" }, + index: number, +): DeckIssue[] { + const issues: DeckIssue[] = []; + for (const [el, path] of freeElements(slide.elements, [ + "slides", + index, + "elements", + ])) { + if (el.type !== "text") continue; + const elPath = formatPath(path); + const bgs = coveringBackgroundsForFreeText(theme, slide, el); + for (const [pIdx, para] of el.paragraphs.entries()) { + for (const [rIdx, rawRun] of para.runs.entries()) { + const run = typeof rawRun === "string" ? { text: rawRun } : rawRun; + if (run.text.trim() === "") continue; + const sizePx = run.size ?? 18; + const sizePt = sizePx * 0.75; + const bold = (run.weight ?? 400) >= 600; + const fgCandidates = paintCandidateHexes(theme, run.color); + const worstFgBg = Math.min( + ...fgCandidates.flatMap((fg) => + bgs.map((bg) => contrastRatio(fg, bg)), + ), + ); + const required = minContrastFor(sizePt, bold); + if (worstFgBg < required) { + issues.push( + slideIssue( + index, + `${elPath}.paragraphs[${pIdx}].runs[${rIdx}]`, + `has low contrast (${worstFgBg.toFixed(2)}:1 < ${required}:1)`, + ), + ); + } + } + } + } + return issues; +} + +function checkContrastSlide( + theme: Theme, + slide: Slide, + index: number, +): DeckIssue[] { + if (slide.layout === "free") + return checkFreeSlideContrast(theme, slide, index); + const issues: DeckIssue[] = []; + const base = `slides[${index}]`; + const bgHex = resolveThemeHex(theme, "background"); + + const addContrast = ( + path: string, + token: ThemeColor, + sizePt: number, + bold: boolean, + ) => { + const issue = checkColorContrast( + index, + path, + resolveThemeHex(theme, token), + [bgHex], + sizePt, + bold, + ); + if (issue) issues.push(issue); + }; + + if ("title" in slide && slide.title && slide.title.trim() !== "") + addContrast(`${base}.title`, "text", 32, true); + if ("subtitle" in slide && slide.subtitle && slide.subtitle.trim() !== "") + addContrast(`${base}.subtitle`, "muted", 20, false); + if (slide.layout === "image" && slide.caption && slide.caption.trim() !== "") + addContrast(`${base}.caption`, "muted", 14, false); + if (slide.layout === "comparison") { + addContrast(`${base}.left.heading`, "primary", 20, true); + addContrast(`${base}.right.heading`, "primary", 20, true); + } + + for (const region of slideRegions(slide)) { + const regionPath = `${base}.${region.path}`; + for (const [bIdx, block] of region.blocks.entries()) { + const bPath = `${regionPath}[${bIdx}]`; + if (block.type === "paragraph") { + issues.push( + ...checkRichTextContrast( + theme, + index, + block.text, + `${bPath}.text`, + "text", + 20, + ), + ); + } else if (block.type === "list") { + checkListContrast( + theme, + index, + block.items, + `${bPath}.items`, + 1, + issues, + ); + } + } + } + return issues; +} + +/** + * Deterministically checks a deck for schema validity, missing or corrupt + * images, empty placeholders/blocks, text overflow and low contrast. + */ +export function checkDeck( + input: unknown, + assets: Assets = new Map(), +): DeckIssue[] { + const validated = validateDeck(input); + if (!validated.ok) return validated.issues; + const { deck } = validated; + const issues: DeckIssue[] = [...checkMedia(deck, assets)]; + for (const [index, slide] of deck.slides.entries()) { + issues.push( + ...checkEmptySlide(slide, index), + ...checkOverflowSlide(slide, index), + ...checkContrastSlide(deck.theme, slide, index), + ); + } + return issues; +} diff --git a/packages/slidedeck/src/index.ts b/packages/slidedeck/src/index.ts index 75d942ca..beb9ab27 100644 --- a/packages/slidedeck/src/index.ts +++ b/packages/slidedeck/src/index.ts @@ -1,3 +1,4 @@ +export { checkDeck, contrastRatio, relativeLuminance } from "./check.ts"; export { AssetRef, Block, @@ -15,10 +16,19 @@ export { Theme, ThemeColor, assetKey, + freeElements, + slideRegions, validateDeck, type DeckInput, type DeckIssue, type DeckValidation, type Layout, } from "./deck.ts"; -export { DeckError, writePptx, type Assets } from "./write.ts"; +export { writeDeckPdf, writePdf } from "./pdf.ts"; +export { + DeckError, + assetUses, + writePptx, + type AssetUse, + type Assets, +} from "./write.ts"; diff --git a/packages/slidedeck/src/pdf.ts b/packages/slidedeck/src/pdf.ts new file mode 100644 index 00000000..604c958a --- /dev/null +++ b/packages/slidedeck/src/pdf.ts @@ -0,0 +1,610 @@ +import { + writePdf as renderPdfPages, + type PdfBorder, + type PdfElement, + type PdfPage, + type PdfPaint, + type PdfParagraph, + type PdfRadius, + type PdfTextRun, +} from "@corbits/pdf"; + +import { resolveThemeHex } from "./check.ts"; +import { + validateDeck, + type Block, + type Border, + type Deck, + type DeckInput, + type FreeElement, + type ListItem, + type Paint, + type PictureElement, + type RichText, + type ShapeElement, + type Slide, + type TextBlock, + type TextElement, + type Theme, +} from "./deck.ts"; +import { + FREE_AREA, + LOGO_BOX, + SLIDE_SIZE, + layoutPlaceholders, + type Box, + type Placeholder, + type Role, +} from "./layouts.ts"; +import { + DeckError, + STACK_GAP, + resolveMedia, + segments, + type Assets, +} from "./write.ts"; + +const EMU_PER_POINT = 12700; +const PX_TO_PT = 0.75; +const LIST_PT_SIZES = [20, 18, 16, 16, 16]; +const LIST_BULLETS = ["•", "–", "•", "–", "•"]; +const LIST_INDENT_PT = 24; + +type MediaStore = ReturnType; + +function emuBoxToPt(box: Box) { + return { + x: box.x / EMU_PER_POINT, + y: box.y / EMU_PER_POINT, + w: box.w / EMU_PER_POINT, + h: box.h / EMU_PER_POINT, + }; +} + +function pxBoxToPt(box: Box) { + return { + x: box.x * PX_TO_PT, + y: box.y * PX_TO_PT, + w: box.w * PX_TO_PT, + h: box.h * PX_TO_PT, + }; +} + +function resolvePdfPaint(theme: Theme, paint: Paint): PdfPaint { + if (typeof paint === "string") return resolveThemeHex(theme, paint); + const stops = paint.stops.map((s) => ({ + color: resolveThemeHex(theme, s.color), + at: s.at, + ...(s.opacity !== undefined ? { opacity: s.opacity } : {}), + })); + if (paint.type === "radial") return { type: "radial", stops }; + return { type: "linear", angle: paint.angle, stops }; +} + +function resolvePdfBorder( + theme: Theme, + border: Border | undefined, +): PdfBorder | undefined { + if (!border) return undefined; + return { + width: border.width * PX_TO_PT, + color: resolveThemeHex(theme, border.color), + dash: border.dash, + ...(border.opacity !== undefined ? { opacity: border.opacity } : {}), + }; +} + +function scaleRadius(radius: PdfRadius): PdfRadius { + if (typeof radius === "number") return radius * PX_TO_PT; + return [ + radius[0] * PX_TO_PT, + radius[1] * PX_TO_PT, + radius[2] * PX_TO_PT, + radius[3] * PX_TO_PT, + ]; +} + +function scaleRadiusToPt( + radius: PictureElement["radius"], +): PdfRadius | undefined { + if (radius === undefined) return undefined; + return scaleRadius(radius); +} + +function richRunsToPdf( + theme: Theme, + text: RichText, + defaultSizePt: number, + defaultColor: string, + defaultBold = false, +): PdfTextRun[] { + const list = typeof text === "string" ? [text] : text; + return list.map((raw) => { + if (typeof raw === "string") { + return { + text: raw, + size: defaultSizePt, + color: resolveThemeHex(theme, defaultColor), + bold: defaultBold, + }; + } + return { + text: raw.text, + size: raw.size ?? defaultSizePt, + color: resolveThemeHex(theme, raw.color ?? defaultColor), + bold: raw.bold ?? defaultBold, + ...(raw.italic !== undefined ? { italic: raw.italic } : {}), + ...(raw.underline !== undefined ? { underline: raw.underline } : {}), + ...(raw.strike !== undefined ? { strike: raw.strike } : {}), + ...(raw.link !== undefined ? { link: raw.link } : {}), + }; + }); +} + +function formatMarker(ordered: boolean, level: number, index: number): string { + if (!ordered) return LIST_BULLETS[level - 1] ?? "•"; + if (level % 2 === 0) { + const letter = String.fromCharCode(97 + (index % 26)); + return `${letter}.`; + } + return `${index + 1}.`; +} + +function* listToPdfParagraphs( + theme: Theme, + items: readonly ListItem[], + ordered: boolean, + level: number, +): Generator { + const sizePt = LIST_PT_SIZES[level - 1] ?? 16; + const indent = level * LIST_INDENT_PT; + const bulletColor = resolveThemeHex(theme, "primary"); + for (const [idx, item] of items.entries()) { + const bullet = formatMarker(ordered, level, idx); + if (typeof item === "string" || Array.isArray(item)) { + yield { + runs: richRunsToPdf(theme, item, sizePt, "text"), + indent, + bullet, + bulletColor, + spaceBefore: 4, + }; + continue; + } + yield { + runs: richRunsToPdf(theme, item.text, sizePt, "text"), + indent, + bullet, + bulletColor, + spaceBefore: 4, + }; + yield* listToPdfParagraphs( + theme, + item.items, + item.ordered ?? ordered, + level + 1, + ); + } +} + +function textBlocksToPdfParagraphs( + theme: Theme, + blocks: readonly TextBlock[], +): PdfParagraph[] { + const out: PdfParagraph[] = []; + for (const [idx, block] of blocks.entries()) { + if (block.type === "paragraph") { + out.push({ + runs: richRunsToPdf(theme, block.text, 20, "text"), + spaceBefore: idx === 0 ? 0 : 6, + }); + } else { + out.push(...listToPdfParagraphs(theme, block.items, block.ordered, 1)); + } + } + return out; +} + +function mapAnchor(anchor: Placeholder["anchor"]): "top" | "middle" | "bottom" { + if (anchor === "ctr") return "middle"; + if (anchor === "b") return "bottom"; + return "top"; +} + +function mapAlign(align: Placeholder["align"]): "left" | "center" | "right" { + if (align === "ctr") return "center"; + if (align === "r") return "right"; + return "left"; +} + +function schemeColorToToken(scheme: string | undefined): string { + if (scheme === "tx2") return "muted"; + if (scheme === "accent1") return "primary"; + return "text"; +} + +function placeholderTextElement( + theme: Theme, + ph: Placeholder, + text: string, +): PdfElement { + const box = emuBoxToPt(ph.box); + const sizePt = ph.size ? ph.size / 100 : 32; + const bold = ph.bold ?? (ph.kind === "title" || ph.kind === "ctrTitle"); + const token = schemeColorToToken(ph.color); + return { + type: "text", + ...box, + anchor: mapAnchor(ph.anchor), + align: mapAlign(ph.align), + paragraphs: [ + { + align: mapAlign(ph.align), + runs: richRunsToPdf(theme, text, sizePt, token, bold), + }, + ], + }; +} + +function stackRegionElements( + theme: Theme, + media: MediaStore, + blocks: readonly Block[], + area: Box, +): PdfElement[] { + if (blocks.length === 0) return []; + const bands = segments(blocks); + const bandHeight = Math.round( + (area.h - STACK_GAP * (bands.length - 1)) / bands.length, + ); + const elements: PdfElement[] = []; + for (const [idx, band] of bands.entries()) { + const bandBox = emuBoxToPt({ + ...area, + y: area.y + idx * (bandHeight + STACK_GAP), + h: bandHeight, + }); + if ("element" in band) { + const data = media.get(band.element.image).data; + elements.push({ + type: "image", + ...bandBox, + data, + fit: band.element.fit, + }); + } else { + elements.push({ + type: "text", + ...bandBox, + paragraphs: textBlocksToPdfParagraphs(theme, band.text), + }); + } + } + return elements; +} + +function renderShapeElement( + theme: Theme, + el: ShapeElement, + opacity: number, +): PdfElement { + const box = pxBoxToPt(el); + const border = resolvePdfBorder(theme, el.border); + if (el.geometry === "line") { + const x1 = el.flip === "horizontal" ? box.x + box.w : box.x; + const x2 = el.flip === "horizontal" ? box.x : box.x + box.w; + const y1 = el.flip === "vertical" ? box.y + box.h : box.y; + const y2 = el.flip === "vertical" ? box.y : box.y + box.h; + return { + type: "line", + x1, + y1, + x2, + y2, + width: el.border.width * PX_TO_PT, + color: resolveThemeHex(theme, el.border.color), + dash: el.border.dash, + opacity: opacity * (el.border.opacity ?? 1), + }; + } + const fill = el.fill ? resolvePdfPaint(theme, el.fill) : undefined; + if (el.geometry === "ellipse") { + return { + type: "ellipse", + ...box, + opacity, + ...(fill !== undefined ? { fill } : {}), + ...(border !== undefined ? { border } : {}), + }; + } + if (el.geometry === "roundRect") { + return { + type: "roundRect", + ...box, + radius: scaleRadius(el.radius), + opacity, + ...(fill !== undefined ? { fill } : {}), + ...(border !== undefined ? { border } : {}), + }; + } + return { + type: "rect", + ...box, + opacity, + ...(fill !== undefined ? { fill } : {}), + ...(border !== undefined ? { border } : {}), + }; +} + +function renderFreeTextElement( + theme: Theme, + el: TextElement, + opacity: number, +): PdfElement { + const box = pxBoxToPt(el); + const fill = el.fill ? resolvePdfPaint(theme, el.fill) : undefined; + const border = resolvePdfBorder(theme, el.border); + const insets = el.insets + ? { + top: el.insets.top * PX_TO_PT, + right: el.insets.right * PX_TO_PT, + bottom: el.insets.bottom * PX_TO_PT, + left: el.insets.left * PX_TO_PT, + } + : undefined; + const paragraphs: PdfParagraph[] = el.paragraphs.map((p) => ({ + align: p.align, + ...(p.lineHeight !== undefined + ? { lineHeight: p.lineHeight * PX_TO_PT } + : {}), + spaceBefore: p.spaceBefore * PX_TO_PT, + spaceAfter: p.spaceAfter * PX_TO_PT, + runs: p.runs.map((r): PdfTextRun => { + if (typeof r === "string") { + return { + text: r, + size: 18 * PX_TO_PT, + color: resolveThemeHex(theme, "text"), + }; + } + return { + text: r.text, + size: (r.size ?? 18) * PX_TO_PT, + color: r.color + ? resolvePdfPaint(theme, r.color) + : resolveThemeHex(theme, "text"), + bold: (r.weight ?? 400) >= 600, + ...(r.italic !== undefined ? { italic: r.italic } : {}), + ...(r.underline !== undefined ? { underline: r.underline } : {}), + ...(r.strike !== undefined ? { strike: r.strike } : {}), + ...(r.letterSpacing !== undefined + ? { letterSpacing: r.letterSpacing * PX_TO_PT } + : {}), + ...(r.link !== undefined ? { link: r.link } : {}), + }; + }), + })); + + return { + type: "text", + ...box, + anchor: el.anchor, + wrap: el.wrap, + opacity, + paragraphs, + ...(insets !== undefined ? { insets } : {}), + ...(fill !== undefined ? { fill } : {}), + ...(border !== undefined ? { border } : {}), + }; +} + +function renderPictureElement( + theme: Theme, + media: MediaStore, + el: PictureElement, + opacity: number, +): PdfElement { + const box = pxBoxToPt(el); + const data = media.get(el.image).data; + const radius = scaleRadiusToPt(el.radius); + const border = resolvePdfBorder(theme, el.border); + return { + type: "image", + ...box, + data, + fit: el.fit, + opacity, + ...(el.crop !== undefined ? { crop: el.crop } : {}), + ...(radius !== undefined ? { radius } : {}), + ...(border !== undefined ? { border } : {}), + }; +} + +function collectFreeElements( + theme: Theme, + media: MediaStore, + elements: readonly FreeElement[], + inheritedOpacity = 1, +): PdfElement[] { + const out: PdfElement[] = []; + for (const el of elements) { + const opacity = inheritedOpacity * (el.opacity ?? 1); + if (el.type === "group") { + out.push(...collectFreeElements(theme, media, el.children, opacity)); + } else if (el.type === "shape") { + out.push(renderShapeElement(theme, el, opacity)); + } else if (el.type === "text") { + out.push(renderFreeTextElement(theme, el, opacity)); + } else { + out.push(renderPictureElement(theme, media, el, opacity)); + } + } + return out; +} + +function singleTextsForSlide( + deck: Deck, + slide: Exclude, + index: number, +): Partial> { + const footer: Partial> = { + ...(deck.footer?.text ? { footer: deck.footer.text } : {}), + ...(deck.footer?.slideNumbers ? { slideNumber: String(index + 1) } : {}), + }; + switch (slide.layout) { + case "title": + case "section": + return { + ...footer, + title: slide.title, + ...(slide.subtitle ? { subtitle: slide.subtitle } : {}), + }; + case "comparison": + return { + ...footer, + title: slide.title, + leftHeading: slide.left.heading, + rightHeading: slide.right.heading, + }; + case "image": + return { + ...footer, + ...(slide.title ? { title: slide.title } : {}), + ...(slide.caption ? { caption: slide.caption } : {}), + }; + case "blank": + return footer; + default: + return { ...footer, title: slide.title }; + } +} + +function renderPlacedSlide( + deck: Deck, + media: MediaStore, + slide: Exclude, + index: number, +): PdfPage { + const elements: PdfElement[] = []; + if (deck.theme.logo) { + elements.push({ + type: "image", + ...emuBoxToPt(LOGO_BOX), + data: media.get(deck.theme.logo).data, + fit: "contain", + }); + } + + const singleTexts = singleTextsForSlide(deck, slide, index); + const byRole = new Map( + layoutPlaceholders(slide.layout).map((ph) => [ph.role, ph]), + ); + + if (slide.layout === "image") { + const ph = byRole.get("image"); + const box = + slide.fit === "cover" && slide.title === undefined + ? { x: 0, y: 0, ...SLIDE_SIZE } + : (ph?.box ?? FREE_AREA); + elements.push({ + type: "image", + ...emuBoxToPt(box), + data: media.get(slide.image).data, + fit: slide.fit, + }); + } + + if (slide.layout === "blank") { + elements.push( + ...stackRegionElements(deck.theme, media, slide.body, FREE_AREA), + ); + } else if (slide.layout === "content") { + const ph = byRole.get("body"); + if (ph) + elements.push( + ...stackRegionElements(deck.theme, media, slide.body, ph.box), + ); + } else if (slide.layout === "two-column") { + const leftPh = byRole.get("left"); + const rightPh = byRole.get("right"); + if (leftPh) + elements.push( + ...stackRegionElements(deck.theme, media, slide.left, leftPh.box), + ); + if (rightPh) + elements.push( + ...stackRegionElements(deck.theme, media, slide.right, rightPh.box), + ); + } else if (slide.layout === "comparison") { + const leftPh = byRole.get("left"); + const rightPh = byRole.get("right"); + if (leftPh) + elements.push( + ...stackRegionElements(deck.theme, media, slide.left.body, leftPh.box), + ); + if (rightPh) + elements.push( + ...stackRegionElements( + deck.theme, + media, + slide.right.body, + rightPh.box, + ), + ); + } + + for (const ph of layoutPlaceholders(slide.layout)) { + const value = singleTexts[ph.role]; + if (value !== undefined) + elements.push(placeholderTextElement(deck.theme, ph, value)); + } + + return { + width: SLIDE_SIZE.w / EMU_PER_POINT, + height: SLIDE_SIZE.h / EMU_PER_POINT, + background: resolveThemeHex(deck.theme, "background"), + elements, + }; +} + +/** + * Renders a validated deck to a 16:9 PDF 1.4 document. Throws `DeckError` if + * the deck or its referenced image assets are invalid. + */ +export function writePdf( + input: DeckInput, + assets: Assets = new Map(), +): Uint8Array { + const checked = validateDeck(input); + if (!checked.ok) throw new DeckError(checked.issues); + const { deck } = checked; + const media = resolveMedia(deck, assets); + + const pages: PdfPage[] = deck.slides.map((slide, index) => { + if (slide.layout === "free") { + return { + width: SLIDE_SIZE.w / EMU_PER_POINT, + height: SLIDE_SIZE.h / EMU_PER_POINT, + background: slide.background + ? resolvePdfPaint(deck.theme, slide.background) + : resolveThemeHex(deck.theme, "background"), + elements: collectFreeElements(deck.theme, media, slide.elements), + }; + } + return renderPlacedSlide(deck, media, slide, index); + }); + + try { + return renderPdfPages(pages); + } catch (error) { + if (error instanceof DeckError) throw error; + throw new DeckError([ + { + path: "slides", + message: error instanceof Error ? error.message : String(error), + }, + ]); + } +} + +export const writeDeckPdf = writePdf; diff --git a/packages/slidedeck/src/write.ts b/packages/slidedeck/src/write.ts index 211a648f..5ed58a4c 100644 --- a/packages/slidedeck/src/write.ts +++ b/packages/slidedeck/src/write.ts @@ -71,11 +71,16 @@ type Media = { data: Uint8Array; info: ImageInfo | undefined; }; -type AssetUse = { ref: AssetRef; path: string; slide?: number; svg?: true }; +export type AssetUse = { + ref: AssetRef; + path: string; + slide?: number; + svg?: true; +}; const CONTENT_TYPE = "application/vnd.openxmlformats-officedocument"; const PRESENTATION = `${CONTENT_TYPE}.presentationml`; -const STACK_GAP = 137160; +export const STACK_GAP = 137160; const lines = (text: string) => text.split(/\r?\n/); @@ -163,7 +168,7 @@ function textBodyXml(blocks: readonly TextBlock[], part: SlidePart) { const isText = (block: Block): block is TextBlock => block.type === "paragraph" || block.type === "list"; -function assetUses(deck: Deck): AssetUse[] { +export function assetUses(deck: Deck): AssetUse[] { const uses: AssetUse[] = deck.theme.logo ? [{ ref: deck.theme.logo, path: "theme.logo" }] : []; @@ -197,7 +202,7 @@ const mediaKey = (ref: AssetRef, svg?: true) => svg ? `svg:${assetKey(ref)}` : assetKey(ref); /** Reads every image the deck names, reporting all missing or unreadable ones at once. */ -function resolveMedia(deck: Deck, assets: Assets) { +export function resolveMedia(deck: Deck, assets: Assets) { const byKey = new Map(); const issues: DeckIssue[] = []; for (const use of assetUses(deck)) { @@ -335,9 +340,9 @@ class SlidePart { } } -type Segment = { text: TextBlock[] } | { element: Element }; +export type Segment = { text: TextBlock[] } | { element: Element }; -function segments(blocks: readonly Block[]) { +export function segments(blocks: readonly Block[]) { const out: Segment[] = []; for (const block of blocks) { const last = out.at(-1); diff --git a/packages/slidedeck/tsconfig.json b/packages/slidedeck/tsconfig.json new file mode 100644 index 00000000..f1d5ac2e --- /dev/null +++ b/packages/slidedeck/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +}