diff --git a/README.md b/README.md index 2a1d6c5..11e94cd 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,86 @@ Result: ![Batch generation result](./examples/use-cases/batch-campaign/batch-launch-og.png) +Generate composition utilities for campaign review and lightweight data visuals: + +```bash +clickclick composition contact-sheet \ + --image examples/use-cases/orbit-social-coral.png --caption "Coral" \ + --image examples/use-cases/orbit-social-indigo.png --caption "Indigo" \ + --image examples/use-cases/orbit-social-lime.png --caption "Lime" \ + --columns 3 \ + --width 900 \ + --background "#eef2f3" \ + --out examples/use-cases/composition-contact-sheet.png + +clickclick composition qr https://github.com/mintyPT/clickclick \ + --caption "ClickClick docs" \ + --width 360 \ + --out examples/use-cases/composition-qr.png + +clickclick composition bar-chart \ + --data '[{"label":"Launch","value":42},{"label":"Gallery","value":68},{"label":"Docs","value":55}]' \ + --title "Campaign views" \ + --width 720 \ + --height 420 \ + --background "#fbfaf7" \ + --bar-color "#0f766e" \ + --out examples/use-cases/composition-chart.png +``` + +Library: + +```ts +import { barChart, imageGrid, qrCode, renderImage } from "@maurogoncalo/clickclick"; + +await renderImage({ + ...imageGrid({ + images: [ + { src: "examples/use-cases/orbit-social-coral.png", caption: "Coral" }, + { src: "examples/use-cases/orbit-social-indigo.png", caption: "Indigo" }, + { src: "examples/use-cases/orbit-social-lime.png", caption: "Lime" }, + ], + columns: 3, + width: 900, + background: "#eef2f3", + }), + output: { path: "examples/use-cases/composition-contact-sheet.png" }, +}); + +await renderImage({ + ...qrCode({ + text: "https://github.com/mintyPT/clickclick", + caption: "ClickClick docs", + width: 360, + }), + output: { path: "examples/use-cases/composition-qr.png" }, +}); + +await renderImage({ + ...barChart({ + title: "Campaign views", + data: [ + { label: "Launch", value: 42 }, + { label: "Gallery", value: 68 }, + { label: "Docs", value: 55 }, + ], + width: 720, + height: 420, + background: "#fbfaf7", + barColor: "#0f766e", + }), + output: { path: "examples/use-cases/composition-chart.png" }, +}); +``` + +Results: + +![Composition contact sheet result](./examples/use-cases/composition-contact-sheet.png) + +![Composition QR result](./examples/use-cases/composition-qr.png) + +![Composition chart result](./examples/use-cases/composition-chart.png) + Run CI-friendly quality gates: ```bash diff --git a/docs/examples.md b/docs/examples.md index e8e05fe..a9852f8 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -207,6 +207,79 @@ npm run examples:readme -- examples/use-cases/orbit-social.manifest.json npm run examples:contact-sheet -- examples/use-cases/orbit-social.manifest.json examples/use-cases/orbit-social-contact-sheet.png ``` +For first-class composition output, render contact sheets, collages, QR codes, and simple charts +through the CLI or library. Media paths stay in flags or options, so preset code remains reusable. + +```bash +clickclick composition contact-sheet \ + --image examples/use-cases/orbit-social-coral.png --caption "Coral" \ + --image examples/use-cases/orbit-social-indigo.png --caption "Indigo" \ + --image examples/use-cases/orbit-social-lime.png --caption "Lime" \ + --columns 3 \ + --width 900 \ + --background "#eef2f3" \ + --out examples/use-cases/composition-contact-sheet.png + +clickclick composition qr https://github.com/mintyPT/clickclick \ + --caption "ClickClick docs" \ + --width 360 \ + --out examples/use-cases/composition-qr.png + +clickclick composition bar-chart \ + --data '[{"label":"Launch","value":42},{"label":"Gallery","value":68},{"label":"Docs","value":55}]' \ + --title "Campaign views" \ + --width 720 \ + --height 420 \ + --background "#fbfaf7" \ + --bar-color "#0f766e" \ + --out examples/use-cases/composition-chart.png +``` + +```ts +import { barChart, collage, qrCode, renderImage } from "@maurogoncalo/clickclick"; + +await renderImage({ + ...collage({ + images: [ + { src: "examples/use-cases/orbit-social-coral.png", caption: "Coral" }, + { src: "examples/use-cases/orbit-social-indigo.png", caption: "Indigo" }, + { src: "examples/use-cases/orbit-social-lime.png", caption: "Lime" }, + ], + columns: 3, + width: 900, + background: "#eef2f3", + }), + output: { path: "examples/use-cases/composition-contact-sheet.png" }, +}); + +await renderImage({ + ...qrCode({ text: "https://github.com/mintyPT/clickclick", caption: "ClickClick docs", width: 360 }), + output: { path: "examples/use-cases/composition-qr.png" }, +}); + +await renderImage({ + ...barChart({ + title: "Campaign views", + data: [ + { label: "Launch", value: 42 }, + { label: "Gallery", value: 68 }, + { label: "Docs", value: 55 }, + ], + width: 720, + height: 420, + background: "#fbfaf7", + barColor: "#0f766e", + }), + output: { path: "examples/use-cases/composition-chart.png" }, +}); +``` + +![Composition contact sheet](../examples/use-cases/composition-contact-sheet.png) + +![Composition QR code](../examples/use-cases/composition-qr.png) + +![Composition chart](../examples/use-cases/composition-chart.png) + When adapting external references, replace source-specific names, logos, slogans, URLs, and direct media. Use fictional copy and pass any needed assets through modification JSON or documented options. Palettes may be shifted when that keeps the composition recognizable without copying the source diff --git a/examples/use-cases/composition-chart.png b/examples/use-cases/composition-chart.png new file mode 100644 index 0000000..95f5ae8 Binary files /dev/null and b/examples/use-cases/composition-chart.png differ diff --git a/examples/use-cases/composition-contact-sheet.png b/examples/use-cases/composition-contact-sheet.png new file mode 100644 index 0000000..83864ea Binary files /dev/null and b/examples/use-cases/composition-contact-sheet.png differ diff --git a/examples/use-cases/composition-qr.png b/examples/use-cases/composition-qr.png new file mode 100644 index 0000000..59f9cbf Binary files /dev/null and b/examples/use-cases/composition-qr.png differ diff --git a/package-lock.json b/package-lock.json index b3831e6..1335e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "dependencies": { "commander": "^12.1.0", "playwright": "^1.45.3", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "qrcode-generator": "^2.0.4" }, "bin": { "clickclick": "dist/cli/index.js" @@ -1663,6 +1664,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", diff --git a/package.json b/package.json index 4cf86a5..45544e1 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,8 @@ "dependencies": { "commander": "^12.1.0", "playwright": "^1.45.3", - "pngjs": "^7.0.0" + "pngjs": "^7.0.0", + "qrcode-generator": "^2.0.4" }, "devDependencies": { "@types/node": "^20.14.10", diff --git a/src/cli/index.ts b/src/cli/index.ts index 732f90b..6485e98 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -3,7 +3,7 @@ import { mkdir, readFile } from "node:fs/promises"; import { basename, dirname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import { Command } from "commander"; -import { ClickClickError, checkImageQuality, checkRenderQuality, clearCache, dataRowToLayerModifications, generateTemplateBatch, listConfigTemplates, loadBrandKit, renderImage, renderRecipe, renderTemplate, renderTemplateSet, screenshotUrl } from "../index.js"; +import { ClickClickError, barChart, checkImageQuality, checkRenderQuality, clearCache, collage, contactSheet, dataRowToLayerModifications, generateTemplateBatch, imageGrid, listConfigTemplates, loadBrandKit, qrCode, renderImage, renderRecipe, renderTemplate, renderTemplateSet, screenshotUrl } from "../index.js"; import type { BatchDataRow } from "../index.js"; import type { BrandKit, LayerModification, QualityResult, QualitySafeArea, RenderCacheOptions, RenderImageInput, RenderImageResult, RenderWarning, TemplateInput } from "../types.js"; import { collectOption, parseCacheOptions, parseInteger, parseNumber, parseOutputOptions, parseRenderOptions, parseSizeOptions } from "./options.js"; @@ -281,6 +281,169 @@ quality reportQualityResult(result, Boolean(options.strict)); }); +const composition = program.command("composition").description("Render deterministic composition utilities."); + +composition + .command("contact-sheet") + .description("Render a captioned image grid for gallery review") + .requiredOption("--image ", "Image source. Repeat for multiple images.", collectOption, []) + .option("--caption ", "Caption for the matching --image. Repeat to label multiple images.", collectOption, []) + .option("--columns ", "Grid column count", parseInteger) + .option("--gap ", "Gap between tiles", parseInteger) + .option("--padding ", "Outer padding", parseInteger) + .option("--background ", "Canvas background color") + .option("--text-color ", "Caption text color") + .option("--out, --output ", "Output image path") + .option("--width ", "Output width", parseInteger) + .option("--format ", "Output format: png or jpeg") + .option("--quality ", "JPEG quality from 0 to 100", parseInteger) + .option("--cache", "Reuse cached output for identical deterministic input") + .option("--cache-dir ", "Cache directory", ".clickclick-cache") + .option("--cache-info", "Print cache hit/miss information") + .option("--strict", "Exit non-zero when renderer warnings are produced") + .action(async (options) => { + await runRender({ + ...contactSheet({ + images: compositionImages(options), + columns: options.columns, + width: options.width, + gap: options.gap, + padding: options.padding, + background: stringOption(options.background), + textColor: stringOption(options.textColor), + }), + output: parseOutputOptions(options), + }, Boolean(options.strict), parseCacheOptions(options)); + }); + +composition + .command("grid") + .description("Render a captioned image grid") + .requiredOption("--image ", "Image source. Repeat for multiple images.", collectOption, []) + .option("--caption ", "Caption for the matching --image. Repeat to label multiple images.", collectOption, []) + .option("--columns ", "Grid column count", parseInteger) + .option("--gap ", "Gap between tiles", parseInteger) + .option("--padding ", "Outer padding", parseInteger) + .option("--background ", "Canvas background color") + .option("--text-color ", "Caption text color") + .option("--out, --output ", "Output image path") + .option("--width ", "Output width", parseInteger) + .option("--format ", "Output format: png or jpeg") + .option("--quality ", "JPEG quality from 0 to 100", parseInteger) + .option("--cache", "Reuse cached output for identical deterministic input") + .option("--cache-dir ", "Cache directory", ".clickclick-cache") + .option("--cache-info", "Print cache hit/miss information") + .option("--strict", "Exit non-zero when renderer warnings are produced") + .action(async (options) => { + await runRender({ + ...imageGrid({ + images: compositionImages(options), + columns: options.columns, + width: options.width, + gap: options.gap, + padding: options.padding, + background: stringOption(options.background), + textColor: stringOption(options.textColor), + }), + output: parseOutputOptions(options), + }, Boolean(options.strict), parseCacheOptions(options)); + }); + +composition + .command("collage") + .description("Render a compact image collage") + .requiredOption("--image ", "Image source. Repeat for multiple images.", collectOption, []) + .option("--caption ", "Caption for the matching --image. Repeat to label multiple images.", collectOption, []) + .option("--columns ", "Grid column count", parseInteger) + .option("--gap ", "Gap between tiles", parseInteger) + .option("--padding ", "Outer padding", parseInteger) + .option("--background ", "Canvas background color") + .option("--text-color ", "Caption text color") + .option("--out, --output ", "Output image path") + .option("--width ", "Output width", parseInteger) + .option("--format ", "Output format: png or jpeg") + .option("--quality ", "JPEG quality from 0 to 100", parseInteger) + .option("--cache", "Reuse cached output for identical deterministic input") + .option("--cache-dir ", "Cache directory", ".clickclick-cache") + .option("--cache-info", "Print cache hit/miss information") + .option("--strict", "Exit non-zero when renderer warnings are produced") + .action(async (options) => { + await runRender({ + ...collage({ + images: compositionImages(options), + columns: options.columns, + width: options.width, + gap: options.gap, + padding: options.padding, + background: stringOption(options.background), + textColor: stringOption(options.textColor), + }), + output: parseOutputOptions(options), + }, Boolean(options.strict), parseCacheOptions(options)); + }); + +composition + .command("qr") + .description("Render a deterministic QR code for a URL or short text") + .argument("", "URL or short text to encode") + .option("--caption ", "Caption below the QR code") + .option("--background ", "Canvas and light module color") + .option("--foreground ", "Dark module color") + .option("--text-color ", "Caption text color") + .option("--out, --output ", "Output image path") + .option("--width ", "Output width", parseInteger) + .option("--format ", "Output format: png or jpeg") + .option("--quality ", "JPEG quality from 0 to 100", parseInteger) + .option("--cache", "Reuse cached output for identical deterministic input") + .option("--cache-dir ", "Cache directory", ".clickclick-cache") + .option("--cache-info", "Print cache hit/miss information") + .option("--strict", "Exit non-zero when renderer warnings are produced") + .action(async (text: string, options) => { + await runRender({ + ...qrCode({ + text, + width: options.width, + caption: stringOption(options.caption), + background: stringOption(options.background), + foreground: stringOption(options.foreground), + textColor: stringOption(options.textColor), + }), + output: parseOutputOptions(options), + }, Boolean(options.strict), parseCacheOptions(options)); + }); + +composition + .command("bar-chart") + .description("Render a simple static bar chart from JSON data") + .requiredOption("--data ", "JSON array, object with rows, or path to a JSON file") + .option("--title ", "Chart title") + .option("--background ", "Canvas background color") + .option("--bar-color ", "Bar fill color") + .option("--text-color ", "Text color") + .option("--out, --output ", "Output image path") + .option("--width ", "Output width", parseInteger) + .option("--height ", "Output height", parseInteger) + .option("--format ", "Output format: png or jpeg") + .option("--quality ", "JPEG quality from 0 to 100", parseInteger) + .option("--cache", "Reuse cached output for identical deterministic input") + .option("--cache-dir ", "Cache directory", ".clickclick-cache") + .option("--cache-info", "Print cache hit/miss information") + .option("--strict", "Exit non-zero when renderer warnings are produced") + .action(async (options) => { + await runRender({ + ...barChart({ + data: await parseChartData(options.data), + title: stringOption(options.title), + width: options.width, + height: options.height, + background: stringOption(options.background), + barColor: stringOption(options.barColor), + textColor: stringOption(options.textColor), + }), + output: parseOutputOptions(options), + }, Boolean(options.strict), parseCacheOptions(options)); + }); + const config = program.command("config").description("Render local templates from a project config."); config @@ -483,6 +646,35 @@ function stringArrayOption(value: unknown): string[] { return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : []; } +function compositionImages(options: Record) { + const images = stringArrayOption(options.image); + const captions = stringArrayOption(options.caption); + return images.map((src, index) => ({ src, caption: captions[index] })); +} + +function stringOption(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +async function parseChartData(value: unknown) { + if (typeof value !== "string") { + throw new ClickClickError("INVALID_INPUT", "--data must be a JSON string or JSON file path."); + } + const trimmed = value.trim(); + const raw = trimmed.startsWith("[") || trimmed.startsWith("{") + ? value + : await readFileChecked(resolve(value), "Chart data"); + const rows = normalizeDataRows(parseJsonData(raw), "Chart data"); + return rows.map((row) => { + const label = row.label; + const datumValue = row.value; + if (typeof label !== "string" || typeof datumValue !== "number") { + throw new ClickClickError("INVALID_INPUT", "Chart data rows must include string label and numeric value fields."); + } + return { label, value: datumValue }; + }); +} + function reportWarnings(result: { warnings: RenderWarning[] }, strict: boolean) { for (const warning of result.warnings) { const target = "selector" in warning ? warning.selector : "layer" in warning ? warning.layer : undefined; diff --git a/src/composition/index.ts b/src/composition/index.ts new file mode 100644 index 0000000..6a29112 --- /dev/null +++ b/src/composition/index.ts @@ -0,0 +1,268 @@ +import { basename } from "node:path"; +import qrcode from "qrcode-generator"; +import { ClickClickError } from "../errors.js"; +import { serializeMediaSource } from "../media/index.js"; +import type { RenderImageInput } from "../types.js"; + +export interface CompositionImage { + src: string; + caption?: string; +} + +export interface ImageGridOptions { + images: CompositionImage[]; + columns?: number; + width?: number; + gap?: number; + padding?: number; + tileAspectRatio?: number; + captionHeight?: number; + background?: string; + textColor?: string; + baseDir?: string; +} + +export interface QrCodeOptions { + text: string; + width?: number; + padding?: number; + caption?: string; + background?: string; + foreground?: string; + textColor?: string; +} + +export interface BarChartDatum { + label: string; + value: number; +} + +export interface BarChartOptions { + data: BarChartDatum[]; + title?: string; + width?: number; + height?: number; + padding?: number; + background?: string; + barColor?: string; + textColor?: string; +} + +export function imageGrid(options: ImageGridOptions): RenderImageInput { + const images = requireImages(options.images); + const width = positiveInteger(options.width ?? 1200, "width"); + const columns = Math.min(positiveInteger(options.columns ?? Math.min(3, images.length), "columns"), images.length); + const gap = nonNegativeInteger(options.gap ?? 24, "gap"); + const padding = nonNegativeInteger(options.padding ?? 48, "padding"); + const captionHeight = nonNegativeInteger(options.captionHeight ?? 48, "captionHeight"); + const tileWidth = Math.floor((width - padding * 2 - gap * (columns - 1)) / columns); + if (tileWidth <= 0) throw new ClickClickError("INVALID_INPUT", "Image grid width is too small for the requested columns, gap, and padding."); + const tileHeight = Math.round(tileWidth / positiveNumber(options.tileAspectRatio ?? 1, "tileAspectRatio")); + const rows = Math.ceil(images.length / columns); + const height = padding * 2 + rows * (tileHeight + captionHeight) + (rows - 1) * gap; + const serialized = images.map((image) => ({ + src: serializeMediaSource(image.src, options.baseDir), + caption: image.caption ?? basename(image.src).replace(/\.[^.]+$/, ""), + })); + + return { + document: { + html: `
${serialized.map((image) => ` +
+ ${escapeAttribute(image.caption)} +
${escapeHtml(image.caption)}
+
`).join("")} +
`, + css: ` + html, body { margin: 0; width: 100%; min-height: 100%; background: ${options.background ?? "#f6f4ef"}; } + body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${options.textColor ?? "#1f2933"}; } + .image-grid { + box-sizing: border-box; + width: ${width}px; + min-height: ${height}px; + display: grid; + grid-template-columns: repeat(${columns}, ${tileWidth}px); + gap: ${gap}px; + padding: ${padding}px; + background: ${options.background ?? "#f6f4ef"}; + } + figure { margin: 0; width: ${tileWidth}px; } + img { + display: block; + width: ${tileWidth}px; + height: ${tileHeight}px; + object-fit: cover; + background: rgba(0, 0, 0, 0.08); + } + figcaption { + box-sizing: border-box; + height: ${captionHeight}px; + padding-top: 10px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + line-height: 22px; + font-weight: 650; + } + `, + }, + viewport: { width, height }, + }; +} + +export const contactSheet = imageGrid; + +export function collage(options: ImageGridOptions): RenderImageInput { + const input = imageGrid(options); + input.document.html = input.document.html.replace('class="image-grid"', 'class="image-grid collage"'); + return input; +} + +export function qrCode(options: QrCodeOptions): RenderImageInput { + if (!options.text) throw new ClickClickError("INVALID_INPUT", "QR code text is required."); + const width = positiveInteger(options.width ?? 512, "width"); + const padding = nonNegativeInteger(options.padding ?? 24, "padding"); + const captionHeight = options.caption ? 38 : 0; + const qr = qrcode(0, "M"); + qr.addData(options.text); + qr.make(); + const moduleCount = qr.getModuleCount(); + const cellSize = Math.floor((width - padding * 2) / moduleCount); + if (cellSize <= 0) throw new ClickClickError("INVALID_INPUT", "QR code width is too small for the encoded text."); + const codeSize = cellSize * moduleCount; + const height = width + captionHeight; + const cells: string[] = []; + for (let row = 0; row < moduleCount; row += 1) { + for (let column = 0; column < moduleCount; column += 1) { + cells.push(``); + } + } + + return { + document: { + html: `
+
${cells.join("")}
+ ${options.caption ? `

${escapeHtml(options.caption)}

` : ""} +
`, + css: ` + html, body { margin: 0; width: 100%; min-height: 100%; background: ${options.background ?? "#ffffff"}; } + body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${options.textColor ?? "#111827"}; } + .qr-code { + box-sizing: border-box; + width: ${width}px; + min-height: ${height}px; + padding: ${padding}px; + display: flex; + flex-direction: column; + align-items: center; + background: ${options.background ?? "#ffffff"}; + } + .qr-matrix { + display: grid; + grid-template-columns: repeat(${moduleCount}, ${cellSize}px); + width: ${codeSize}px; + height: ${codeSize}px; + } + .qr-cell { width: ${cellSize}px; height: ${cellSize}px; } + .qr-cell.dark { background: ${options.foreground ?? "#111827"}; } + .qr-cell.light { background: ${options.background ?? "#ffffff"}; } + p { margin: 14px 0 0; height: 24px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 18px; line-height: 24px; font-weight: 650; } + `, + }, + viewport: { width, height }, + }; +} + +export function barChart(options: BarChartOptions): RenderImageInput { + const data = requireChartData(options.data); + const width = positiveInteger(options.width ?? 1200, "width"); + const height = positiveInteger(options.height ?? 630, "height"); + const padding = nonNegativeInteger(options.padding ?? 48, "padding"); + const max = Math.max(...data.map((datum) => datum.value)); + + return { + document: { + html: `
+ ${options.title ? `

${escapeHtml(options.title)}

` : ""} +
${data.map((datum) => { + const percent = max === 0 ? 0 : Math.round((datum.value / max) * 100); + return `
+
+ ${escapeHtml(String(datum.value))} + ${escapeHtml(datum.label)} +
`; + }).join("")}
+
`, + css: ` + html, body { margin: 0; width: 100%; height: 100%; background: ${options.background ?? "#ffffff"}; } + body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: ${options.textColor ?? "#172033"}; } + .bar-chart { + box-sizing: border-box; + width: ${width}px; + height: ${height}px; + padding: ${padding}px; + display: flex; + flex-direction: column; + gap: 28px; + background: ${options.background ?? "#ffffff"}; + } + h1 { margin: 0; font-size: 34px; line-height: 1.15; letter-spacing: 0; } + .bars { flex: 1; display: grid; grid-template-columns: repeat(${data.length}, minmax(0, 1fr)); gap: 22px; align-items: end; } + .bar-item { min-width: 0; height: 100%; display: grid; grid-template-rows: 1fr 24px 24px; gap: 8px; text-align: center; } + .bar-track { height: 100%; display: flex; align-items: end; background: rgba(23, 32, 51, 0.08); } + .bar-fill { width: 100%; min-height: 2px; background: ${options.barColor ?? "#2563eb"}; } + strong, span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + strong { font-size: 18px; line-height: 24px; } + span { font-size: 15px; line-height: 24px; } + `, + }, + viewport: { width, height }, + }; +} + +function requireImages(images: CompositionImage[] | undefined): CompositionImage[] { + if (!Array.isArray(images) || images.length === 0) throw new ClickClickError("INVALID_INPUT", "At least one image is required."); + for (const image of images) { + if (!image || typeof image.src !== "string" || image.src.length === 0) throw new ClickClickError("INVALID_INPUT", "Each image requires a src."); + } + return images; +} + +function requireChartData(data: BarChartDatum[] | undefined): BarChartDatum[] { + if (!Array.isArray(data) || data.length === 0) throw new ClickClickError("INVALID_INPUT", "At least one chart datum is required."); + for (const datum of data) { + if (!datum || typeof datum.label !== "string" || !Number.isFinite(datum.value) || datum.value < 0) { + throw new ClickClickError("INVALID_INPUT", "Chart data must contain labels and non-negative numeric values."); + } + } + return data; +} + +function positiveInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value <= 0) throw new ClickClickError("INVALID_INPUT", `${name} must be a positive integer.`); + return value; +} + +function nonNegativeInteger(value: number, name: string): number { + if (!Number.isInteger(value) || value < 0) throw new ClickClickError("INVALID_INPUT", `${name} must be a non-negative integer.`); + return value; +} + +function positiveNumber(value: number, name: string): number { + if (!Number.isFinite(value) || value <= 0) throw new ClickClickError("INVALID_INPUT", `${name} must be a positive number.`); + return value; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function escapeAttribute(value: string): string { + return escapeHtml(value).replaceAll("`", "`"); +} diff --git a/src/index.ts b/src/index.ts index aaafb3b..69fb04f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export { clearCache } from "./cache/index.js"; export { applyBrandToPresetOptions, brandFonts, brandKitJsonSchema, brandTemplateCss, brandTemplateModifications, loadBrandKit, validateBrandKit } from "./brand-kit/index.js"; export { ClickClickError } from "./errors.js"; +export { barChart, collage, contactSheet, imageGrid, qrCode } from "./composition/index.js"; export { serializeMediaSource } from "./media/index.js"; export { dataRowToLayerModifications, generateTemplateBatch, interpolateOutputPattern } from "./generate/index.js"; export { createRenderer, renderImage, screenshotUrl } from "./renderer/index.js"; @@ -21,6 +22,13 @@ export type { export type { ClickClickErrorCode, } from "./errors.js"; +export type { + BarChartDatum, + BarChartOptions, + CompositionImage, + ImageGridOptions, + QrCodeOptions, +} from "./composition/index.js"; export type { BatchDataRow, BatchDataValue, diff --git a/test/cli.test.ts b/test/cli.test.ts index 0c37e89..4cfbead 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -56,6 +56,57 @@ describe("CLI", () => { await expect(readFile(presetOut)).resolves.toHaveProperty("length"); }); + it("renders composition contact sheets, QR codes, and charts", async () => { + const imagePath = join(tempDir, "composition-tile.png"); + const sheetOut = join(tempDir, "composition-sheet.png"); + const gridOut = join(tempDir, "composition-grid.png"); + const qrOut = join(tempDir, "composition-qr.png"); + const chartOut = join(tempDir, "composition-chart.png"); + const dataPath = join(tempDir, "composition-chart.json"); + + await writeSolidPng(imagePath, [30, 120, 220, 255]); + await writeFile(dataPath, JSON.stringify([ + { label: "Gallery", value: 8 }, + { label: "Campaign", value: 12 }, + ])); + + await runCli([ + "composition", + "contact-sheet", + "--image", + imagePath, + "--caption", + "Hero", + "--out", + sheetOut, + "--width", + "128", + "--columns", + "1", + ]); + await runCli([ + "composition", + "grid", + "--image", + imagePath, + "--caption", + "Grid", + "--out", + gridOut, + "--width", + "128", + "--columns", + "1", + ]); + await runCli(["composition", "qr", "https://github.com/mintyPT/clickclick", "--caption", "Docs", "--out", qrOut, "--width", "128"]); + await runCli(["composition", "bar-chart", "--data", dataPath, "--title", "Results", "--out", chartOut, "--width", "180", "--height", "120"]); + + expect(PNG.sync.read(await readFile(sheetOut))).toMatchObject({ width: 128 }); + expect(PNG.sync.read(await readFile(gridOut))).toMatchObject({ width: 128 }); + expect(PNG.sync.read(await readFile(qrOut))).toMatchObject({ width: 128 }); + expect(PNG.sync.read(await readFile(chartOut))).toMatchObject({ width: 180, height: 120 }); + }, 60000); + it("renders raw HTML to multiple deterministic output sizes", async () => { const htmlPath = join(tempDir, "multi-card.html"); const outDir = join(tempDir, "multi-raw"); diff --git a/test/composition.test.ts b/test/composition.test.ts new file mode 100644 index 0000000..e0b67bf --- /dev/null +++ b/test/composition.test.ts @@ -0,0 +1,89 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { mkdtemp } from "node:fs/promises"; +import { PNG } from "pngjs"; +import { describe, expect, it } from "vitest"; +import { barChart, collage, contactSheet, imageGrid, qrCode } from "../src/index.js"; + +describe("composition library helpers", () => { + it("builds an image grid document with deterministic layout, captions, spacing, and background", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "clickclick-composition-")); + const imagePath = join(tempDir, "tile.png"); + await writeSolidPng(imagePath, [255, 0, 0, 255]); + + const input = imageGrid({ + images: [{ src: imagePath, caption: "Launch" }], + columns: 1, + width: 120, + gap: 12, + padding: 16, + background: "#102030", + }); + + expect(input.viewport).toEqual({ width: 120, height: 168 }); + expect(input.document.css).toContain("gap: 12px"); + expect(input.document.css).toContain("background: #102030"); + expect(input.document.html).toContain("Launch"); + expect(input.document.html).toContain("data:image/png;base64,"); + expect(contactSheet({ + images: [{ src: imagePath, caption: "Launch" }], + columns: 1, + width: 120, + }).document.html).toContain("Launch"); + }); + + it("exposes collages as a named image-grid workflow", async () => { + const input = collage({ + images: [ + { src: "data:image/svg+xml,%3Csvg%3E%3C/svg%3E", caption: "A" }, + { src: "data:image/svg+xml,%3Csvg%3E%3C/svg%3E", caption: "B" }, + ], + columns: 2, + width: 240, + }); + + expect(input.document.html).toContain("image-grid collage"); + expect(input.viewport?.width).toBe(240); + }); + + it("creates deterministic QR documents for short text and URLs", () => { + const first = qrCode({ text: "https://github.com/mintyPT/clickclick", width: 160, caption: "Docs" }); + const second = qrCode({ text: "https://github.com/mintyPT/clickclick", width: 160, caption: "Docs" }); + + expect(first).toEqual(second); + expect(first.viewport).toEqual({ width: 160, height: 198 }); + expect(first.document.html).toContain("qr-cell dark"); + expect(first.document.html).toContain("Docs"); + }); + + it("creates static bar chart documents from numeric data", () => { + const input = barChart({ + title: "Campaign results", + data: [ + { label: "A", value: 8 }, + { label: "B", value: 12 }, + ], + width: 320, + height: 220, + background: "#ffffff", + }); + + expect(input.viewport).toEqual({ width: 320, height: 220 }); + expect(input.document.html).toContain("Campaign results"); + expect(input.document.html).toContain("height: 67%"); + expect(input.document.html).toContain("height: 100%"); + }); +}); + +async function writeSolidPng(path: string, rgba: [number, number, number, number]) { + const png = new PNG({ width: 8, height: 8 }); + for (let offset = 0; offset < png.data.length; offset += 4) { + png.data[offset] = rgba[0]; + png.data[offset + 1] = rgba[1]; + png.data[offset + 2] = rgba[2]; + png.data[offset + 3] = rgba[3]; + } + await writeFile(path, PNG.sync.write(png)); + await expect(readFile(path)).resolves.toHaveProperty("length"); +}